Skip to main content

rab/tui/components/
editor.rs

1#![allow(clippy::type_complexity)]
2
3use crate::tui::autocomplete::AutocompleteProvider;
4use crate::tui::component::Component;
5use crate::tui::components::select_list::{SelectItem, SelectList, SelectListTheme};
6use crate::tui::focusable::{CURSOR_MARKER, Focusable};
7use crate::tui::keybindings::{
8    ACTION_EDITOR_CURSOR_DOWN, ACTION_EDITOR_CURSOR_LEFT, ACTION_EDITOR_CURSOR_LINE_END,
9    ACTION_EDITOR_CURSOR_LINE_START, ACTION_EDITOR_CURSOR_RIGHT, ACTION_EDITOR_CURSOR_UP,
10    ACTION_EDITOR_CURSOR_WORD_LEFT, ACTION_EDITOR_CURSOR_WORD_RIGHT,
11    ACTION_EDITOR_DELETE_CHAR_BACKWARD, ACTION_EDITOR_DELETE_CHAR_FORWARD,
12    ACTION_EDITOR_DELETE_TO_LINE_END, ACTION_EDITOR_DELETE_TO_LINE_START,
13    ACTION_EDITOR_DELETE_WORD_BACKWARD, ACTION_EDITOR_DELETE_WORD_FORWARD,
14    ACTION_EDITOR_JUMP_BACKWARD, ACTION_EDITOR_JUMP_FORWARD, ACTION_EDITOR_PAGE_DOWN,
15    ACTION_EDITOR_PAGE_UP, ACTION_EDITOR_UNDO, ACTION_EDITOR_YANK, ACTION_EDITOR_YANK_POP,
16    ACTION_INPUT_NEW_LINE, ACTION_INPUT_SUBMIT, ACTION_INPUT_TAB, ACTION_SELECT_CANCEL,
17    ACTION_SELECT_CONFIRM, ACTION_SELECT_DOWN, ACTION_SELECT_UP, get_keybindings,
18};
19use crate::tui::keys::key_event_to_string;
20use crate::tui::kill_ring::KillRing;
21use crate::tui::util::is_whitespace_char;
22use std::collections::HashMap;
23
24use crate::tui::undo_stack::UndoStack;
25use crate::tui::util::{visible_width, visual_col_to_byte_offset, wrap_text_with_ansi};
26use crate::tui::word_nav::{
27    WordNavigationOptions, find_word_backward_with, find_word_forward_with,
28};
29use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
30use unicode_segmentation::UnicodeSegmentation;
31
32/// Direction for character jump mode (pi-style).
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34enum JumpDirection {
35    Forward,
36    Backward,
37}
38
39pub struct EditorOptions {
40    pub padding_x: usize,
41}
42
43impl Default for EditorOptions {
44    fn default() -> Self {
45        Self { padding_x: 1 }
46    }
47}
48
49// ── Editor ─────────────────────────────────────────────────────────
50
51pub struct Editor {
52    lines: Vec<String>,
53    cursor_line: usize,
54    cursor_col: usize,
55    padding_x: usize,
56    scroll_offset: usize,
57
58    focused: bool,
59    kill_ring: KillRing,
60    undo_stack: UndoStack<EditorSnapshot>,
61    history: Vec<String>,
62    history_index: i32,
63    history_draft: Option<EditorSnapshot>,
64    preferred_col: Option<usize>,
65    last_width: std::cell::Cell<usize>,
66    last_action: Option<String>,
67    pub on_submit: Option<Box<dyn FnMut(String) + Send>>,
68    pub on_change: Option<Box<dyn FnMut(&str)>>,
69    pub disable_submit: bool,
70    pub border_color: crate::tui::Style,
71
72    // Character jump mode (pi-style: await next printable char to jump to)
73    jump_mode: Option<JumpDirection>,
74
75    // Pi-style autocomplete provider (handles slash commands, file paths, etc.)
76    autocomplete_provider: Option<Box<dyn AutocompleteProvider>>,
77
78    // Pi-style paste markers (large pastes stored, marker inserted in place)
79    pastes: HashMap<u32, String>,
80    paste_counter: u32,
81
82    /// True after submit() is called, reset when checked.
83    pub just_submitted: bool,
84    /// Text that was submitted in the last submit() call, captured before clearing.
85    pub last_submitted_text: String,
86
87    // Pi-style autocomplete state (uses SelectList)
88    /// Terminal height for dynamic max-visible-lines (pi: 30% of rows, min 5).
89    terminal_rows: usize,
90    autocomplete_max_visible: usize,
91    autocomplete_list: Option<SelectList>,
92    pub autocomplete_active: bool,
93    /// The prefix from the provider's last get_suggestions call.
94    /// Used instead of recomputing at selection time to avoid mismatches
95    /// (e.g. `@src/au` → provider strips `@`, returns prefix `src/au`).
96    autocomplete_prefix: String,
97    /// Debounce: minimum time between autocomplete provider calls for @/# triggers.
98    /// Pi uses 20ms for attachment autocomplete, 0ms for slash commands.
99    last_autocomplete_trigger: std::time::Instant,
100}
101
102#[derive(Debug, Clone)]
103struct EditorSnapshot {
104    lines: Vec<String>,
105    cursor_line: usize,
106    cursor_col: usize,
107}
108
109impl Editor {
110    pub fn new(options: EditorOptions) -> Self {
111        Self {
112            lines: vec![String::new()],
113            cursor_line: 0,
114            cursor_col: 0,
115            padding_x: options.padding_x,
116            scroll_offset: 0,
117
118            focused: false,
119            kill_ring: KillRing::new(),
120            undo_stack: UndoStack::new(),
121            history: Vec::new(),
122            history_index: -1,
123            history_draft: None,
124            preferred_col: None,
125            last_width: std::cell::Cell::new(80),
126            last_action: None,
127            on_submit: None,
128            on_change: None,
129            disable_submit: false,
130            terminal_rows: 24,
131            autocomplete_max_visible: 5,
132            autocomplete_list: None,
133            autocomplete_active: false,
134            autocomplete_prefix: String::new(),
135            last_autocomplete_trigger: std::time::Instant::now(),
136            border_color: crate::tui::Style::default(),
137            autocomplete_provider: None,
138            pastes: HashMap::new(),
139            paste_counter: 0,
140            just_submitted: false,
141            last_submitted_text: String::new(),
142            jump_mode: None,
143        }
144    }
145
146    // ── Public API ──
147
148    pub fn get_text(&self) -> String {
149        self.lines.join("\n")
150    }
151
152    pub fn get_lines(&self) -> &[String] {
153        &self.lines
154    }
155
156    pub fn get_cursor(&self) -> (usize, usize) {
157        (self.cursor_line, self.cursor_col)
158    }
159
160    /// Update the terminal height so render can compute max visible lines
161    /// dynamically (pi: 30% of rows, min 5).
162    pub fn set_terminal_rows(&mut self, rows: usize) {
163        self.terminal_rows = rows;
164    }
165
166    pub fn set_padding_x(&mut self, padding: usize) {
167        self.padding_x = padding;
168    }
169
170    pub fn set_autocomplete_max_visible(&mut self, max: usize) {
171        self.autocomplete_max_visible = max.clamp(3, 20);
172    }
173
174    /// Internal: set text without undo/autocomplete (used by history navigation).
175    fn set_text_internal(&mut self, text: &str) {
176        self.lines = if text.is_empty() {
177            vec![String::new()]
178        } else {
179            text.split('\n').map(|s| s.to_string()).collect()
180        };
181        self.cursor_line = self.lines.len().saturating_sub(1);
182        self.cursor_col = self.lines.last().map_or(0, |l| l.len());
183        self.scroll_offset = 0;
184        self.preferred_col = None;
185    }
186
187    pub fn set_text(&mut self, text: &str) {
188        // Pi: cancel autocomplete, push undo if content differs, then fire onChange
189        self.clear_autocomplete();
190        self.last_action = None;
191        self.exit_history();
192        if self.get_text() != text {
193            self.push_undo();
194        }
195        self.set_text_internal(text);
196        self.notify_change();
197    }
198
199    pub fn add_to_history(&mut self, text: &str) {
200        let trimmed = text.trim().to_string();
201        if trimmed.is_empty() {
202            return;
203        }
204        // Skip consecutive duplicates (pi-style)
205        if !self.history.is_empty() && self.history[0] == trimmed {
206            return;
207        }
208        self.history.insert(0, trimmed);
209        if self.history.len() > 100 {
210            self.history.pop();
211        }
212        self.history_index = -1;
213    }
214
215    pub fn insert_text_at_cursor(&mut self, text: &str) {
216        self.clear_autocomplete();
217        self.exit_history();
218        self.last_action = None;
219        self.push_undo();
220        self.insert_text_internal(text);
221    }
222
223    // ── Autocomplete (pi-style: uses SelectList) ──
224
225    /// Set the autocomplete provider (handles slash commands, file paths, etc.).
226    pub fn set_autocomplete_provider(&mut self, provider: Box<dyn AutocompleteProvider>) {
227        self.autocomplete_provider = Some(provider);
228    }
229
230    pub fn set_autocomplete(&mut self, items: Vec<SelectItem>) {
231        if items.is_empty() {
232            self.autocomplete_active = false;
233            self.autocomplete_list = None;
234            return;
235        }
236        self.set_autocomplete_with_layout(items, None);
237    }
238
239    /// Set autocomplete items with an optional custom layout.
240    /// Pi-style: slash commands use a special layout with wider primary column.
241    fn set_autocomplete_with_layout(
242        &mut self,
243        items: Vec<SelectItem>,
244        layout: Option<crate::tui::components::select_list::SelectListLayoutOptions>,
245    ) {
246        if items.is_empty() {
247            self.autocomplete_active = false;
248            self.autocomplete_list = None;
249            return;
250        }
251        let theme = SelectListTheme {
252            selected_prefix: Box::new(|s| {
253                format!("\x1b[7m\x1b[38;2;138;190;183m→ {}\x1b[27m\x1b[39m", s)
254            }),
255            selected_text: Box::new(|s| {
256                format!("\x1b[7m\x1b[38;2;138;190;183m{}\x1b[27m\x1b[39m", s)
257            }),
258            normal_text: Box::new(|s| format!("\x1b[38;2;128;128;128m{}\x1b[39m", s)),
259            description: Box::new(|s| format!("\x1b[38;2;128;128;128m{}\x1b[39m", s)),
260            scroll_info: crate::tui::Style::new().fg("\x1b[38;2;128;128;128m".to_string()),
261            no_match: crate::tui::Style::new(),
262            hint: crate::tui::Style::new(),
263        };
264        // Pi-style: pre-select the best matching item (exact match > prefix match)
265        let best = self.best_autocomplete_index(&items);
266        let mut list = SelectList::new(items, self.autocomplete_max_visible, theme, layout);
267        list.set_selected_index(best);
268        self.autocomplete_list = Some(list);
269        self.autocomplete_active = true;
270    }
271
272    /// Find the best autocomplete item index for the current prefix.
273    /// Returns 0 if no match (same as pi's default).
274    fn best_autocomplete_index(&self, items: &[SelectItem]) -> usize {
275        let prefix = self.autocomplete_prefix.trim_start_matches(['/', '@', '#']);
276        if prefix.is_empty() {
277            return 0;
278        }
279        let mut first_prefix = None;
280        for (i, item) in items.iter().enumerate() {
281            if item.value == prefix {
282                return i; // Exact match always wins
283            }
284            if first_prefix.is_none() && item.value.starts_with(prefix) {
285                first_prefix = Some(i);
286            }
287        }
288        first_prefix.unwrap_or(0)
289    }
290
291    pub fn clear_autocomplete(&mut self) {
292        self.autocomplete_active = false;
293        self.autocomplete_list = None;
294        self.autocomplete_prefix.clear();
295    }
296
297    /// Apply completion using a pre-selected value.
298    fn apply_autocomplete_completion_value(&mut self, val: &str) {
299        if let Some(ref provider) = self.autocomplete_provider {
300            let prefix = if !self.autocomplete_prefix.is_empty() {
301                self.autocomplete_prefix.clone()
302            } else {
303                self.get_autocomplete_prefix()
304            };
305            let item = crate::tui::autocomplete::AutocompleteItem {
306                value: val.to_string(),
307                label: val.to_string(),
308                description: None,
309            };
310            let (new_lines, new_line, new_col) = provider.apply_completion(
311                &self.lines,
312                self.cursor_line,
313                self.cursor_col,
314                &item,
315                &prefix,
316            );
317            self.lines = new_lines;
318            self.cursor_line = new_line;
319            self.cursor_col = new_col;
320        } else {
321            self.set_text(&format!("/{} ", val));
322        }
323    }
324
325    /// After cursor movement, re-query autocomplete if active (pi-style).
326    /// Keeps the picker in sync with the new cursor position - closes when
327    /// the new position yields no suggestions, refreshes otherwise.
328    fn update_autocomplete_if_active(&mut self) {
329        if self.autocomplete_active {
330            self.try_trigger_autocomplete();
331        }
332    }
333
334    /// Pi-style: after backspace/delete that dismissed autocomplete,
335    /// re-trigger if cursor is still in a completable context.
336    fn retrigger_autocomplete_dismissed(&mut self) {
337        if self.autocomplete_active {
338            return; // not dismissed
339        }
340        // Pi: check slash command context first (only line 0, starts with /)
341        if self.is_in_slash_command_context() {
342            self.try_trigger_autocomplete();
343            return;
344        }
345        let line = self
346            .lines
347            .get(self.cursor_line)
348            .map(|l| l.as_str())
349            .unwrap_or("");
350        let before = &line[..self.cursor_col.min(line.len())];
351        // Check @/#/~ is at token start
352        if before.contains('@') || before.contains('#') || before.contains('~') {
353            self.try_trigger_autocomplete();
354        }
355    }
356
357    pub fn autocomplete_selected_value(&self) -> Option<String> {
358        self.autocomplete_list
359            .as_ref()
360            .and_then(|l| l.selected_item())
361            .map(|item| item.value.clone())
362    }
363
364    pub fn autocomplete_is_empty(&self) -> bool {
365        self.autocomplete_list
366            .as_ref()
367            .is_none_or(|l| l.items().is_empty())
368    }
369
370    // ── Undo ──
371
372    // Pi fish-style undo coalescing:
373    // - Consecutive word chars coalesce into one undo unit
374    // - Space captures state before itself (undo removes space + following word)
375    fn maybe_push_undo(&mut self, ch: &str) {
376        if is_whitespace_char(ch) || self.last_action.as_deref() != Some("type_word") {
377            self.undo_stack.push(&EditorSnapshot {
378                lines: self.lines.clone(),
379                cursor_line: self.cursor_line,
380                cursor_col: self.cursor_col,
381            });
382        }
383        self.last_action = Some("type_word".into());
384    }
385
386    fn push_undo(&mut self) {
387        self.undo_stack.push(&EditorSnapshot {
388            lines: self.lines.clone(),
389            cursor_line: self.cursor_line,
390            cursor_col: self.cursor_col,
391        });
392    }
393
394    fn undo(&mut self) {
395        if let Some(snap) = self.undo_stack.pop() {
396            self.lines = snap.lines;
397            self.cursor_line = snap.cursor_line;
398            self.cursor_col = snap.cursor_col;
399            self.preferred_col = None;
400        }
401    }
402
403    // ── Cursor ──
404
405    fn set_cursor_col(&mut self, col: usize) {
406        self.cursor_col = col;
407        self.preferred_col = None;
408    }
409
410    // ── Text insertion ──
411
412    fn insert_text_internal(&mut self, text: &str) {
413        if text.is_empty() {
414            return;
415        }
416        let normalized = text.replace("\r\n", "\n").replace('\t', "    ");
417        let inserted_lines: Vec<&str> = normalized.split('\n').collect();
418        let current_line = self.lines[self.cursor_line].clone();
419        let before = &current_line[..self.cursor_col.min(current_line.len())];
420        let after = &current_line[self.cursor_col.min(current_line.len())..];
421
422        if inserted_lines.len() == 1 {
423            self.lines[self.cursor_line] = format!("{}{}{}", before, normalized, after);
424            self.set_cursor_col(self.cursor_col + normalized.len());
425        } else {
426            let mut new_lines: Vec<String> = Vec::new();
427            new_lines.extend(self.lines[..self.cursor_line].iter().cloned());
428            new_lines.push(format!("{}{}", before, inserted_lines[0]));
429            for line in &inserted_lines[1..inserted_lines.len() - 1] {
430                new_lines.push(line.to_string());
431            }
432            new_lines.push(format!("{}{}", inserted_lines.last().unwrap_or(&""), after));
433            new_lines.extend(self.lines[self.cursor_line + 1..].iter().cloned());
434            self.lines = new_lines;
435            self.cursor_line += inserted_lines.len() - 1;
436            self.set_cursor_col(inserted_lines.last().map_or(0, |l| l.len()));
437        }
438        self.notify_change();
439    }
440
441    fn insert_character(&mut self, ch: &str) {
442        self.exit_history();
443        self.maybe_push_undo(ch);
444        self.insert_text_internal(ch);
445
446        // Pi-style autocomplete: trigger or update after character insertion
447        self.update_autocomplete(ch);
448    }
449
450    /// Check if the just-typed character should trigger or update autocomplete.
451    /// Pi behavior: / at start of line, @ and # at token boundaries,
452    /// and letters when already in a slash command context.
453    /// When autocomplete is already active, re-triggers to update suggestions.
454    /// Pi: slash menu only allowed on the first line of the editor.
455    fn is_slash_menu_allowed(&self) -> bool {
456        self.cursor_line == 0
457    }
458
459    /// Pi: check if cursor is at start of message (for slash command detection).
460    fn is_at_start_of_message(&self) -> bool {
461        if !self.is_slash_menu_allowed() {
462            return false;
463        }
464        let line = self
465            .lines
466            .get(self.cursor_line)
467            .map(|l| l.as_str())
468            .unwrap_or("");
469        let before = &line[..self.cursor_col.min(line.len())];
470        let trimmed = before.trim();
471        trimmed.is_empty() || trimmed == "/"
472    }
473
474    /// Pi: check if cursor is in a slash command context (starts with /, slash menu allowed).
475    fn is_in_slash_command_context(&self) -> bool {
476        if !self.is_slash_menu_allowed() {
477            return false;
478        }
479        let line = self
480            .lines
481            .get(self.cursor_line)
482            .map(|l| l.as_str())
483            .unwrap_or("");
484        let before = &line[..self.cursor_col.min(line.len())];
485        before.trim_start().starts_with('/')
486    }
487
488    fn update_autocomplete(&mut self, ch: &str) {
489        // If autocomplete is already active, always re-trigger to update
490        if self.autocomplete_active {
491            self.try_trigger_autocomplete();
492            return;
493        }
494        let current_line = &self.lines[self.cursor_line];
495        let text_before = &current_line[..self.cursor_col.min(current_line.len())];
496
497        // / at start of message (pi: checks isAtStartOfMessage)
498        if ch == "/" && self.is_at_start_of_message() {
499            self.try_trigger_autocomplete();
500            return;
501        }
502
503        // / as file path trigger (not slash command) – after @/#/~ or space
504        if ch == "/" && !self.is_at_start_of_message() {
505            let before_char = text_before.chars().nth_back(1);
506            if text_before.len() >= 2
507                && before_char
508                    .is_some_and(|c| c == '~' || c == '@' || c == '#' || c.is_whitespace())
509            {
510                self.try_trigger_autocomplete();
511                return;
512            }
513        }
514
515        // @ and # at token boundaries
516        if ch == "@" || ch == "#" {
517            let before_char = text_before.chars().nth_back(1);
518            if text_before.len() == 1
519                || before_char.is_none_or(|c| c.is_whitespace() || c == ' ' || c == '\t')
520            {
521                self.try_trigger_autocomplete();
522                return;
523            }
524        }
525
526        // ~ at token boundary → file path completion
527        if ch == "~" {
528            let before_char = text_before.chars().nth_back(1);
529            if text_before.len() == 1
530                || before_char.is_none_or(|c| c.is_whitespace() || c == ' ' || c == '\t')
531            {
532                self.try_trigger_autocomplete();
533                return;
534            }
535        }
536
537        // Provider trigger characters (e.g. custom providers that use +, :, etc.)
538        if let Some(ref provider) = self.autocomplete_provider {
539            for tc in provider.trigger_characters() {
540                if ch.len() == 1 && ch == tc.to_string() && tc != &'/' && tc != &'@' && tc != &'#'
541                // already handled above
542                {
543                    let before_char = text_before.chars().nth_back(1);
544                    if text_before.len() == 1
545                        || before_char.is_none_or(|c| c.is_whitespace() || c == ' ' || c == '\t')
546                    {
547                        self.try_trigger_autocomplete();
548                        return;
549                    }
550                }
551            }
552        }
553
554        // Letters when in a slash command context (pi: only on first line)
555        if ch.len() == 1
556            && ch
557                .chars()
558                .next()
559                .is_some_and(|c| c.is_alphanumeric() || c == '-' || c == '_')
560        {
561            if self.is_in_slash_command_context() && !text_before.trim_start().contains(' ') {
562                self.try_trigger_autocomplete();
563                return;
564            }
565            // Also trigger for @, #, and ~ contexts
566            if text_before.contains('@') || text_before.contains('#') || text_before.contains('~') {
567                self.try_trigger_autocomplete();
568            }
569        }
570    }
571
572    /// Get the autocomplete prefix for the current cursor position.
573    fn get_autocomplete_prefix(&self) -> String {
574        let line = self
575            .lines
576            .get(self.cursor_line)
577            .map(|l| l.as_str())
578            .unwrap_or("");
579        let before = &line[..self.cursor_col.min(line.len())];
580        // Find the last token boundary
581        if before.starts_with('/') && !before.contains(' ') {
582            before.to_string()
583        } else if let Some(pos) = before.rfind(['@', '#']) {
584            before[pos..].to_string()
585        } else if let Some(pos) = before.rfind(|c: char| c.is_whitespace()) {
586            before[pos + 1..].to_string()
587        } else {
588            before.to_string()
589        }
590    }
591
592    /// Trigger autocomplete.
593    ///
594    /// When `force` is true (Tab key):
595    /// - 1 match → complete immediately (no selector)
596    /// - Otherwise → open the selector
597    ///
598    /// When `force` is false (automatic on typing), always opens the selector.
599    fn trigger_autocomplete(&mut self, force: bool) {
600        let Some(ref provider) = self.autocomplete_provider else {
601            return;
602        };
603
604        // Debounce: for non-slash, non-force triggers (attachment @, #),
605        // skip if called within 20ms of the last call. Pi uses 20ms for
606        // attachment autocomplete to avoid flickering during rapid typing.
607        if !force {
608            let line = self
609                .lines
610                .get(self.cursor_line)
611                .map(|l| l.as_str())
612                .unwrap_or("");
613            let before = &line[..self.cursor_col.min(line.len())];
614            let is_slash = before.starts_with('/');
615            if !is_slash && !before.is_empty() {
616                let elapsed = self.last_autocomplete_trigger.elapsed();
617                if elapsed < std::time::Duration::from_millis(20) {
618                    return;
619                }
620            }
621        }
622        self.last_autocomplete_trigger = std::time::Instant::now();
623
624        let Some(suggestions) =
625            provider.get_suggestions(&self.lines, self.cursor_line, self.cursor_col, force)
626        else {
627            self.clear_autocomplete();
628            return;
629        };
630
631        let items = suggestions.items;
632        let prefix = suggestions.prefix;
633
634        if items.is_empty() {
635            self.clear_autocomplete();
636            return;
637        }
638
639        // Pi behavior: on Tab (force), single match → complete immediately with no selector
640        if force && items.len() == 1 {
641            let (new_lines, new_line, new_col) = provider.apply_completion(
642                &self.lines,
643                self.cursor_line,
644                self.cursor_col,
645                &items[0],
646                &prefix,
647            );
648            self.lines = new_lines;
649            self.cursor_line = new_line;
650            self.cursor_col = new_col;
651            self.clear_autocomplete();
652            return;
653        }
654
655        // ── Open the selector with all matches ──
656        let select_items: Vec<SelectItem> = items
657            .into_iter()
658            .map(|item| {
659                let mut si = SelectItem::new(item.value, item.label);
660                if let Some(desc) = item.description {
661                    si = si.with_description(desc);
662                }
663                si
664            })
665            .collect();
666        // Pi-style: slash commands use a wider primary column layout
667        let layout = if prefix.starts_with('/') {
668            Some(
669                crate::tui::components::select_list::SelectListLayoutOptions {
670                    min_primary_column_width: Some(12),
671                    max_primary_column_width: Some(32),
672                    truncate_primary: None,
673                },
674            )
675        } else {
676            None
677        };
678        self.set_autocomplete_with_layout(select_items, layout);
679        self.autocomplete_prefix = prefix;
680    }
681
682    pub fn try_trigger_autocomplete(&mut self) {
683        self.trigger_autocomplete(false);
684    }
685
686    /// Force-trigger autocomplete (for Tab key).
687    fn try_trigger_autocomplete_force(&mut self) {
688        self.trigger_autocomplete(true);
689    }
690
691    fn add_newline(&mut self) {
692        self.exit_history();
693        self.last_action = None;
694        self.push_undo();
695        let line = self.lines[self.cursor_line].clone();
696        let before = &line[..self.cursor_col.min(line.len())];
697        let after = &line[self.cursor_col.min(line.len())..];
698        self.lines[self.cursor_line] = before.to_string();
699        self.lines.insert(self.cursor_line + 1, after.to_string());
700        self.cursor_line += 1;
701        self.set_cursor_col(0);
702        self.notify_change();
703    }
704
705    // ── Delete ──
706
707    /// Find the segment before cursor, treating paste markers as atomic units.
708    /// Returns (start, len) of the segment to delete.
709    fn grapheme_or_paste_before(&self, line: &str, cursor: usize) -> Option<(usize, usize)> {
710        // Check if cursor is at the end of a paste marker
711        for &(start, end) in &Self::find_paste_marker_spans(line) {
712            if cursor >= end && cursor < end + 10 {
713                // The grapheme at end could be the start of the next marker.
714                // If cursor lands exactly at a marker start, the previous
715                // atomic unit is that marker itself.
716                if cursor == end {
717                    return Some((start, end - start));
718                }
719            }
720        }
721        // Also check if cursor is inside a marker - snap to start
722        for &(start, end) in &Self::find_paste_marker_spans(line) {
723            if cursor > start && cursor < end {
724                return Some((start, end - start));
725            }
726        }
727        // Default: last grapheme
728        let graphemes: Vec<(usize, &str)> = line[..cursor].grapheme_indices(true).collect();
729        graphemes.last().map(|&(idx, g)| (idx, g.len()))
730    }
731
732    /// Find the segment after cursor, treating paste markers as atomic units.
733    fn grapheme_or_paste_after(&self, line: &str, cursor: usize) -> Option<(usize, usize)> {
734        // Check if cursor is at the start of a paste marker
735        for &(start, end) in &Self::find_paste_marker_spans(line) {
736            if cursor == start {
737                return Some((start, end - start));
738            }
739        }
740        // Default: first grapheme
741        let graphemes: Vec<(usize, &str)> = line[cursor..].grapheme_indices(true).collect();
742        graphemes.first().map(|&(i, g)| (cursor + i, g.len()))
743    }
744
745    fn backspace(&mut self) {
746        self.exit_history();
747        self.last_action = None;
748        if self.cursor_col > 0 {
749            self.push_undo();
750            let line = self.lines[self.cursor_line].clone();
751            if let Some((idx, len)) = self.grapheme_or_paste_before(&line, self.cursor_col) {
752                self.lines[self.cursor_line].drain(idx..idx + len);
753                self.set_cursor_col(idx);
754            }
755        } else if self.cursor_line > 0 {
756            self.push_undo();
757            let current = self.lines.remove(self.cursor_line);
758            self.cursor_line -= 1;
759            let prev_len = self.lines[self.cursor_line].len();
760            self.lines[self.cursor_line].push_str(&current);
761            self.set_cursor_col(prev_len);
762        }
763        self.notify_change();
764    }
765
766    fn delete_forward(&mut self) {
767        self.exit_history();
768        self.last_action = None;
769        let line = self.lines[self.cursor_line].clone();
770        if self.cursor_col < line.len() {
771            self.push_undo();
772            if let Some((idx, len)) = self.grapheme_or_paste_after(&line, self.cursor_col) {
773                self.lines[self.cursor_line].drain(idx..idx + len);
774            }
775        } else if self.cursor_line + 1 < self.lines.len() {
776            self.push_undo();
777            let next = self.lines.remove(self.cursor_line + 1);
778            self.lines[self.cursor_line].push_str(&next);
779        }
780        self.notify_change();
781
782        // Pi: re-trigger autocomplete after forward delete if in context
783        self.retrigger_autocomplete_dismissed();
784    }
785
786    // ── Kill operations ──
787
788    fn delete_to_line_start(&mut self) {
789        self.exit_history();
790        let line = self.lines[self.cursor_line].clone();
791        if self.cursor_col > 0 {
792            self.push_undo();
793            let deleted = line[..self.cursor_col].to_string();
794            let accumulate = self.last_action.as_deref() == Some("kill");
795            self.kill_ring.push(&deleted, true, accumulate);
796            self.last_action = Some("kill".into());
797            self.lines[self.cursor_line] = line[self.cursor_col..].to_string();
798            self.set_cursor_col(0);
799        } else if self.cursor_line > 0 {
800            self.push_undo();
801            let accumulate = self.last_action.as_deref() == Some("kill");
802            self.kill_ring.push("\n", true, accumulate);
803            self.last_action = Some("kill".into());
804            let current = self.lines.remove(self.cursor_line);
805            self.cursor_line -= 1;
806            let prev_len = self.lines[self.cursor_line].len();
807            self.lines[self.cursor_line].push_str(&current);
808            self.set_cursor_col(prev_len);
809        }
810        self.notify_change();
811    }
812
813    fn delete_to_line_end(&mut self) {
814        self.exit_history();
815        let line = self.lines[self.cursor_line].clone();
816        if self.cursor_col < line.len() {
817            self.push_undo();
818            let deleted = line[self.cursor_col..].to_string();
819            let accumulate = self.last_action.as_deref() == Some("kill");
820            self.kill_ring.push(&deleted, false, accumulate);
821            self.last_action = Some("kill".into());
822            self.lines[self.cursor_line] = line[..self.cursor_col].to_string();
823        } else if self.cursor_line + 1 < self.lines.len() {
824            self.push_undo();
825            let accumulate = self.last_action.as_deref() == Some("kill");
826            self.kill_ring.push("\n", false, accumulate);
827            self.last_action = Some("kill".into());
828            let next = self.lines.remove(self.cursor_line + 1);
829            self.lines[self.cursor_line].push_str(&next);
830        }
831        self.notify_change();
832    }
833
834    fn delete_word_backward(&mut self) {
835        self.exit_history();
836        let line = self.lines[self.cursor_line].clone();
837        if self.cursor_col == 0 {
838            return;
839        }
840        let opts = WordNavigationOptions {
841            segment: None,
842            is_atomic_segment: Some(&|s: &str| s.starts_with("[paste #") && s.ends_with(']')),
843        };
844        let new_col = find_word_backward_with(&line, self.cursor_col, &opts);
845        if new_col < self.cursor_col {
846            self.push_undo();
847            let deleted = line[new_col..self.cursor_col].to_string();
848            let accumulate = self.last_action.as_deref() == Some("kill");
849            self.kill_ring.push(&deleted, true, accumulate);
850            self.last_action = Some("kill".into());
851            self.lines[self.cursor_line].drain(new_col..self.cursor_col);
852            self.set_cursor_col(new_col);
853            self.notify_change();
854        }
855    }
856
857    fn delete_word_forward(&mut self) {
858        self.exit_history();
859        let line = self.lines[self.cursor_line].clone();
860        if self.cursor_col >= line.len() {
861            return;
862        }
863        let opts = WordNavigationOptions {
864            segment: None,
865            is_atomic_segment: Some(&|s: &str| s.starts_with("[paste #") && s.ends_with(']')),
866        };
867        let new_col = find_word_forward_with(&line, self.cursor_col, &opts);
868        if new_col > self.cursor_col {
869            self.push_undo();
870            let deleted = line[self.cursor_col..new_col].to_string();
871            let accumulate = self.last_action.as_deref() == Some("kill");
872            self.kill_ring.push(&deleted, false, accumulate);
873            self.last_action = Some("kill".into());
874            self.lines[self.cursor_line].drain(self.cursor_col..new_col);
875            self.notify_change();
876        }
877    }
878
879    // ── Yank ──
880
881    fn yank(&mut self) {
882        self.exit_history();
883        let text = self.kill_ring.peek().map(|s| s.to_string());
884        if let Some(text) = text {
885            self.push_undo();
886            self.cursor_col += text.len();
887            self.lines[self.cursor_line].insert_str(self.cursor_col - text.len(), &text);
888            self.last_action = Some("yank".into());
889            self.notify_change();
890        }
891    }
892
893    fn yank_pop(&mut self) {
894        // Must be called after yank() - check via last_action
895        if self.last_action.as_deref() != Some("yank") || self.kill_ring.len() <= 1 {
896            return;
897        }
898        // Save current state before modifying (pi-style: pushUndoSnapshot first)
899        self.push_undo();
900
901        // Delete the previously yanked text (still at end of ring before rotation)
902        let prev = self.kill_ring.peek().map(|s| s.to_string());
903        if let Some(ref prev_text) = prev {
904            let line = &self.lines[self.cursor_line].clone();
905            if self.cursor_col >= prev_text.len() {
906                let before = &line[..self.cursor_col - prev_text.len()];
907                let after = &line[self.cursor_col..];
908                self.lines[self.cursor_line] = format!("{}{}", before, after);
909                self.cursor_col -= prev_text.len();
910            }
911        }
912
913        // Rotate the ring: move end to front
914        self.kill_ring.rotate();
915
916        // Insert the new most recent entry (now at end after rotation)
917        let text = self.kill_ring.peek().map(|s| s.to_string());
918        if let Some(ref new_text) = text {
919            self.cursor_col += new_text.len();
920            self.lines[self.cursor_line].insert_str(self.cursor_col - new_text.len(), new_text);
921        }
922
923        self.last_action = Some("yank".into());
924        self.notify_change();
925    }
926
927    // ── Cursor movement ──
928
929    fn move_left(&mut self) {
930        self.last_action = None;
931        if self.cursor_col > 0 {
932            let line = &self.lines[self.cursor_line].clone();
933            let graphemes: Vec<(usize, &str)> =
934                line[..self.cursor_col].grapheme_indices(true).collect();
935            if let Some(&(idx, _g)) = graphemes.last() {
936                let raw = idx;
937                // Snap to paste marker start if inside one
938                self.set_cursor_col(Self::snap_paste_marker(line, raw, true));
939            }
940        } else if self.cursor_line > 0 {
941            self.cursor_line -= 1;
942            self.set_cursor_col(self.lines[self.cursor_line].len());
943        }
944    }
945
946    fn move_right(&mut self) {
947        self.last_action = None;
948        let line = &self.lines[self.cursor_line].clone();
949        if self.cursor_col < line.len() {
950            let mut it = line[self.cursor_col..].grapheme_indices(true);
951            if let Some((idx, g)) = it.next() {
952                let raw = self.cursor_col + idx + g.len();
953                // Snap to paste marker end if inside one
954                self.set_cursor_col(Self::snap_paste_marker(line, raw, false));
955            }
956        } else if self.cursor_line + 1 < self.lines.len() {
957            self.cursor_line += 1;
958            self.set_cursor_col(0);
959        }
960    }
961
962    fn move_up(&mut self) {
963        self.move_vertical(-1);
964    }
965
966    fn move_down(&mut self) {
967        self.move_vertical(1);
968    }
969
970    fn move_to_line_start(&mut self) {
971        self.last_action = None;
972        self.set_cursor_col(0);
973    }
974
975    fn move_to_line_end(&mut self) {
976        self.last_action = None;
977        let len = self.lines[self.cursor_line].len();
978        self.set_cursor_col(len);
979    }
980
981    /// Build visual line spans: (logical_line, start_byte_in_logical, length_in_bytes).
982    fn build_visual_line_spans(&self, width: usize) -> Vec<(usize, usize, usize)> {
983        let mut spans = Vec::new();
984        for (i, line) in self.lines.iter().enumerate() {
985            let line_w = visible_width(line);
986            if line.is_empty() {
987                spans.push((i, 0, 0));
988            } else if line_w <= width {
989                spans.push((i, 0, line.len()));
990            } else {
991                let chunks = crate::tui::util::wrap_text_with_ansi(line, width);
992                let mut byte_pos = 0;
993                for chunk in &chunks {
994                    let chunk_len = chunk.len();
995                    spans.push((i, byte_pos, chunk_len));
996                    byte_pos += chunk_len;
997                }
998            }
999        }
1000        spans
1001    }
1002
1003    /// Find the visual line index for the current cursor position.
1004    fn find_current_visual_line(&self, spans: &[(usize, usize, usize)]) -> usize {
1005        for (i, &(li, start, len)) in spans.iter().enumerate() {
1006            if li != self.cursor_line {
1007                continue;
1008            }
1009            let offset = self.cursor_col.saturating_sub(start);
1010            let is_last = i + 1 >= spans.len() || spans[i + 1].0 != li;
1011            if offset <= len || (is_last && offset == len) {
1012                return i;
1013            }
1014        }
1015        spans.len().saturating_sub(1)
1016    }
1017
1018    /// Move cursor to a target visual line with sticky column logic.
1019    /// Mirrors pi's moveToVisualLine() + computeVerticalMoveColumn().
1020    fn move_to_visual_line(
1021        &mut self,
1022        spans: &[(usize, usize, usize)],
1023        current_vis: usize,
1024        target_vis: usize,
1025    ) {
1026        let (cur_li, _cur_start, cur_len) = spans[current_vis];
1027        let (tgt_li, tgt_start, tgt_len) = spans[target_vis];
1028        let cur_vis_col = self.cursor_col;
1029
1030        let is_last_source = current_vis + 1 >= spans.len() || spans[current_vis + 1].0 != cur_li;
1031        let src_max = if is_last_source {
1032            cur_len
1033        } else {
1034            cur_len.saturating_sub(1)
1035        };
1036
1037        let is_last_target = target_vis + 1 >= spans.len() || spans[target_vis + 1].0 != tgt_li;
1038        let tgt_max = if is_last_target {
1039            tgt_len
1040        } else {
1041            tgt_len.saturating_sub(1)
1042        };
1043
1044        // Decision table (matches pi)
1045        let has_pref = self.preferred_col.is_some();
1046        let cursor_in_middle = cur_vis_col < src_max;
1047        let target_too_short = tgt_max < cur_vis_col;
1048
1049        let move_to_col = if !has_pref || cursor_in_middle {
1050            if target_too_short {
1051                self.preferred_col = Some(cur_vis_col);
1052                tgt_max
1053            } else {
1054                self.preferred_col = None;
1055                cur_vis_col
1056            }
1057        } else {
1058            let pref = self.preferred_col.unwrap_or(0);
1059            let target_cant_fit_pref = tgt_max < pref;
1060            if target_too_short || target_cant_fit_pref {
1061                tgt_max
1062            } else {
1063                self.preferred_col = None;
1064                pref
1065            }
1066        };
1067
1068        self.cursor_line = tgt_li;
1069        let raw_col = tgt_start + move_to_col;
1070        let line = &self.lines[tgt_li].clone();
1071        self.cursor_col = raw_col.min(line.len());
1072        // Snapping uses `delta < 0` for moving-up context
1073        // (we don't have delta here, but snap-to-start is the safe choice
1074        //  since the marker boundary determination is same regardless of direction)
1075        // Actually, snap to start when moving up, end when moving down.
1076        // Infer direction from target_vis vs current_vis.
1077        let moving_up = target_vis < current_vis;
1078        self.cursor_col = Self::snap_paste_marker(line, self.cursor_col, moving_up);
1079    }
1080
1081    fn move_vertical(&mut self, delta: isize) {
1082        let width = self.last_width.get();
1083        let spans = self.build_visual_line_spans(width);
1084        let current_vis = self.find_current_visual_line(&spans);
1085
1086        let target_vis = if delta < 0 {
1087            if current_vis == 0 {
1088                return;
1089            }
1090            current_vis - 1
1091        } else if current_vis + 1 >= spans.len() {
1092            return;
1093        } else {
1094            current_vis + 1
1095        };
1096
1097        self.move_to_visual_line(&spans, current_vis, target_vis);
1098    }
1099
1100    // ── Character jump (pi-style) ──
1101
1102    /// Jump to the first occurrence of a character in the specified direction.
1103    /// Multi-line search (pi-style). Case-sensitive. Skips current cursor position.
1104    fn jump_to_char(&mut self, ch: char, dir: JumpDirection) {
1105        let is_forward = dir == JumpDirection::Forward;
1106        let lines = &self.lines;
1107
1108        let start_line = self.cursor_line as isize;
1109        let end = if is_forward { lines.len() as isize } else { -1 };
1110        let step: isize = if is_forward { 1 } else { -1 };
1111
1112        let mut line_idx = start_line;
1113        while line_idx != end {
1114            let line = &lines[line_idx as usize];
1115            let is_current = line_idx == start_line;
1116            let search_from = if is_current {
1117                if is_forward {
1118                    self.cursor_col + 1
1119                } else {
1120                    self.cursor_col.saturating_sub(1)
1121                }
1122            } else if is_forward {
1123                0
1124            } else {
1125                line.len()
1126            };
1127
1128            let idx = if is_forward {
1129                line[search_from..].find(ch).map(|i| search_from + i)
1130            } else if search_from > 0 {
1131                line[..search_from].rfind(ch)
1132            } else {
1133                None
1134            };
1135
1136            if let Some(pos) = idx {
1137                self.cursor_line = line_idx as usize;
1138                self.set_cursor_col(pos);
1139                return;
1140            }
1141            line_idx += step;
1142        }
1143        // No match - cursor stays
1144    }
1145
1146    // ── History ──
1147
1148    fn exit_history(&mut self) {
1149        self.history_index = -1;
1150        self.history_draft = None;
1151        self.last_action = None;
1152    }
1153
1154    fn recall_older(&mut self) {
1155        if self.history.is_empty() {
1156            return;
1157        }
1158        // Pi: newest at front (index 0), Up increases index (goes older)
1159        let idx = if self.history_index < 0 {
1160            0
1161        } else {
1162            self.history_index + 1
1163        };
1164        if idx >= self.history.len() as i32 {
1165            return; // already at oldest
1166        }
1167
1168        // Pi: save draft when first entering history browsing
1169        if self.history_index < 0 && idx >= 0 {
1170            self.history_draft = Some(EditorSnapshot {
1171                lines: self.lines.clone(),
1172                cursor_line: self.cursor_line,
1173                cursor_col: self.cursor_col,
1174            });
1175        }
1176
1177        let text = self.history[idx as usize].clone();
1178        self.set_text_internal(&text);
1179        self.cursor_col = 0; // pi: cursor at start when going older
1180        self.history_index = idx;
1181    }
1182
1183    fn recall_newer(&mut self) {
1184        if self.history_index < 0 {
1185            return;
1186        }
1187        // Pi: Down decreases index (goes newer). history_index > 0 means browsing older entries.
1188        let idx = self.history_index - 1;
1189        if idx < 0 {
1190            // Pi: restore draft instead of clearing to empty
1191            if let Some(draft) = self.history_draft.take() {
1192                self.lines = draft.lines;
1193                self.cursor_line = draft.cursor_line;
1194                self.cursor_col = draft.cursor_col;
1195                self.preferred_col = None;
1196            } else {
1197                self.set_text_internal("");
1198            }
1199            self.history_index = -1;
1200        } else {
1201            let text = self.history[idx as usize].clone();
1202            self.set_text_internal(&text);
1203            self.history_index = idx;
1204        }
1205    }
1206
1207    // ── Paste markers (pi-style) ──
1208
1209    /// CSI-u decode: terminals with extended keys (e.g. tmux popups with
1210    /// `extended-keys-format=csi-u`) re-encode control bytes inside bracketed
1211    /// paste as `\x1b[<codepoint>;5u`. Decode those back to the literal byte.
1212    fn decode_csi_u_in_paste(&self, text: &str) -> String {
1213        // Pattern: ESC [ digits ; 5 u  - Ctrl+<letter> encoded as CSI-u
1214        let re = regex::Regex::new(r"\x1b\[(\d+);5u").unwrap();
1215        re.replace_all(text, |caps: &regex::Captures| {
1216            let cp: u32 = caps[1].parse().unwrap_or(0);
1217            if (97..=122).contains(&cp) {
1218                // Ctrl+A..Ctrl+Z
1219                char::from_u32(cp - 96)
1220                    .map(|c| c.to_string())
1221                    .unwrap_or_default()
1222            } else if (65..=90).contains(&cp) {
1223                // Ctrl+Shift+A..Ctrl+Shift+Z
1224                char::from_u32(cp - 64)
1225                    .map(|c| c.to_string())
1226                    .unwrap_or_default()
1227            } else {
1228                caps[0].to_string()
1229            }
1230        })
1231        .to_string()
1232    }
1233
1234    // ── Paste marker atomic segment helpers (pi-style) ──
1235
1236    /// Find all paste marker spans `[paste #N ...]` in a line.
1237    /// Returns (start, end) byte positions.
1238    fn find_paste_marker_spans(line: &str) -> Vec<(usize, usize)> {
1239        let mut spans = Vec::new();
1240        let mut pos = 0;
1241        while let Some(start) = line[pos..].find("[paste #") {
1242            let abs_start = pos + start;
1243            if let Some(end) = line[abs_start..].find(']') {
1244                let abs_end = abs_start + end + 1;
1245                spans.push((abs_start, abs_end));
1246                pos = abs_end;
1247            } else {
1248                break;
1249            }
1250        }
1251        spans
1252    }
1253
1254    /// If cursor is inside a paste marker, snap to the nearest boundary:
1255    /// start of marker when moving left, end when moving right.
1256    fn snap_paste_marker(line: &str, cursor: usize, moving_left: bool) -> usize {
1257        for &(start, end) in &Self::find_paste_marker_spans(line) {
1258            if cursor > start && cursor < end {
1259                return if moving_left { start } else { end };
1260            }
1261        }
1262        cursor
1263    }
1264
1265    /// Handle a paste: normalizes line endings, filters non-printable chars,
1266    /// CSI-u decodes control bytes, and for large pastes (>10 lines or >1000 chars)
1267    /// stores the content with a marker like "\[paste #1 +123 lines\]".
1268    /// Matches pi's Editor.handlePaste().
1269    pub fn handle_paste(&mut self, text: &str) {
1270        self.clear_autocomplete();
1271        self.exit_history();
1272        self.last_action = None;
1273        self.push_undo();
1274
1275        // 1. CSI-u decode control bytes that tmux/etc may have re-encoded
1276        let decoded = self.decode_csi_u_in_paste(text);
1277
1278        // 2. Normalize line endings and tabs (same as insert_text_internal)
1279        let normalized = decoded
1280            .replace("\r\n", "\n")
1281            .replace('\r', "\n")
1282            .replace('\t', "    ");
1283
1284        // 3. Filter non-printable chars except newlines
1285        let filtered: String = normalized
1286            .chars()
1287            .filter(|&c| c == '\n' || c == ' ' || c as u32 >= 32)
1288            .collect();
1289
1290        // 4. If pasting a file path (starts with /, ~, or .) and char before
1291        //    cursor is a word char, prepend a space (pi-style)
1292        let current_line = self.lines[self.cursor_line].clone();
1293        let space_prefix = if filtered.starts_with('/')
1294            || filtered.starts_with('~')
1295            || filtered.starts_with('.')
1296        {
1297            if self.cursor_col > 0 {
1298                let prev = current_line
1299                    .as_bytes()
1300                    .get(self.cursor_col - 1)
1301                    .copied()
1302                    .unwrap_or(b' ');
1303                if prev.is_ascii_alphanumeric() || prev == b'_' {
1304                    " "
1305                } else {
1306                    ""
1307                }
1308            } else {
1309                ""
1310            }
1311        } else {
1312            ""
1313        };
1314        let prepared = format!("{}{}", space_prefix, filtered);
1315
1316        let total_chars = prepared.len();
1317        let is_large = prepared.lines().count().max(1) > 10 || total_chars > 1000;
1318
1319        if is_large {
1320            let line_count = prepared.lines().count();
1321            self.paste_counter += 1;
1322            let paste_id = self.paste_counter;
1323            self.pastes.insert(paste_id, prepared);
1324
1325            let marker = if line_count > 10 {
1326                format!("[paste #{} +{} lines]", paste_id, line_count)
1327            } else {
1328                format!("[paste #{} {} chars]", paste_id, total_chars)
1329            };
1330            self.insert_text_internal(&marker);
1331        } else {
1332            self.insert_text_internal(&prepared);
1333        }
1334    }
1335
1336    /// Expand paste markers in text back to their full content.
1337    pub fn expand_paste_markers(&self, text: &str) -> String {
1338        let mut result = text.to_string();
1339        // Replace markers from highest ID to lowest to avoid ID conflicts
1340        let mut ids: Vec<u32> = self.pastes.keys().copied().collect();
1341        ids.sort_unstable_by(|a, b| b.cmp(a)); // descending
1342        for paste_id in ids {
1343            if let Some(content) = self.pastes.get(&paste_id) {
1344                // Simple replacement - find any marker with this ID
1345                let marker1 = format!("[paste #{} ", paste_id);
1346                loop {
1347                    let start = result.find(&marker1);
1348                    match start {
1349                        Some(pos) => {
1350                            let end = result[pos..]
1351                                .find(']')
1352                                .map(|e| pos + e + 1)
1353                                .unwrap_or(result.len());
1354                            result.replace_range(pos..end, content);
1355                        }
1356                        None => break,
1357                    }
1358                }
1359            }
1360        }
1361        result
1362    }
1363
1364    /// Get text with paste markers expanded.
1365    /// Use this when you need the full content (e.g., for external editor).
1366    pub fn get_expanded_text(&self) -> String {
1367        self.expand_paste_markers(&self.lines.join("\n"))
1368    }
1369
1370    /// Check if a string is a paste marker.
1371    pub fn is_paste_marker(segment: &str) -> bool {
1372        segment.starts_with("[paste #") && segment.ends_with(']')
1373    }
1374
1375    // ── Page scroll ──
1376
1377    fn page_size(&self) -> usize {
1378        std::cmp::max(5, (self.terminal_rows as f64 * 0.3) as usize)
1379    }
1380
1381    fn page_up(&mut self) {
1382        let size = self.page_size();
1383        self.scroll_offset = self.scroll_offset.saturating_sub(size);
1384    }
1385
1386    fn page_down(&mut self) {
1387        let size = self.page_size();
1388        self.scroll_offset += size;
1389    }
1390
1391    // ── Submit ──
1392
1393    fn submit(&mut self) {
1394        // Pi: expand paste markers before submitting
1395        let raw = self.lines.join("\n");
1396        let result = self.expand_paste_markers(&raw);
1397        self.last_submitted_text = result.clone();
1398        self.lines = vec![String::new()];
1399        self.cursor_line = 0;
1400        self.cursor_col = 0;
1401        self.scroll_offset = 0;
1402        self.pastes.clear();
1403        self.paste_counter = 0;
1404        self.undo_stack.clear();
1405        self.last_action = None;
1406        self.preferred_col = None;
1407        self.just_submitted = true;
1408        self.exit_history();
1409        if let Some(ref mut cb) = self.on_submit {
1410            cb(result);
1411        }
1412        self.notify_change();
1413    }
1414
1415    // ── Helpers ──
1416
1417    fn notify_change(&mut self) {
1418        let text = self.get_text();
1419        if let Some(ref mut cb) = self.on_change {
1420            cb(&text);
1421        }
1422        // After any text change, update autocomplete if active (pi-style)
1423        if self.autocomplete_active {
1424            self.try_trigger_autocomplete();
1425        }
1426    }
1427
1428    fn is_empty(&self) -> bool {
1429        self.lines.is_empty() || (self.lines.len() == 1 && self.lines[0].is_empty())
1430    }
1431
1432    fn is_first_visual_line(&self) -> bool {
1433        let width = self.last_width.get();
1434        let visual_lines = layout_text(&self.lines, width, self.cursor_line, self.cursor_col);
1435        let current = visual_lines
1436            .iter()
1437            .position(|vl| vl.has_cursor)
1438            .unwrap_or(0);
1439        current == 0
1440    }
1441
1442    fn is_last_visual_line(&self) -> bool {
1443        let width = self.last_width.get();
1444        let visual_lines = layout_text(&self.lines, width, self.cursor_line, self.cursor_col);
1445        let current = visual_lines
1446            .iter()
1447            .position(|vl| vl.has_cursor)
1448            .unwrap_or(0);
1449        current >= visual_lines.len().saturating_sub(1)
1450    }
1451}
1452
1453// ── Component impl ─────────────────────────────────────────────────
1454
1455impl Component for Editor {
1456    fn render(&mut self, width: usize) -> Vec<String> {
1457        let max_padding = if width > 1 { (width - 1) / 2 } else { 0 };
1458        let pad_x = self.padding_x.min(max_padding);
1459        let content_width = if width > pad_x * 2 {
1460            width - pad_x * 2
1461        } else {
1462            1
1463        };
1464        // Pi: with padding, cursor can overflow into it; without, reserve 1 col for cursor.
1465        let layout_width = content_width
1466            .max(1)
1467            .saturating_sub(if pad_x > 0 { 0 } else { 1 });
1468        self.last_width.set(layout_width);
1469
1470        let horizontal = "─";
1471        let left_pad = " ".repeat(pad_x);
1472        let right_pad = " ".repeat(pad_x);
1473        let mut result: Vec<String> = Vec::new();
1474
1475        // ── Layout text into visual lines, tracking cursor ──
1476        let visual_lines =
1477            layout_text(&self.lines, layout_width, self.cursor_line, self.cursor_col);
1478        let total_visual = visual_lines.len().max(1);
1479
1480        // Find cursor visual line index
1481        let cursor_vis = visual_lines
1482            .iter()
1483            .position(|vl| vl.has_cursor)
1484            .unwrap_or(0);
1485
1486        // Adjust scroll to keep cursor visible
1487        // Pi: max visible lines is 30% of terminal height, minimum 5.
1488        let max_vis = std::cmp::max(5, (self.terminal_rows as f64 * 0.3) as usize).max(1);
1489        let mut scroll = self.scroll_offset;
1490        if cursor_vis < scroll {
1491            scroll = cursor_vis;
1492        } else if cursor_vis >= scroll + max_vis {
1493            scroll = cursor_vis - max_vis + 1;
1494        }
1495        let max_scroll = total_visual.saturating_sub(max_vis);
1496        scroll = scroll.min(max_scroll);
1497
1498        let visible_end = (scroll + max_vis).min(total_visual);
1499
1500        // ── Top border ──
1501        if scroll > 0 {
1502            let indicator = format!("─── ↑ {} more ", scroll);
1503            let indicator_w = visible_width(&indicator);
1504            let fill = if indicator_w < width {
1505                horizontal.repeat(width - indicator_w)
1506            } else {
1507                String::new()
1508            };
1509            result.push(self.border_color.apply(&format!("{}{}", indicator, fill)));
1510        } else {
1511            result.push(self.border_color.apply(&horizontal.repeat(width)));
1512        }
1513
1514        // ── Content lines ──
1515        for vl in visual_lines.iter().skip(scroll).take(visible_end - scroll) {
1516            let text = &vl.text;
1517            let (display, line_width) = if vl.has_cursor {
1518                let cursor_pos = vl.cursor_pos.unwrap_or(0);
1519                let before = &text[..cursor_pos.min(text.len())];
1520                let after = &text[cursor_pos.min(text.len())..];
1521
1522                let marker = if self.focused {
1523                    CURSOR_MARKER.to_string()
1524                } else {
1525                    String::new()
1526                };
1527
1528                if !after.is_empty() {
1529                    let after_graphemes: Vec<&str> = after.graphemes(true).collect();
1530                    let first_g = after_graphemes.first().copied().unwrap_or(" ");
1531                    let rest = &after[first_g.len()..];
1532                    let cursor = format!("\x1b[7m{}\x1b[0m", first_g);
1533                    (
1534                        format!("{}{}{}{}", before, marker, cursor, rest),
1535                        visible_width(text),
1536                    )
1537                } else if !before.is_empty() {
1538                    // Cursor at end: show a block cursor after the last character.
1539                    let cursor_block = "\x1b[7m \x1b[0m";
1540                    (
1541                        format!("{}{}{}", before, cursor_block, marker),
1542                        visible_width(text) + 1,
1543                    )
1544                } else {
1545                    // Empty text: show block cursor
1546                    let cursor = "\x1b[7m \x1b[0m";
1547                    (
1548                        format!("{}{}{}", before, marker, cursor),
1549                        visible_width(text) + 1,
1550                    )
1551                }
1552            } else {
1553                (text.clone(), visible_width(text))
1554            };
1555
1556            // Pi-style: cursor can overflow into right padding when at end of line
1557            let cursor_in_padding = line_width > content_width && pad_x > 0;
1558            let padding = if line_width < content_width {
1559                " ".repeat(content_width - line_width)
1560            } else {
1561                String::new()
1562            };
1563            let right_pad_used = if cursor_in_padding {
1564                &right_pad[1..]
1565            } else {
1566                &right_pad
1567            };
1568            result.push(format!(
1569                "{}{}{}{}",
1570                left_pad, display, padding, right_pad_used
1571            ));
1572        }
1573
1574        // ── Bottom border ──
1575        let below = total_visual.saturating_sub(visible_end);
1576        if below > 0 {
1577            let indicator = format!("─── ↓ {} more ", below);
1578            let indicator_w = visible_width(&indicator);
1579            let fill = if indicator_w < width {
1580                horizontal.repeat(width - indicator_w)
1581            } else {
1582                String::new()
1583            };
1584            result.push(self.border_color.apply(&format!("{}{}", indicator, fill)));
1585        } else {
1586            result.push(self.border_color.apply(&horizontal.repeat(width)));
1587        }
1588
1589        // ── Autocomplete dropdown (pi-style: renders SelectList below bottom border) ──
1590        if self.autocomplete_active
1591            && let Some(ref mut list) = self.autocomplete_list
1592        {
1593            let list_lines = list.render(width);
1594            result.extend(list_lines);
1595        }
1596
1597        result
1598    }
1599
1600    fn handle_input(&mut self, key: &KeyEvent) -> bool {
1601        let kb = get_keybindings();
1602
1603        // ── Character jump mode: await next printable char ──
1604        if let Some(dir) = self.jump_mode {
1605            // Cancel on jump hotkey again
1606            if kb.matches(key, ACTION_EDITOR_JUMP_FORWARD)
1607                || kb.matches(key, ACTION_EDITOR_JUMP_BACKWARD)
1608            {
1609                self.jump_mode = None;
1610                return true;
1611            }
1612            if is_printable_plain(key)
1613                && let Some(s) = key_event_to_string(key)
1614            {
1615                let ch = s.chars().next().unwrap_or(' ');
1616                self.jump_mode = None;
1617                self.jump_to_char(ch, dir);
1618                return true;
1619            }
1620            // Non-printable cancels jump mode
1621            self.jump_mode = None;
1622        }
1623
1624        // ── Autocomplete: route to SelectList (pi-style) ──
1625        // Pi behavior: only Escape dismisses, Enter/Tab confirms, Up/Down navigates.
1626        // All other keys (including printable chars and backspace) fall through
1627        // to the normal handler so the character is inserted/deleted first, then
1628        // autocomplete is re-queried via update_autocomplete().
1629        //
1630        // We capture state inside the borrow (list) and apply completion outside
1631        // to avoid borrow checker conflicts with self methods.
1632        let mut ac_selected: Option<String> = None;
1633        let mut ac_complete_and_return = false;
1634
1635        if let Some(ref mut list) = self.autocomplete_list {
1636            if kb.matches(key, ACTION_SELECT_CANCEL) {
1637                self.autocomplete_active = false;
1638                self.autocomplete_list = None;
1639                self.autocomplete_prefix.clear();
1640                return true;
1641            }
1642
1643            if kb.matches(key, ACTION_INPUT_TAB) {
1644                ac_selected = list.selected_item().map(|i| i.value.clone());
1645                ac_complete_and_return = true;
1646            } else if kb.matches(key, ACTION_SELECT_CONFIRM) {
1647                ac_selected = list.selected_item().map(|i| i.value.clone());
1648                let is_slash = self.autocomplete_prefix.starts_with('/');
1649                if !is_slash {
1650                    ac_complete_and_return = true;
1651                }
1652                // Slash command: apply completion and fall through to submit
1653            } else if kb.matches(key, ACTION_SELECT_UP) || kb.matches(key, ACTION_SELECT_DOWN) {
1654                list.handle_input(key);
1655                return true;
1656            }
1657            // For all other keys, fall through to normal handling.
1658            // autocomplete will be updated after the key is processed.
1659        }
1660
1661        // Handle deferred completion (outside the borrow on self.autocomplete_list)
1662        if let Some(val) = ac_selected {
1663            self.apply_autocomplete_completion_value(&val);
1664            self.clear_autocomplete();
1665            if ac_complete_and_return {
1666                return true;
1667            }
1668            // ac_complete_and_submit: fall through to submit handler below
1669        }
1670
1671        // ── Tab: trigger autocomplete via provider (pi-style) ──
1672        if kb.matches(key, ACTION_INPUT_TAB) && self.autocomplete_provider.is_some() {
1673            self.try_trigger_autocomplete_force();
1674            return true;
1675        }
1676
1677        // ── Enter / Submit ──
1678        if kb.matches(key, ACTION_INPUT_SUBMIT) {
1679            if self.disable_submit {
1680                self.add_newline();
1681                return true;
1682            }
1683            let line = &self.lines[self.cursor_line];
1684            if self.cursor_col > 0 && line.as_bytes().get(self.cursor_col - 1) == Some(&b'\\') {
1685                self.backspace();
1686                self.add_newline();
1687                return true;
1688            }
1689            self.submit();
1690            return true;
1691        }
1692
1693        // ── Character jump triggers ──
1694        if kb.matches(key, ACTION_EDITOR_JUMP_FORWARD) {
1695            self.jump_mode = Some(JumpDirection::Forward);
1696            return true;
1697        }
1698        if kb.matches(key, ACTION_EDITOR_JUMP_BACKWARD) {
1699            self.jump_mode = Some(JumpDirection::Backward);
1700            return true;
1701        }
1702
1703        // ── Printable character ──
1704        if is_printable_plain(key)
1705            && let Some(s) = key_event_to_string(key)
1706        {
1707            self.insert_character(&s);
1708            return true;
1709        }
1710
1711        // ── Basic movement ──
1712        if kb.matches(key, ACTION_EDITOR_CURSOR_LEFT) {
1713            self.move_left();
1714            self.update_autocomplete_if_active();
1715            return true;
1716        }
1717        if kb.matches(key, ACTION_EDITOR_CURSOR_RIGHT) {
1718            self.move_right();
1719            self.update_autocomplete_if_active();
1720            return true;
1721        }
1722        if kb.matches(key, ACTION_EDITOR_CURSOR_LINE_START) {
1723            self.move_to_line_start();
1724            self.update_autocomplete_if_active();
1725            return true;
1726        }
1727        if kb.matches(key, ACTION_EDITOR_CURSOR_LINE_END) {
1728            self.move_to_line_end();
1729            self.update_autocomplete_if_active();
1730            return true;
1731        }
1732
1733        // ── Up/Down with history ──
1734        if kb.matches(key, ACTION_EDITOR_CURSOR_UP) {
1735            if self.is_first_visual_line()
1736                && (self.is_empty() || self.history_index >= 0 || self.cursor_col == 0)
1737            {
1738                self.recall_older();
1739            } else if self.is_first_visual_line() {
1740                self.move_to_line_start();
1741            } else {
1742                self.move_up();
1743            }
1744            self.update_autocomplete_if_active();
1745            return true;
1746        }
1747        if kb.matches(key, ACTION_EDITOR_CURSOR_DOWN) {
1748            if self.history_index >= 0 && self.is_last_visual_line() {
1749                self.recall_newer();
1750            } else if self.is_last_visual_line() {
1751                self.move_to_line_end();
1752            } else {
1753                self.move_down();
1754            }
1755            self.update_autocomplete_if_active();
1756            return true;
1757        }
1758
1759        // ── Page scroll ──
1760        if kb.matches(key, ACTION_EDITOR_PAGE_UP) {
1761            self.page_up();
1762            self.update_autocomplete_if_active();
1763            return true;
1764        }
1765        if kb.matches(key, ACTION_EDITOR_PAGE_DOWN) {
1766            self.page_down();
1767            self.update_autocomplete_if_active();
1768            return true;
1769        }
1770
1771        // ── Word movement ──
1772        if kb.matches(key, ACTION_EDITOR_CURSOR_WORD_LEFT) {
1773            let line = &self.lines[self.cursor_line].clone();
1774            if self.cursor_col > 0 {
1775                let opts = WordNavigationOptions {
1776                    segment: None,
1777                    is_atomic_segment: Some(&|s: &str| {
1778                        s.starts_with("[paste #") && s.ends_with(']')
1779                    }),
1780                };
1781                let c = find_word_backward_with(line, self.cursor_col, &opts);
1782                self.set_cursor_col(c);
1783            }
1784            self.update_autocomplete_if_active();
1785            return true;
1786        }
1787        if kb.matches(key, ACTION_EDITOR_CURSOR_WORD_RIGHT) {
1788            let line = &self.lines[self.cursor_line].clone();
1789            if self.cursor_col < line.len() {
1790                let opts = WordNavigationOptions {
1791                    segment: None,
1792                    is_atomic_segment: Some(&|s: &str| {
1793                        s.starts_with("[paste #") && s.ends_with(']')
1794                    }),
1795                };
1796                let c = find_word_forward_with(line, self.cursor_col, &opts);
1797                self.set_cursor_col(c);
1798            }
1799            self.update_autocomplete_if_active();
1800            return true;
1801        }
1802
1803        // ── Deletion ──
1804        if kb.matches(key, ACTION_EDITOR_DELETE_CHAR_BACKWARD) {
1805            self.backspace();
1806            // notify_change handles autocomplete update
1807            return true;
1808        }
1809        if kb.matches(key, ACTION_EDITOR_DELETE_CHAR_FORWARD) {
1810            self.delete_forward();
1811            // notify_change handles autocomplete update
1812            return true;
1813        }
1814
1815        // ── Kill operations ──
1816        if kb.matches(key, ACTION_EDITOR_DELETE_WORD_BACKWARD) {
1817            self.delete_word_backward();
1818            // notify_change handles autocomplete update
1819            return true;
1820        }
1821        if kb.matches(key, ACTION_EDITOR_DELETE_WORD_FORWARD) {
1822            self.delete_word_forward();
1823            // notify_change handles autocomplete update
1824            return true;
1825        }
1826        if kb.matches(key, ACTION_EDITOR_DELETE_TO_LINE_START) {
1827            self.delete_to_line_start();
1828            // notify_change handles autocomplete update
1829            return true;
1830        }
1831        if kb.matches(key, ACTION_EDITOR_DELETE_TO_LINE_END) {
1832            self.delete_to_line_end();
1833            // notify_change handles autocomplete update
1834            return true;
1835        }
1836
1837        // ── Yank ──
1838        if kb.matches(key, ACTION_EDITOR_YANK) {
1839            self.yank();
1840            return true;
1841        }
1842        if kb.matches(key, ACTION_EDITOR_YANK_POP) {
1843            self.yank_pop();
1844            return true;
1845        }
1846
1847        // ── Undo ──
1848        if kb.matches(key, ACTION_EDITOR_UNDO) {
1849            self.last_action = None;
1850            self.undo();
1851            self.notify_change();
1852            return true;
1853        }
1854
1855        // ── Ctrl+J = newline ──
1856        if kb.matches(key, ACTION_INPUT_NEW_LINE) {
1857            self.add_newline();
1858            return true;
1859        }
1860
1861        // ── Escape - let parent handle ──
1862        if kb.matches(key, ACTION_SELECT_CANCEL) {
1863            return false;
1864        }
1865
1866        false
1867    }
1868
1869    fn handle_paste(&mut self, text: &str) {
1870        Editor::handle_paste(self, text);
1871    }
1872
1873    fn is_focusable(&self) -> bool {
1874        true
1875    }
1876}
1877
1878impl Focusable for Editor {
1879    fn set_focused(&mut self, focused: bool) {
1880        self.focused = focused;
1881    }
1882
1883    fn focused(&self) -> bool {
1884        self.focused
1885    }
1886}
1887
1888// ── Visual layout ──────────────────────────────────────────────────
1889
1890#[derive(Debug)]
1891struct VisualLine {
1892    text: String,
1893    has_cursor: bool,
1894    cursor_pos: Option<usize>,
1895}
1896
1897/// Layout text into visual lines, marking which line contains the cursor.
1898fn layout_text(
1899    lines: &[String],
1900    max_width: usize,
1901    cursor_line: usize,
1902    cursor_col: usize,
1903) -> Vec<VisualLine> {
1904    let mut result: Vec<VisualLine> = Vec::new();
1905
1906    if lines.is_empty() || (lines.len() == 1 && lines[0].is_empty()) {
1907        result.push(VisualLine {
1908            text: String::new(),
1909            has_cursor: true,
1910            cursor_pos: Some(0),
1911        });
1912        return result;
1913    }
1914
1915    let mut _col_offset = 0;
1916
1917    for (line_idx, line) in lines.iter().enumerate() {
1918        let is_cursor_line = line_idx == cursor_line;
1919        let line_w = visible_width(line);
1920        _col_offset = 0;
1921
1922        if line_w <= max_width {
1923            // Line fits entirely
1924            result.push(VisualLine {
1925                text: line.clone(),
1926                has_cursor: is_cursor_line,
1927                cursor_pos: if is_cursor_line {
1928                    Some(cursor_col.min(line.len()))
1929                } else {
1930                    None
1931                },
1932            });
1933        } else {
1934            // Word-wrap the line, tracking cursor position by visual column.
1935            // We cannot use byte-pos accumulation because wrap_text_with_ansi may
1936            // trim trailing whitespace or add ANSI codes, making chunk byte lengths
1937            // diverge from the original line's substrings.
1938            let wrapped = wrap_text_with_ansi(line, max_width);
1939
1940            // Compute cursor's visual column position in the original line.
1941            let cursor_vis = if is_cursor_line {
1942                visible_width(&line[..cursor_col.min(line.len())])
1943            } else {
1944                0
1945            };
1946
1947            let mut vis_offset: usize = 0;
1948            for (chunk_idx, chunk) in wrapped.iter().enumerate() {
1949                let chunk_vis = visible_width(chunk);
1950                let chunk_vis_end = vis_offset + chunk_vis;
1951
1952                let cursor_in_chunk = is_cursor_line
1953                    && cursor_vis >= vis_offset
1954                    && (cursor_vis < chunk_vis_end || chunk_idx == wrapped.len() - 1);
1955
1956                let cursor_pos = if cursor_in_chunk {
1957                    let local_vis = cursor_vis.saturating_sub(vis_offset);
1958                    // Convert visual offset within chunk to byte offset
1959                    Some(visual_col_to_byte_offset(chunk, local_vis))
1960                } else {
1961                    None
1962                };
1963
1964                result.push(VisualLine {
1965                    text: chunk.clone(),
1966                    has_cursor: cursor_in_chunk && cursor_pos.is_some(),
1967                    cursor_pos,
1968                });
1969
1970                vis_offset = chunk_vis_end;
1971            }
1972        }
1973    }
1974
1975    result
1976}
1977
1978fn is_printable_plain(key: &KeyEvent) -> bool {
1979    matches!(key.code, KeyCode::Char(_))
1980        && !key.modifiers.contains(KeyModifiers::CONTROL)
1981        && !key.modifiers.contains(KeyModifiers::ALT)
1982        && key.code != KeyCode::Enter
1983        && key.code != KeyCode::Tab
1984        && key.code != KeyCode::Backspace
1985        && key.code != KeyCode::Delete
1986        && key.code != KeyCode::Esc
1987}
1988
1989#[cfg(test)]
1990mod tests {
1991    use super::*;
1992    use crate::tui::autocomplete::{
1993        AutocompleteItem, AutocompleteProvider, AutocompleteSuggestions, SlashCommand,
1994    };
1995
1996    // ── Mock autocomplete provider for testing ──
1997
1998    struct MockSlashProvider {
1999        commands: Vec<SlashCommand>,
2000    }
2001
2002    impl MockSlashProvider {
2003        fn new(commands: Vec<&str>) -> Self {
2004            Self {
2005                commands: commands
2006                    .into_iter()
2007                    .map(|name| SlashCommand {
2008                        name: name.to_string(),
2009                        description: Some(format!("The {} command", name)),
2010                        argument_hint: None,
2011                        argument_completions: None,
2012                        get_argument_completions: None,
2013                    })
2014                    .collect(),
2015            }
2016        }
2017    }
2018
2019    impl AutocompleteProvider for MockSlashProvider {
2020        fn trigger_characters(&self) -> &[char] {
2021            &['/', '@', '#']
2022        }
2023
2024        fn get_suggestions(
2025            &self,
2026            lines: &[String],
2027            cursor_line: usize,
2028            cursor_col: usize,
2029            _force: bool,
2030        ) -> Option<AutocompleteSuggestions> {
2031            let line = lines.get(cursor_line)?;
2032            let before = &line[..cursor_col.min(line.len())];
2033
2034            // Slash command: text starts with / and has no space
2035            if before.starts_with('/') && !before.contains(' ') {
2036                let query = &before[1..].to_lowercase();
2037                let matching: Vec<AutocompleteItem> = self
2038                    .commands
2039                    .iter()
2040                    .filter(|cmd| cmd.name.to_lowercase().starts_with(query))
2041                    .map(|cmd| AutocompleteItem {
2042                        value: cmd.name.clone(),
2043                        label: format!("/{}", cmd.name),
2044                        description: cmd.description.clone(),
2045                    })
2046                    .collect();
2047                if matching.is_empty() {
2048                    return None;
2049                }
2050                return Some(AutocompleteSuggestions {
2051                    items: matching,
2052                    prefix: before.to_string(),
2053                });
2054            }
2055            None
2056        }
2057
2058        fn apply_completion(
2059            &self,
2060            lines: &[String],
2061            cursor_line: usize,
2062            cursor_col: usize,
2063            item: &AutocompleteItem,
2064            prefix: &str,
2065        ) -> (Vec<String>, usize, usize) {
2066            let current_line = lines[cursor_line].clone();
2067            let prefix_start = cursor_col.saturating_sub(prefix.len());
2068            let before = &current_line[..prefix_start];
2069            let after = &current_line[cursor_col..];
2070            (
2071                vec![format!("{}/{} {}", before, item.value, after)],
2072                cursor_line,
2073                before.len() + 1 + item.value.len() + 1,
2074            )
2075        }
2076
2077        fn should_trigger_file_completion(
2078            &self,
2079            lines: &[String],
2080            cursor_line: usize,
2081            cursor_col: usize,
2082        ) -> bool {
2083            let current_line = lines.get(cursor_line);
2084            match current_line {
2085                Some(text) => {
2086                    let before = &text[..cursor_col.min(text.len())];
2087                    if before.starts_with('/') && !before.contains(' ') {
2088                        return false;
2089                    }
2090                    true
2091                }
2092                None => false,
2093            }
2094        }
2095    }
2096
2097    // ── Autocomplete tests ──
2098
2099    fn make_editor_with_slash_provider(commands: Vec<&str>) -> Editor {
2100        let mut editor = Editor::new(EditorOptions::default());
2101        let provider = Box::new(MockSlashProvider::new(commands));
2102        editor.set_autocomplete_provider(provider);
2103        editor
2104    }
2105
2106    #[test]
2107    fn autocomplete_triggers_on_slash() {
2108        let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2109        editor.handle_input(&char_key('/'));
2110        assert!(
2111            editor.autocomplete_active,
2112            "autocomplete should activate after typing /"
2113        );
2114        let selected = editor.autocomplete_selected_value();
2115        assert_eq!(
2116            selected.as_deref(),
2117            Some("help"),
2118            "first item should be help"
2119        );
2120    }
2121
2122    #[test]
2123    fn autocomplete_filters_as_user_types() {
2124        let mut editor = make_editor_with_slash_provider(vec!["help", "history", "model"]);
2125        // Type /
2126        editor.handle_input(&char_key('/'));
2127        assert!(editor.autocomplete_active);
2128
2129        // Type 'h' - should filter to help, history
2130        editor.handle_input(&char_key('h'));
2131        assert!(
2132            editor.autocomplete_active,
2133            "autocomplete should stay active after typing more letters"
2134        );
2135        // Should still have items (no flicker on footer)
2136
2137        // Type 'e' - should filter to help only
2138        editor.handle_input(&char_key('e'));
2139        assert!(editor.autocomplete_active);
2140        let selected = editor.autocomplete_selected_value();
2141        assert_eq!(selected.as_deref(), Some("help"));
2142    }
2143
2144    #[test]
2145    fn autocomplete_stays_active_on_printable_chars() {
2146        // Regression: typing a letter should NOT dismiss autocomplete first
2147        let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2148        editor.handle_input(&char_key('/'));
2149        assert!(editor.autocomplete_active);
2150
2151        editor.handle_input(&char_key('h'));
2152        assert!(
2153            editor.autocomplete_active,
2154            "typing 'h' after '/' must keep autocomplete visible"
2155        );
2156
2157        editor.handle_input(&char_key('e'));
2158        assert!(
2159            editor.autocomplete_active,
2160            "typing 'e' after '/h' must keep autocomplete visible"
2161        );
2162
2163        let lines = editor.render(80);
2164        // Should have at least 3 border lines + some suggestion lines
2165        assert!(lines.len() > 3, "autocomplete lines should be rendered");
2166    }
2167
2168    #[test]
2169    fn escape_dismisses_autocomplete() {
2170        let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2171        editor.handle_input(&char_key('/'));
2172        assert!(editor.autocomplete_active);
2173
2174        editor.handle_input(&escape());
2175        assert!(
2176            !editor.autocomplete_active,
2177            "escape should dismiss autocomplete"
2178        );
2179
2180        // Text should remain (Escape only dismisses autocomplete, not clear text)
2181        assert_eq!(editor.get_text(), "/");
2182    }
2183
2184    #[test]
2185    fn backspace_removing_slash_dismisses_autocomplete() {
2186        let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2187        editor.handle_input(&char_key('/'));
2188        assert!(editor.autocomplete_active, "after /");
2189
2190        editor.handle_input(&backspace());
2191        assert!(
2192            !editor.autocomplete_active,
2193            "backspace removing / should dismiss autocomplete"
2194        );
2195        assert_eq!(editor.get_text(), "", "text should be empty");
2196    }
2197
2198    #[test]
2199    fn autocomplete_updates_after_backspace_char() {
2200        let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2201        // Type /he
2202        editor.handle_input(&char_key('/'));
2203        editor.handle_input(&char_key('h'));
2204        editor.handle_input(&char_key('e'));
2205        assert!(editor.autocomplete_active);
2206        let val1 = editor.autocomplete_selected_value();
2207        assert_eq!(val1.as_deref(), Some("help"));
2208
2209        // Backspace the 'e' - should re-filter to show help, history
2210        editor.handle_input(&backspace());
2211        assert!(
2212            editor.autocomplete_active,
2213            "backspace should re-filter, not dismiss"
2214        );
2215        // Should now have 2 matching items (help, history)
2216        assert!(!editor.autocomplete_is_empty());
2217        assert_eq!(editor.get_text(), "/h");
2218    }
2219
2220    #[test]
2221    fn autocomplete_updates_on_cursor_movement() {
2222        let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2223        // Type /help (autocomplete shows)
2224        editor.handle_input(&char_key('/'));
2225        editor.handle_input(&char_key('h'));
2226        editor.handle_input(&char_key('e'));
2227        editor.handle_input(&char_key('l'));
2228        editor.handle_input(&char_key('p'));
2229        assert!(editor.autocomplete_active);
2230
2231        // Now type a space after /help - autocomplete should dismiss because
2232        // the context changes (/command with space = file completion, not slash)
2233        editor.handle_input(&char_key(' '));
2234        assert!(
2235            !editor.autocomplete_active,
2236            "space after /cmd should dismiss slash autocomplete"
2237        );
2238
2239        // Move cursor left back into /help - should re-trigger autocomplete via update_autocomplete_if_active
2240        editor.handle_input(&left_key());
2241        // Actually, moving left won't trigger autocomplete since the provider doesn't
2242        // re-trigger from cursor movement alone when autocomplete was dismissed.
2243        // The key change is that when autocomplete IS active, cursor movement updates it.
2244    }
2245
2246    #[test]
2247    fn autocomplete_clears_when_provider_returns_none() {
2248        // Provider returns None for unknown commands, which should clear autocomplete
2249        let mut editor = make_editor_with_slash_provider(vec!["help"]);
2250        editor.handle_input(&char_key('/'));
2251        assert!(editor.autocomplete_active);
2252
2253        // Type 'z' - no command starts with /z, provider returns None
2254        editor.handle_input(&char_key('z'));
2255        assert!(
2256            !editor.autocomplete_active,
2257            "typing /z with no matching command should dismiss autocomplete"
2258        );
2259    }
2260
2261    #[test]
2262    fn autocomplete_does_not_interfere_with_normal_typing() {
2263        // Without a slash prefix, autocomplete should not trigger
2264        let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2265        editor.handle_input(&char_key('h'));
2266        editor.handle_input(&char_key('e'));
2267        editor.handle_input(&char_key('l'));
2268        editor.handle_input(&char_key('l'));
2269        editor.handle_input(&char_key('o'));
2270        assert!(!editor.autocomplete_active, "no slash = no autocomplete");
2271        assert_eq!(editor.get_text(), "hello");
2272    }
2273
2274    #[test]
2275    fn autocomplete_renders_lines_below_editor() {
2276        let mut editor = make_editor_with_slash_provider(vec!["help", "history", "model"]);
2277        editor.handle_input(&char_key('/'));
2278        assert!(editor.autocomplete_active);
2279
2280        let lines = editor.render(80);
2281        // Lines should include: top border, content (/), bottom border, autocomplete items
2282        assert!(
2283            lines.len() >= 5,
2284            "should have border lines + autocomplete items"
2285        );
2286        // Bottom border should be present
2287        assert!(lines[2].contains('─'), "line 2 should be bottom border");
2288        // Autocomplete items should follow
2289        let after_border = &lines[3..];
2290        let all_have_content = after_border.iter().any(|l| !l.trim().is_empty());
2291        assert!(all_have_content, "autocomplete lines should have content");
2292    }
2293
2294    #[test]
2295    fn autocomplete_stable_rendering_no_flash_on_extra_char() {
2296        // Verify that typing an extra character doesn't change the total
2297        // line count drastically (no dismiss + re-show bounce).
2298        let mut editor = make_editor_with_slash_provider(vec!["help", "history", "model"]);
2299        editor.handle_input(&char_key('/'));
2300        let lines_after_slash = editor.render(80).len();
2301
2302        editor.handle_input(&char_key('h'));
2303        let lines_after_h = editor.render(80).len();
2304
2305        // Both renders should have autocomplete, so line counts should be similar
2306        // (items may differ: 3 vs 2, so at most 1 line difference)
2307        let diff = lines_after_slash.abs_diff(lines_after_h);
2308        assert!(
2309            diff <= 1,
2310            "line count should not change dramatically: {} -> {} (diff {})",
2311            lines_after_slash,
2312            lines_after_h,
2313            diff
2314        );
2315    }
2316
2317    #[test]
2318    fn autocomplete_dismissed_on_submit() {
2319        let mut editor = make_editor_with_slash_provider(vec!["help"]);
2320        editor.handle_input(&char_key('/'));
2321        assert!(editor.autocomplete_active);
2322
2323        // Submit (Enter) - should apply completion or dismiss
2324        editor.handle_input(&enter_key());
2325        // After submit, autocomplete is cleared
2326    }
2327
2328    #[test]
2329    fn tab_force_triggers_autocomplete() {
2330        let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2331        // Type nothing - Tab should trigger file completion (not slash)
2332        // Type / and then Tab
2333        editor.handle_input(&char_key('/'));
2334        // insert_character should have triggered autocomplete already
2335        assert!(editor.autocomplete_active);
2336    }
2337
2338    #[test]
2339    fn autocomplete_persists_across_multiple_chars() {
2340        // Real-world flow: type /help and see autocomplete stay visible throughout
2341        let mut editor = make_editor_with_slash_provider(vec!["help", "history", "hello", "heavy"]);
2342
2343        for ch in "/hel".chars() {
2344            editor.handle_input(&char_key(ch));
2345            assert!(
2346                editor.autocomplete_active,
2347                "autocomplete should stay active after '{}'",
2348                ch
2349            );
2350        }
2351
2352        // Should show items starting with /hel
2353        assert!(
2354            !editor.autocomplete_is_empty(),
2355            "should have matching items"
2356        );
2357        assert_eq!(editor.get_text(), "/hel");
2358    }
2359
2360    #[test]
2361    fn test_new_editor() {
2362        let editor = Editor::new(EditorOptions::default());
2363        assert_eq!(editor.get_text(), "");
2364    }
2365
2366    #[test]
2367    fn test_set_text() {
2368        let mut editor = Editor::new(EditorOptions::default());
2369        editor.set_text("hello world");
2370        assert_eq!(editor.get_text(), "hello world");
2371    }
2372
2373    #[test]
2374    fn test_insert_and_move() {
2375        let mut editor = Editor::new(EditorOptions::default());
2376        editor.insert_character("h");
2377        editor.insert_character("i");
2378        assert_eq!(editor.get_text(), "hi");
2379        editor.move_left();
2380        assert_eq!(editor.cursor_col, 1);
2381        editor.move_right();
2382        assert_eq!(editor.cursor_col, 2);
2383    }
2384
2385    #[test]
2386    fn test_backspace() {
2387        let mut editor = Editor::new(EditorOptions::default());
2388        editor.set_text("hello");
2389        editor.backspace();
2390        assert_eq!(editor.get_text(), "hell");
2391    }
2392
2393    #[test]
2394    fn test_multiline() {
2395        let mut editor = Editor::new(EditorOptions::default());
2396        editor.set_text("line1\nline2");
2397        assert_eq!(editor.get_lines().len(), 2);
2398    }
2399
2400    #[test]
2401    fn test_undo() {
2402        let mut editor = Editor::new(EditorOptions::default());
2403        editor.push_undo();
2404        editor.insert_text_internal("a");
2405        editor.push_undo();
2406        editor.insert_text_internal("b");
2407        assert_eq!(editor.get_text(), "ab");
2408        editor.undo();
2409        assert_eq!(editor.get_text(), "a");
2410        editor.undo();
2411        assert_eq!(editor.get_text(), "");
2412    }
2413
2414    #[test]
2415    fn test_submit_clears() {
2416        let mut editor = Editor::new(EditorOptions::default());
2417        editor.set_text("hello");
2418        let result = editor.lines.join("\n");
2419        editor.lines = vec![String::new()];
2420        editor.cursor_line = 0;
2421        editor.cursor_col = 0;
2422        assert_eq!(result, "hello");
2423        assert_eq!(editor.get_text(), "");
2424    }
2425
2426    #[test]
2427    fn test_render_borders() {
2428        let mut editor = Editor::new(EditorOptions::default());
2429        let lines = editor.render(80);
2430        assert!(lines.len() >= 3);
2431        assert!(lines[0].contains('─'));
2432        assert!(lines.last().unwrap().contains('─'));
2433    }
2434
2435    #[test]
2436    fn test_scroll_indicator() {
2437        let mut editor = Editor::new(EditorOptions { padding_x: 1 });
2438        // Set terminal_rows=6 → max_vis = max(5, 1) = 5.
2439        // With 6 content lines and cursor at the bottom, scroll offset of 2
2440        // should produce an up-arrow indicator at the top.
2441        editor.set_terminal_rows(6);
2442        editor.set_text("line1\nline2\nline3\nline4\nline5\nline6");
2443        editor.cursor_line = 5;
2444        editor.cursor_col = 5;
2445        editor.scroll_offset = 2;
2446        let lines = editor.render(80);
2447        assert!(
2448            lines[0].contains("↑"),
2449            "Expected scroll-up indicator, got: {:?}",
2450            lines[0]
2451        );
2452    }
2453
2454    #[test]
2455    fn test_newline() {
2456        let mut editor = Editor::new(EditorOptions::default());
2457        editor.set_text("hello");
2458        editor.add_newline();
2459        assert_eq!(editor.get_text(), "hello\n");
2460        editor.insert_character("w");
2461        assert_eq!(editor.get_text(), "hello\nw");
2462    }
2463
2464    #[test]
2465    fn test_cursor_in_layout() {
2466        let editor = Editor::new(EditorOptions::default());
2467        // Empty editor - cursor should be in visual line 0
2468        let vl = layout_text(&editor.lines, 80, editor.cursor_line, editor.cursor_col);
2469        assert!(vl[0].has_cursor);
2470        assert_eq!(vl[0].cursor_pos, Some(0));
2471    }
2472
2473    #[test]
2474    fn test_cursor_in_layout_with_text() {
2475        let mut editor = Editor::new(EditorOptions::default());
2476        editor.set_text("abc");
2477        editor.cursor_col = 1;
2478        let vl = layout_text(&editor.lines, 80, editor.cursor_line, editor.cursor_col);
2479        assert!(vl[0].has_cursor);
2480        assert_eq!(vl[0].cursor_pos, Some(1));
2481    }
2482
2483    // ── Ported from pi-tui editor.test.ts ──
2484
2485    fn up_key() -> KeyEvent {
2486        KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)
2487    }
2488    fn left_key() -> KeyEvent {
2489        KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)
2490    }
2491    fn char_key(c: char) -> KeyEvent {
2492        KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
2493    }
2494    fn enter_key() -> KeyEvent {
2495        KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
2496    }
2497    fn escape() -> KeyEvent {
2498        KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)
2499    }
2500    fn backspace() -> KeyEvent {
2501        KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)
2502    }
2503
2504    #[test]
2505    fn test_history_empty_up_does_nothing() {
2506        let mut editor = Editor::new(EditorOptions::default());
2507        editor.handle_input(&up_key());
2508        assert_eq!(editor.get_text(), "");
2509    }
2510
2511    #[test]
2512    fn test_history_up_shows_most_recent() {
2513        let mut editor = Editor::new(EditorOptions::default());
2514        editor.add_to_history("first");
2515        editor.add_to_history("second");
2516        editor.handle_input(&up_key());
2517        assert_eq!(editor.get_text(), "second");
2518    }
2519
2520    #[test]
2521    fn test_history_cycles() {
2522        let mut editor = Editor::new(EditorOptions::default());
2523        editor.add_to_history("first");
2524        editor.add_to_history("second");
2525        editor.add_to_history("third");
2526        editor.handle_input(&up_key());
2527        assert_eq!(editor.get_text(), "third");
2528        editor.handle_input(&up_key());
2529        assert_eq!(editor.get_text(), "second");
2530        editor.handle_input(&up_key());
2531        assert_eq!(editor.get_text(), "first");
2532        editor.handle_input(&up_key()); // stays at oldest
2533        assert_eq!(editor.get_text(), "first");
2534    }
2535
2536    #[test]
2537    fn test_history_exits_on_type() {
2538        let mut editor = Editor::new(EditorOptions::default());
2539        editor.add_to_history("old");
2540        editor.handle_input(&up_key());
2541        assert_eq!(editor.get_text(), "old");
2542        editor.handle_input(&char_key('x'));
2543        assert_eq!(editor.get_text(), "xold");
2544    }
2545
2546    #[test]
2547    fn test_backslash_enter_newline() {
2548        let mut editor = Editor::new(EditorOptions::default());
2549        editor.handle_input(&char_key('\\'));
2550        assert_eq!(editor.get_text(), "\\");
2551        editor.handle_input(&enter_key());
2552        assert_eq!(editor.get_text(), "\n");
2553    }
2554
2555    #[test]
2556    fn test_move_cursor_over_emoji() {
2557        let mut editor = Editor::new(EditorOptions::default());
2558        editor.set_text("a😀b");
2559        editor.cursor_col = 0;
2560        editor.move_right();
2561        assert_eq!(editor.cursor_col, 1);
2562        editor.move_right();
2563        assert_eq!(editor.cursor_col, 5);
2564        editor.move_right();
2565        assert_eq!(editor.cursor_col, 6);
2566    }
2567
2568    #[test]
2569    fn test_backspace_emoji() {
2570        let mut editor = Editor::new(EditorOptions::default());
2571        editor.set_text("a😀b");
2572        editor.cursor_col = 6;
2573        editor.backspace();
2574        assert_eq!(editor.get_text(), "a😀");
2575        editor.backspace();
2576        assert_eq!(editor.get_text(), "a");
2577    }
2578
2579    #[test]
2580    fn test_render_cursor_visible() {
2581        let mut editor = Editor::new(EditorOptions::default());
2582        editor.focused = true;
2583        editor.insert_character("x");
2584        let lines = editor.render(40);
2585        let content = &lines[1];
2586        assert!(content.contains("\x1b[7m"), "Cursor inverse not found");
2587    }
2588
2589    #[test]
2590    fn test_emits_cursor_marker_when_focused() {
2591        let mut editor = Editor::new(EditorOptions::default());
2592        editor.focused = true;
2593        editor.insert_character("hello");
2594        let lines = editor.render(40);
2595        let content = &lines[1];
2596        assert!(
2597            content.contains(CURSOR_MARKER),
2598            "Focused editor should emit cursor marker"
2599        );
2600    }
2601
2602    #[test]
2603    fn test_no_cursor_marker_when_not_focused() {
2604        let mut editor = Editor::new(EditorOptions::default());
2605        editor.focused = false;
2606        editor.insert_character("hello");
2607        let lines = editor.render(40);
2608        let content = &lines[1];
2609        assert!(
2610            !content.contains(CURSOR_MARKER),
2611            "Unfocused editor should not emit cursor marker"
2612        );
2613    }
2614
2615    #[test]
2616    fn test_render_borders_always_present() {
2617        let mut editor = Editor::new(EditorOptions::default());
2618        let lines = editor.render(80);
2619        assert_eq!(lines.len(), 3, "Empty editor should have 3 lines");
2620        assert!(lines[0].contains('─'), "Top border missing");
2621        assert!(lines[2].contains('─'), "Bottom border missing");
2622
2623        editor.insert_character("/");
2624        let lines = editor.render(80);
2625        assert_eq!(lines.len(), 3, "After typing / should still have 3 lines");
2626        assert!(lines[0].contains('─'), "Top border missing after /");
2627        assert!(lines[2].contains('─'), "Bottom border missing after /");
2628
2629        editor.set_text("hello world this is text");
2630        let lines = editor.render(40);
2631        assert!(lines.len() >= 3, "Wrapped text: {}", lines.len());
2632        assert!(lines[0].contains('─'), "Top border");
2633        assert!(lines.last().unwrap().contains('─'), "Bottom border");
2634    }
2635
2636    #[test]
2637    fn test_content_width_respected() {
2638        let mut editor = Editor::new(EditorOptions { padding_x: 1 });
2639        editor.set_text("hello world this is a test");
2640        let lines = editor.render(20);
2641        for line in &lines {
2642            let vw = crate::tui::util::visible_width(line);
2643            assert!(vw <= 20, "Width {} > 20: {:?}", vw, line);
2644        }
2645    }
2646
2647    // ── Wrap/duplication tests ───────────────────────────────────
2648
2649    #[test]
2650    fn test_no_duplicate_chunks_from_wrapping() {
2651        // wrap_text_with_ansi must not produce duplicate chunks from a single input.
2652        // The same content may appear in multiple chunks if the source has it
2653        // multiple times, but it must not appear MORE times than in the original.
2654        let texts = [
2655            "hello world this is a test of the wrapping system",
2656            "a b c d e f g h i j k l m n o p q r s t u v w x y z",
2657            "short",
2658            "",
2659            "abc abc abc abc abc abc abc abc",
2660            "  leading and trailing spaces  ",
2661            "hello   world   extra   spaces",
2662        ];
2663        for text in &texts {
2664            for width in [1, 2, 3, 5, 8, 12, 20, 40] {
2665                let wrapped = crate::tui::util::wrap_text_with_ansi(text, width);
2666
2667                // Total visible content of wrapped chunks must not exceed original
2668                let total_vis_wrapped: usize = wrapped.iter().map(|c| visible_width(c)).sum();
2669                let total_vis_original = visible_width(text);
2670                assert!(
2671                    total_vis_wrapped <= total_vis_original,
2672                    "Width={}: wrapped visible {} > original visible {} for {:?}",
2673                    width,
2674                    total_vis_wrapped,
2675                    total_vis_original,
2676                    text
2677                );
2678
2679                // No non-empty chunk should appear more times than it occurs
2680                // as a substring in the original text.
2681                for a in &wrapped {
2682                    if a.is_empty() {
2683                        continue;
2684                    }
2685                    let count_in_wrapped = wrapped.iter().filter(|c| *c == a).count();
2686                    let count_in_original = text.matches(a.as_str()).count();
2687                    assert!(
2688                        count_in_wrapped <= count_in_original || count_in_original == 0,
2689                        "Width={}: chunk '{}' appears {}x in wrapped but {}x in original for {:?}",
2690                        width,
2691                        a,
2692                        count_in_wrapped,
2693                        count_in_original,
2694                        text
2695                    );
2696                }
2697            }
2698        }
2699    }
2700
2701    #[test]
2702    fn test_cursor_in_wrapped_text_first_chunk() {
2703        // Cursor at position within first wrapped chunk
2704        let mut editor = Editor::new(EditorOptions::default());
2705        let text = "hello world this is a test";
2706        editor.set_text(text);
2707        // cursor at position 3 ('l' in "hello")
2708        editor.cursor_col = 3;
2709        let vl = layout_text(&editor.lines, 10, editor.cursor_line, editor.cursor_col);
2710        assert!(vl.len() > 1, "Text should wrap into multiple visual lines");
2711        assert!(
2712            vl[0].has_cursor,
2713            "Cursor at col 3 should be in first visual line"
2714        );
2715        if let Some(pos) = vl[0].cursor_pos {
2716            assert_eq!(pos, 3, "Cursor byte offset in first chunk should be 3");
2717        }
2718    }
2719
2720    #[test]
2721    fn test_cursor_in_wrapped_text_middle_chunk() {
2722        // Cursor at position within the middle chunk
2723        let mut editor = Editor::new(EditorOptions::default());
2724        let text = "hello world this is a test";
2725        editor.set_text(text);
2726        // "hello world this" = 16 chars, cursor at col 16 = end of "hello world this"
2727        // which should be the last byte of chunk 0 ("hello worl" at width 10)
2728        // Actually at width 10, chunk 0 might be "hello worl", chunk 1 "d this is", chunk 2 " a test"
2729        editor.cursor_col = 16;
2730        let vl = layout_text(&editor.lines, 10, editor.cursor_line, editor.cursor_col);
2731        assert!(vl.len() > 1, "Text should wrap");
2732        let cursor_vl = vl.iter().position(|v| v.has_cursor);
2733        assert!(
2734            cursor_vl.is_some(),
2735            "Cursor should be found in some visual line"
2736        );
2737    }
2738
2739    #[test]
2740    fn test_cursor_last_chunk_on_boundary() {
2741        // Cursor at last byte of text - should be in the last visual line
2742        let mut editor = Editor::new(EditorOptions::default());
2743        let text = "hello world this is a test";
2744        editor.set_text(text);
2745        editor.cursor_col = text.len();
2746        let vl = layout_text(&editor.lines, 10, editor.cursor_line, editor.cursor_col);
2747        assert!(
2748            vl.last().is_some_and(|v| v.has_cursor),
2749            "Cursor at end should be in last visual line"
2750        );
2751    }
2752
2753    #[test]
2754    fn test_layout_text_each_chunk_unique() {
2755        // layout_text should never produce VisualLines with identical text
2756        // from a single logical line's wrapping.
2757        let text = "hello world this is a test of the wrapping system";
2758        let vl = layout_text(&[text.to_string()], 12, 0, 0);
2759        let chunk_texts: Vec<&str> = vl.iter().map(|v| v.text.as_str()).collect();
2760        for i in 0..chunk_texts.len() {
2761            for j in (i + 1)..chunk_texts.len() {
2762                if chunk_texts[i] == chunk_texts[j] {
2763                    // Same text is OK if the text is empty (edge case)
2764                    if !chunk_texts[i].is_empty() {
2765                        panic!(
2766                            "Duplicate chunk text at positions {} and {}: '{}'",
2767                            i, j, chunk_texts[i]
2768                        );
2769                    }
2770                }
2771            }
2772        }
2773    }
2774
2775    // ── visual_col_to_byte_offset tests ──────────────────────────
2776
2777    #[test]
2778    fn test_visual_col_to_byte_offset_ascii() {
2779        let text = "hello";
2780        assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 0), 0);
2781        assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 3), 3);
2782        assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 5), 5);
2783    }
2784
2785    #[test]
2786    fn test_visual_col_to_byte_offset_cjk() {
2787        let text = "世界hello";
2788        assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 0), 0);
2789        // "世" is width 2, 2 bytes
2790        assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 2), 3);
2791        // "世界" is width 4, 6 bytes
2792        assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 4), 6);
2793    }
2794
2795    #[test]
2796    fn test_visual_col_to_byte_offset_ansi() {
2797        // "\x1b[31m" = 5 bytes, "hello" = 5 bytes, "\x1b[0m" = 4 bytes = 14 total
2798        let text = "\x1b[31mhello\x1b[0m";
2799        // visible width is 5 ("hello"), ANSI codes are invisible
2800        assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 0), 5); // "h" at byte 5
2801        assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 1), 6); // "e" at byte 6
2802        assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 2), 7); // first "l" at byte 7
2803        assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 3), 8); // second "l" at byte 8
2804        assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 4), 9); // "o" at byte 9
2805        assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 5), 14); // end at byte 14
2806    }
2807
2808    #[test]
2809    fn test_visual_col_to_byte_offset_empty() {
2810        assert_eq!(crate::tui::util::visual_col_to_byte_offset("", 0), 0);
2811        assert_eq!(crate::tui::util::visual_col_to_byte_offset("", 5), 0);
2812    }
2813
2814    #[test]
2815    fn test_visual_col_to_byte_offset_zero_col() {
2816        // Plain ASCII: first visible char is at byte 0
2817        assert_eq!(crate::tui::util::visual_col_to_byte_offset("abc", 0), 0);
2818        // ANSI-prefixed: first visible char is after the ANSI code
2819        assert_eq!(
2820            crate::tui::util::visual_col_to_byte_offset("\x1b[31mabc", 0),
2821            5
2822        );
2823    }
2824
2825    // ── Paste marker tests ──
2826
2827    #[test]
2828    fn test_large_paste_creates_marker() {
2829        let mut editor = Editor::new(EditorOptions::default());
2830        let large = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11";
2831        editor.handle_paste(large);
2832        let text = editor.get_text();
2833        assert!(text.contains("[paste #"), "Should contain paste marker");
2834        assert!(
2835            !text.contains("line1"),
2836            "Should not contain original content"
2837        );
2838        assert_eq!(editor.pastes.len(), 1, "Should store one paste");
2839    }
2840
2841    #[test]
2842    fn test_small_paste_no_marker() {
2843        let mut editor = Editor::new(EditorOptions::default());
2844        editor.handle_paste("hello");
2845        let text = editor.get_text();
2846        assert!(
2847            !text.contains("[paste #"),
2848            "Small paste should not create marker"
2849        );
2850        assert_eq!(text, "hello");
2851    }
2852
2853    #[test]
2854    fn test_expand_paste_markers() {
2855        let mut editor = Editor::new(EditorOptions::default());
2856        editor.handle_paste(
2857            "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11",
2858        );
2859        let expanded = editor.get_expanded_text();
2860        assert!(
2861            expanded.contains("line1"),
2862            "Expanded text should contain original content"
2863        );
2864        assert!(
2865            !expanded.contains("[paste #"),
2866            "Expanded text should not contain markers"
2867        );
2868    }
2869
2870    #[test]
2871    fn test_submit_expands_markers() {
2872        let mut editor = Editor::new(EditorOptions::default());
2873        editor.handle_paste(
2874            "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11",
2875        );
2876        let large_content =
2877            "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11";
2878        // Manually call the submit logic to verify expansion
2879        let raw = editor.lines.join("\n");
2880        let expanded = editor.expand_paste_markers(&raw);
2881        assert_eq!(
2882            expanded, large_content,
2883            "Submit should expand to original content"
2884        );
2885    }
2886
2887    #[test]
2888    fn test_is_paste_marker() {
2889        assert!(Editor::is_paste_marker("[paste #1 +5 lines]"));
2890        assert!(Editor::is_paste_marker("[paste #123 456 chars]"));
2891        assert!(!Editor::is_paste_marker("normal text"));
2892        assert!(!Editor::is_paste_marker(""));
2893    }
2894
2895    #[test]
2896    fn test_get_expanded_text() {
2897        let mut editor = Editor::new(EditorOptions::default());
2898        editor.handle_paste(
2899            "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11",
2900        );
2901        let expanded = editor.get_expanded_text();
2902        assert!(
2903            expanded.contains("line1"),
2904            "get_expanded_text should expand markers"
2905        );
2906        assert!(
2907            expanded.starts_with("line1"),
2908            "Should start with original content"
2909        );
2910    }
2911
2912    // ── Render duplication tests ──
2913
2914    #[test]
2915    fn test_multiline_render_no_duplicate_content() {
2916        let mut editor = Editor::new(EditorOptions::default());
2917        // Simulate: type "hello", add newline, type "world"
2918        editor.set_text("hello");
2919        editor.add_newline();
2920        editor.insert_character("w");
2921        editor.insert_character("o");
2922        editor.insert_character("r");
2923        editor.insert_character("l");
2924        editor.insert_character("d");
2925        assert_eq!(editor.get_text(), "hello\nworld");
2926
2927        // Render at various widths
2928        for width in [20, 40, 80] {
2929            let rendered = editor.render(width);
2930
2931            // Collect content lines (skip border lines)
2932            let content_lines: Vec<&str> = rendered
2933                .iter()
2934                .filter(|l| !l.contains('─'))
2935                .map(|l| l.trim())
2936                .collect();
2937
2938            // Check total content lines count matches expected (2: "hello" + "world")
2939            assert!(
2940                content_lines.len() >= 2,
2941                "Width {}: expected >= 2 content lines, got {}: {:?}",
2942                width,
2943                content_lines.len(),
2944                rendered
2945            );
2946
2947            // Check no duplicates among non-empty content lines
2948            let mut seen = std::collections::HashSet::new();
2949            for line in &content_lines {
2950                if !line.is_empty() {
2951                    let plain = line.replace("\x1b_pi:c\x07", "").to_string();
2952                    if !seen.insert(plain.clone()) {
2953                        panic!(
2954                            "Width {}: duplicate content line '{}' in {:?}",
2955                            width, line, rendered
2956                        );
2957                    }
2958                }
2959            }
2960        }
2961    }
2962
2963    #[test]
2964    fn test_editor_add_newline_adds_one_visual_line() {
2965        let mut editor = Editor::new(EditorOptions::default());
2966        editor.set_text("hello");
2967
2968        let before = editor.render(80).len();
2969        editor.add_newline();
2970        let after = editor.render(80).len();
2971
2972        assert_eq!(
2973            after,
2974            before + 1,
2975            "Adding newline should increase rendered line count by exactly 1. before={}, after={}",
2976            before,
2977            after
2978        );
2979    }
2980
2981    #[test]
2982    fn test_layout_text_no_extra_empty_visual_line() {
2983        // layout_text should not produce an extra empty visual line
2984        // when transitioning from empty to single-line content.
2985        let lines: Vec<String> = vec![String::new()];
2986        let vl = layout_text(&lines, 80, 0, 0);
2987        assert_eq!(vl.len(), 1, "Empty text should have 1 visual line");
2988        assert!(vl[0].has_cursor);
2989
2990        let lines = vec!["hello".to_string()];
2991        let vl = layout_text(&lines, 80, 0, 5);
2992        assert_eq!(vl.len(), 1, "Single line should have 1 visual line");
2993        assert!(vl[0].has_cursor);
2994
2995        let lines = vec!["hello".to_string(), "".to_string()];
2996        let vl = layout_text(&lines, 80, 0, 5);
2997        assert_eq!(
2998            vl.len(),
2999            2,
3000            "Two lines (one empty) should have 2 visual lines"
3001        );
3002        // Cursor is on line 0 ("hello"), so first visual line has cursor
3003        assert!(vl[0].has_cursor);
3004        assert!(!vl[1].has_cursor);
3005
3006        let lines = vec!["hello".to_string(), "".to_string()];
3007        let vl = layout_text(&lines, 80, 1, 0);
3008        assert_eq!(vl.len(), 2);
3009        // Cursor is on line 1 (empty), so second visual line has cursor
3010        assert!(!vl[0].has_cursor);
3011        assert!(vl[1].has_cursor);
3012
3013        let lines = vec!["".to_string(), "hello".to_string()];
3014        let vl = layout_text(&lines, 80, 1, 5);
3015        assert_eq!(
3016            vl.len(),
3017            2,
3018            "Two lines (one empty first) should have 2 visual lines"
3019        );
3020        assert!(!vl[0].has_cursor);
3021        assert!(vl[1].has_cursor);
3022    }
3023
3024    #[test]
3025    fn test_wrap_edge_cases_no_empty_lines() {
3026        // Various edge cases that should NOT produce empty lines.
3027        // Empty strings in wrapped output cause visual artifacts
3028        // (blank lines appearing in the editor).
3029        let cases = vec![
3030            ("  hello", 3, "leading spaces"),
3031            ("hello  ", 3, "trailing spaces"),
3032            ("  hello  ", 3, "leading and trailing spaces"),
3033            ("abc  def", 5, "double space in middle"),
3034            ("a   b", 4, "triple space"),
3035            ("a  b", 3, "double space at wrap boundary"),
3036        ];
3037        for (text, width, label) in &cases {
3038            // Debug: print the actual tokens produced by split_into_tokens
3039            // to verify our understanding of tokenization.
3040            let wrapped = crate::tui::util::wrap_text_with_ansi(text, *width);
3041            for chunk in &wrapped {
3042                // A non-empty input should never produce empty chunks
3043                if chunk.is_empty() {
3044                    panic!(
3045                        "Case '{}' (width {}): empty chunk found in wrapped: {:?}",
3046                        label, width, wrapped
3047                    );
3048                }
3049                let vis = crate::tui::util::visible_width(chunk);
3050                assert!(
3051                    vis > 0,
3052                    "Case '{}' (width {}): chunk with visible width 0: {:?} (wrapped: {:?})",
3053                    label,
3054                    width,
3055                    chunk,
3056                    wrapped
3057                );
3058            }
3059        }
3060    }
3061
3062    #[test]
3063    fn test_wrap_long_word_no_duplicate_chunks() {
3064        // A long continuous word (no spaces) past width should not duplicate
3065        let long = "aaaaa bbbbb ccccc ddddd";
3066        for width in [5, 6, 7, 8, 10, 12] {
3067            let wrapped = crate::tui::util::wrap_text_with_ansi(long, width);
3068            // Count visible content and check for duplicates
3069            let mut seen = std::collections::HashSet::new();
3070            for chunk in &wrapped {
3071                let trimmed = chunk.trim();
3072                if !trimmed.is_empty() && !seen.insert(trimmed.to_string()) {
3073                    panic!(
3074                        "Width {}: duplicate chunk '{}' in {:?}",
3075                        width, chunk, wrapped
3076                    );
3077                }
3078            }
3079        }
3080    }
3081
3082    #[test]
3083    fn test_wrap_typing_detailed_trace() {
3084        // Simulate typing character by character into the editor,
3085        // checking the visual line layout after each character.
3086        let mut editor = Editor::new(EditorOptions::default());
3087
3088        // Type a sentence that exceeds width 10, checking after each char
3089        let sentence = "hello world";
3090        let width = 10;
3091
3092        for (i, ch) in sentence.chars().enumerate() {
3093            editor.handle_input(&char_key(ch));
3094
3095            // Get visual lines via layout_text (simulating what render does)
3096            let vl = layout_text(&editor.lines, width, editor.cursor_line, editor.cursor_col);
3097
3098            // Check no duplicate visual lines or empty lines
3099            let mut seen = std::collections::HashSet::new();
3100            for vis in &vl {
3101                let trimmed = vis.text.trim();
3102                if !trimmed.is_empty() && !seen.insert(trimmed.to_string()) {
3103                    panic!(
3104                        "After char '{}' (pos {}): duplicate visual line '{}' in {:?}",
3105                        ch, i, vis.text, vl
3106                    );
3107                }
3108            }
3109
3110            // Check exactly one cursor
3111            let cursor_count = vl.iter().filter(|v| v.has_cursor).count();
3112            assert_eq!(
3113                cursor_count, 1,
3114                "After char '{}' (pos {}): expected exactly 1 cursor, got {}. vl: {:?}",
3115                ch, i, cursor_count, vl
3116            );
3117        }
3118    }
3119
3120    #[test]
3121    fn test_wrap_long_continuous_string_no_duplicates() {
3122        // A very long continuous string (like a URL or path) with no spaces.
3123        // Must not produce duplicate chunks when word-broken across lines.
3124        let mut editor = Editor::new(EditorOptions::default());
3125
3126        // Simulate typing a long URL character by character
3127        let url = "https://very-long-url-with-no-spaces.example.com/path/to/resource";
3128        for ch in url.chars() {
3129            editor.handle_input(&char_key(ch));
3130        }
3131
3132        // Test at various narrow widths
3133        for width in [5, 10, 15, 20, 30] {
3134            let rendered = editor.render(width);
3135            let content: Vec<&str> = rendered
3136                .iter()
3137                .filter(|l| !l.contains('─'))
3138                .map(|l| l.trim())
3139                .filter(|l| !l.is_empty())
3140                .collect();
3141
3142            let mut seen = std::collections::HashSet::new();
3143            for line in &content {
3144                let plain = line
3145                    .replace("\x1b_pi:c\x07", "")
3146                    .chars()
3147                    .filter(|&c| c.is_ascii_graphic() || c == ' ')
3148                    .collect::<String>()
3149                    .trim()
3150                    .to_string();
3151                if !plain.is_empty() && !seen.insert(plain.clone()) {
3152                    panic!(
3153                        "Width {}: duplicate content line '{}' (plain: '{}')\nFull render: {:?}",
3154                        width, line, plain, rendered
3155                    );
3156                }
3157            }
3158        }
3159    }
3160
3161    #[test]
3162    fn test_editor_typing_past_width_no_duplicate_render() {
3163        // Simulate typing characters one at a time until the line exceeds the width.
3164        // The rendered output must never show the same content line twice.
3165        let mut editor = Editor::new(EditorOptions::default());
3166
3167        // Type characters to build up a line longer than the render width
3168        let input = "hello world this is a test of the emergency broadcast system";
3169        for ch in input.chars() {
3170            editor.handle_input(&char_key(ch));
3171        }
3172
3173        // Render at a narrow width so wrapping occurs
3174        for width in [5, 8, 10, 12, 15, 20] {
3175            let rendered = editor.render(width);
3176
3177            // Collect visible content (skip border/scroll indicator lines)
3178            let content: Vec<&str> = rendered
3179                .iter()
3180                .filter(|l| !l.contains('─'))
3181                .map(|l| l.trim())
3182                .filter(|l| !l.is_empty())
3183                .collect();
3184
3185            // Check for duplicates among content lines
3186            let mut seen = std::collections::HashSet::new();
3187            for line in &content {
3188                // Strip cursor marker and ansi codes for comparison
3189                let plain = line
3190                    .replace("\x1b_pi:c\x07", "")
3191                    .chars()
3192                    .filter(|&c| c.is_ascii_graphic() || c == ' ')
3193                    .collect::<String>()
3194                    .trim()
3195                    .to_string();
3196                if !plain.is_empty() && !seen.insert(plain.clone()) {
3197                    panic!(
3198                        "Width {}: duplicate content line '{}' (plain: '{}')\nFull render: {:?}",
3199                        width, line, plain, rendered
3200                    );
3201                }
3202            }
3203
3204            // Also check that the total content roughly matches input (accounting for wrapping)
3205            let content_plain: String = content.join(" ");
3206            let content_plain = content_plain
3207                .replace("\x1b_pi:c\x07", "")
3208                .chars()
3209                .filter(|&c| c.is_ascii_graphic() || c == ' ')
3210                .collect::<String>();
3211            assert!(
3212                !content_plain.is_empty(),
3213                "Width {}: no visible content in render: {:?}",
3214                width,
3215                rendered
3216            );
3217        }
3218    }
3219}