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