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