Skip to main content

rpi_tui/
editor.rs

1//! Editor component for multi-line text editing.
2//!
3//! Provides a text editor with cursor positioning, selection, and keyboard input.
4
5use std::any::Any;
6use std::collections::HashMap;
7use std::sync::OnceLock;
8use std::sync::{Arc, Mutex};
9
10use crossterm::event::{KeyCode, KeyModifiers};
11use unicode_width::UnicodeWidthStr;
12
13use super::component::{Component, Focusable};
14use super::keybindings::Keybindings;
15use crate::ansi::CURSOR_MARKER;
16use crate::kill_ring::{KillRing, PushOptions};
17use crate::theme::Color;
18use crate::undo_stack::UndoStack;
19
20/// Editor style configuration.
21#[derive(Debug, Clone)]
22pub struct EditorStyle {
23    pub prompt: String,
24    pub placeholder: String,
25}
26
27impl Default for EditorStyle {
28    fn default() -> Self {
29        Self {
30            prompt: "> ".to_string(),
31            placeholder: String::new(),
32        }
33    }
34}
35
36/// Editor options.
37#[derive(Debug, Clone, Default)]
38pub struct EditorOptions {
39    /// Padding on left and right.
40    pub padding_x: usize,
41    /// Maximum visible lines for autocomplete.
42    pub autocomplete_max_visible: usize,
43    /// Placeholder text when empty.
44    pub placeholder: Option<String>,
45    /// Initial text content.
46    pub initial_text: Option<String>,
47}
48
49/// Editor state.
50#[derive(Debug, Clone)]
51struct EditorState {
52    /// Lines of text.
53    lines: Vec<String>,
54    /// Cursor row (line index).
55    cursor_row: usize,
56    /// Cursor column (character index within line).
57    cursor_col: usize,
58    /// Selection anchor (row, col) if any.
59    selection_anchor: Option<(usize, usize)>,
60    /// Scroll offset for multi-line.
61    #[allow(dead_code)]
62    scroll_offset: usize,
63    /// Whether the editor is focused.
64    focused: bool,
65    /// History of submitted lines.
66    history: Vec<String>,
67    /// History index for navigation.
68    history_index: usize,
69}
70
71impl Default for EditorState {
72    fn default() -> Self {
73        Self {
74            lines: vec![String::new()],
75            cursor_row: 0,
76            cursor_col: 0,
77            selection_anchor: None,
78            scroll_offset: 0,
79            focused: false,
80            history: Vec::new(),
81            history_index: 0,
82        }
83    }
84}
85
86/// Greatest char-boundary byte index in `s` that is `<= idx` (clamped to
87/// `s.len()`). The editor tracks `cursor_col` as a **byte** index and keeps it
88/// on a char boundary; this helper restores that invariant when a caller
89/// supplies a column that may be mid-character (e.g. clamping after moving
90/// across lines of differing widths) and when converting external char counts.
91///
92/// Why byte indices: every `String` mutation the editor performs
93/// (`insert`, `remove`, `truncate`, `drain`) and the `len()` comparisons used
94/// for clamping are byte-indexed in `std`. Treating `cursor_col` as a byte
95/// offset (advanced by `ch.len_utf8()`, never `+1`) keeps all of those sound
96/// for multibyte input. Movement ops use this helper to step by *character*
97/// while storing bytes.
98fn snap_boundary(s: &str, idx: usize) -> usize {
99    let idx = idx.min(s.len());
100    // A valid char boundary (including the END of the string) is kept as-is —
101    // the old `take_while(b <= idx)` returned the START of the char AT `idx`
102    // for boundary positions, so a caret placed exactly after the last char
103    // (e.g. autocomplete accept: `set_cursor(0, len)`) snapped one char EARLY
104    // and the terminal showed "/mode|l" with the final char dangling past the
105    // caret. Mid-character positions snap DOWN to the char start instead.
106    if s.is_char_boundary(idx) {
107        return idx;
108    }
109    s.char_indices()
110        .take_while(|(b, _)| *b < idx)
111        .last()
112        .map(|(b, _)| b)
113        .unwrap_or(0)
114}
115
116/// A folded store of a large paste, keyed by the id rendered in its marker.
117/// The document (`EditorState::lines`) only ever carries the short
118/// `[paste #id …]` marker; the real text lives here until the draft is
119/// submitted, at which point [`Editor::get_expanded_text`] splices it back in.
120#[derive(Debug, Clone)]
121struct PasteEntry {
122    content: String,
123}
124
125/// Matches a whole `[paste #id …]` marker (group 1 = id).
126fn paste_marker_regex() -> &'static regex::Regex {
127    static RE: OnceLock<regex::Regex> = OnceLock::new();
128    RE.get_or_init(|| regex::Regex::new(r"\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]").unwrap())
129}
130
131/// The byte length of the `[paste #id …]` marker ending exactly at `end`
132/// (`end` is a byte offset in `line`), or `0` when no marker ends there.
133fn paste_marker_len_ending_at(line: &str, end: usize) -> usize {
134    for m in paste_marker_regex().find_iter(line) {
135        if m.end() == end {
136            return m.as_str().len();
137        }
138    }
139    0
140}
141
142/// A paste folds when it exceeds either threshold (upstream's `handlePaste`).
143const PASTE_FOLD_LINE_THRESHOLD: usize = 10;
144const PASTE_FOLD_CHAR_THRESHOLD: usize = 1000;
145
146/// Replace every `[paste #id …]` marker in `document` with its stashed body
147/// (upstream's `expandPasteMarkers`). Markers whose id is absent from
148/// `pastes` are left as-is so a stray marker never silently vanishes.
149fn expand_paste_markers(document: &str, pastes: &HashMap<u32, PasteEntry>) -> String {
150    if pastes.is_empty() || !document.contains("[paste #") {
151        return document.to_string();
152    }
153    paste_marker_regex()
154        .replace_all(document, |caps: &regex::Captures| {
155            let id: u32 = caps[1].parse().unwrap_or(0);
156            match pastes.get(&id) {
157                Some(entry) => entry.content.clone(),
158                None => caps[0].to_string(),
159            }
160        })
161        .into_owned()
162}
163
164/// Decode the kitty CSI-u form a client may emit for `Ctrl+<letter>`
165/// (`ESC[<code>;5u`), which upstream decodes before folding
166/// (`handlePaste`). Scalars that are not a `Ctrl+letter` chord are left as-is.
167fn decode_csi_u_ctrl(text: &str) -> String {
168    if !text.contains("\x1b[") {
169        return text.to_string();
170    }
171    static RE: OnceLock<regex::Regex> = OnceLock::new();
172    let re = RE.get_or_init(|| regex::Regex::new(r"\x1b\[(\d+);5u").unwrap());
173    re.replace_all(text, |caps: &regex::Captures| {
174        let code: u32 = caps[1].parse().unwrap_or(0);
175        let lower = 0x61..=0x7a;
176        let upper = 0x41..=0x5a;
177        if lower.contains(&code) {
178            char::from_u32(code - 96)
179                .map(String::from)
180                .unwrap_or_default()
181        } else if upper.contains(&code) {
182            char::from_u32(code - 64)
183                .map(String::from)
184                .unwrap_or_default()
185        } else {
186            caps[0].to_string()
187        }
188    })
189    .into_owned()
190}
191
192/// Split a logical editor line into terminal-width chunks. The editor keeps
193/// logical lines for editing and cursor movement; wrapping is strictly a
194/// rendering concern so a long draft cannot escape the bordered input box.
195fn wrap_line_ranges(line: &str, max_width: usize) -> Vec<(usize, usize)> {
196    if line.is_empty() {
197        return vec![(0, 0)];
198    }
199    let max_width = max_width.max(1);
200    let mut ranges = Vec::new();
201    let mut start = 0;
202    let mut width = 0;
203    for (idx, ch) in line.char_indices() {
204        let char_width = UnicodeWidthStr::width(ch.to_string().as_str());
205        if idx > start && width + char_width > max_width {
206            ranges.push((start, idx));
207            start = idx;
208            width = 0;
209        }
210        width += char_width;
211    }
212    ranges.push((start, line.len()));
213    ranges
214}
215
216/// Insert the zero-width cursor marker at a visible column without slicing
217/// through ANSI escape sequences added for selection/placeholder styling.
218fn insert_cursor_marker(text: &str, column: usize) -> String {
219    let mut out = String::with_capacity(text.len() + CURSOR_MARKER.len());
220    let mut visible = 0;
221    let mut chars = text.char_indices().peekable();
222    let mut inserted = false;
223    while let Some((idx, ch)) = chars.next() {
224        if ch == '\x1b' {
225            out.push(ch);
226            while let Some((_, esc)) = chars.next() {
227                out.push(esc);
228                if esc == '\x07' || ('@'..='~').contains(&esc) {
229                    break;
230                }
231            }
232            continue;
233        }
234        if !inserted && visible >= column {
235            out.push_str(CURSOR_MARKER);
236            inserted = true;
237        }
238        out.push(ch);
239        visible += UnicodeWidthStr::width(ch.to_string().as_str());
240        let _ = idx;
241    }
242    if !inserted {
243        out.push_str(CURSOR_MARKER);
244    }
245    out
246}
247
248/// Editor - A multi-line text editor component.
249pub struct Editor {
250    state: Mutex<EditorState>,
251    options: EditorOptions,
252    #[allow(dead_code)]
253    style: EditorStyle,
254    #[allow(dead_code)]
255    keybindings: Arc<Keybindings>,
256    on_submit: Mutex<Option<Arc<dyn Fn(&str) + Send + Sync>>>,
257    on_change: Mutex<Option<Arc<dyn Fn(&str) + Send + Sync>>>,
258    /// Undo history (text + caret snapshots). Pushed before each mutation;
259    /// consecutive character insertions coalesce into one entry.
260    undo_stack: Mutex<UndoStack<EditorSnapshot>>,
261    /// Popped undo entries, for Ctrl+R redo.
262    redo_stack: Mutex<Vec<EditorSnapshot>>,
263    /// Emacs-style kill ring for Ctrl+K/U/W kills; Ctrl+Y yanks.
264    kill_ring: Mutex<KillRing>,
265    /// The last mutation kind, for undo coalescing + kill accumulation.
266    last_action: Mutex<Option<&'static str>>,
267    /// Character-jump mode (pi `jumpForward`/`jumpBackward`): `Some(1)` waits
268    /// for the next printable char to jump forward to, `Some(-1)` backward.
269    /// The next character input consumes it instead of inserting.
270    jump_mode: Mutex<Option<i32>>,
271    /// Explicit top/bottom border color. `None` ⇒ the theme's default border
272    /// color. Upstream's `editor.borderColor` (used for bash mode and thinking
273    /// levels via `updateEditorBorderColor`).
274    border_color: Mutex<Option<Color>>,
275    /// Working indicator text shown inside the top border. `None` means idle.
276    /// When `Some`, the top border line renders the spinner + elapsed + message
277    /// instead of plain `─` characters (pi's working-inside-input-border style).
278    working: Mutex<Option<WorkingState>>,
279    /// Folded pastes: `id -> content`, referenced by a `[paste #id …]` marker
280    /// in `state.lines`. Mirrors upstream's `pastes` map.
281    pastes: Mutex<HashMap<u32, PasteEntry>>,
282    /// Monotonic id source for [`Self::pastes`] (upstream's `pasteCounter`).
283    paste_counter: Mutex<u32>,
284}
285
286/// State for the working indicator embedded in the editor's top border.
287#[derive(Debug, Clone)]
288pub struct WorkingState {
289    /// Spinner animation frame index.
290    pub frame: usize,
291    /// When the working state started (for elapsed time display).
292    pub started_at: std::time::Instant,
293    /// The message to display (e.g. "Working…").
294    pub message: String,
295}
296
297/// A restorable editor state snapshot (text + caret, plus the folded paste
298/// store so undo can resurrect a marker's content). Selection/scroll/focus
299/// are not part of undo (mirrors the TS `EditorSnapshot`).
300#[derive(Clone)]
301struct EditorSnapshot {
302    lines: Vec<String>,
303    cursor_row: usize,
304    cursor_col: usize,
305    pastes: HashMap<u32, PasteEntry>,
306    paste_counter: u32,
307}
308
309impl Editor {
310    /// Create a new editor with options.
311    pub fn new(options: EditorOptions, style: EditorStyle, keybindings: Arc<Keybindings>) -> Self {
312        let mut state = EditorState::default();
313        if let Some(text) = &options.initial_text {
314            state.lines = text.split('\n').map(|s| s.to_string()).collect();
315            if state.lines.is_empty() {
316                state.lines.push(String::new());
317            }
318        }
319
320        Self {
321            state: Mutex::new(state),
322            options,
323            style,
324            keybindings,
325            on_submit: Mutex::new(None),
326            on_change: Mutex::new(None),
327            undo_stack: Mutex::new(UndoStack::with_max_size(200)),
328            redo_stack: Mutex::new(Vec::new()),
329            kill_ring: Mutex::new(KillRing::new()),
330            last_action: Mutex::new(None),
331            jump_mode: Mutex::new(None),
332            border_color: Mutex::new(None),
333            working: Mutex::new(None),
334            pastes: Mutex::new(HashMap::new()),
335            paste_counter: Mutex::new(0),
336        }
337    }
338
339    /// Create a new editor with default options.
340    pub fn simple() -> Self {
341        Self::new(
342            EditorOptions::default(),
343            EditorStyle::default(),
344            Arc::new(Keybindings::new()),
345        )
346    }
347
348    /// Set the text content.
349    pub fn set_text(&self, text: &str) {
350        if let Ok(mut state) = self.state.lock() {
351            state.lines = if text.is_empty() {
352                vec![String::new()]
353            } else {
354                text.split('\n').map(|s| s.to_string()).collect()
355            };
356            if state.lines.is_empty() {
357                state.lines.push(String::new());
358            }
359            state.cursor_row = 0;
360            state.cursor_col = 0;
361        }
362        // A programmatic replacement discards any folded pastes: the new text
363        // is the whole truth (mirrors upstream's `setText` clearing `pastes`).
364        self.reset_pastes();
365        // Mirrors native `setTextInternal`: programmatic replacement is a change.
366        self.notify_change();
367    }
368
369    /// Get the text content **as shown**: folded large pastes appear as their
370    /// `[paste #id …]` marker, not their full body. This is the right source
371    /// for UI state that reasons about what is on screen (autocomplete,
372    /// empty-checks). Use [`Self::get_expanded_text`] for anything that must
373    /// see the user's real content.
374    pub fn get_text(&self) -> String {
375        self.state
376            .lock()
377            .map(|s| s.lines.join("\n"))
378            .unwrap_or_default()
379    }
380
381    /// Get the text content **with folded pastes expanded** (upstream's
382    /// `getExpandedText`): every `[paste #id …]` marker is replaced by the full
383    /// pasted body. Submission, the external editor and extension reads all go
384    /// through here so a folded paste is never sent truncated.
385    pub fn get_expanded_text(&self) -> String {
386        let document = self.get_text();
387        let pastes = self.pastes.lock().map(|p| p.clone()).unwrap_or_default();
388        expand_paste_markers(&document, &pastes)
389    }
390
391    /// Clear the folded-paste store and reset the id counter.
392    fn reset_pastes(&self) {
393        if let Ok(mut pastes) = self.pastes.lock() {
394            pastes.clear();
395        }
396        if let Ok(mut counter) = self.paste_counter.lock() {
397            *counter = 0;
398        }
399    }
400
401    /// The folded-paste id whose marker starts exactly at byte offset
402    /// `start` on line `row`, if any.
403    fn paste_id_at_marker_start(&self, row: usize, start: usize) -> Option<u32> {
404        let state = self.state.lock().ok()?;
405        let line = state.lines.get(row)?;
406        paste_marker_regex()
407            .captures_iter(line)
408            .find(|caps| caps.get(0).unwrap().start() == start)
409            .and_then(|caps| caps[1].parse::<u32>().ok())
410    }
411
412    /// Drop a folded paste and renumber the higher ids down by one so the
413    /// markers left in the document stay contiguous (upstream renumbers in
414    /// `handleBackspace`). Also rewrites the surviving markers in place.
415    fn remove_paste(&self, id: u32) {
416        {
417            let mut pastes = self.pastes.lock().unwrap();
418            if pastes.remove(&id).is_none() {
419                return;
420            }
421            let higher: Vec<u32> = pastes.keys().copied().filter(|k| *k > id).collect();
422            for old in higher {
423                if let Some(entry) = pastes.remove(&old) {
424                    pastes.insert(old - 1, entry);
425                }
426            }
427        }
428        {
429            let mut counter = self.paste_counter.lock().unwrap();
430            *counter = counter.saturating_sub(1);
431        }
432        // Rewrite the markers still in the document: ids above the removed one
433        // shift down by one, matching the renumbered store.
434        if let Ok(mut state) = self.state.lock() {
435            for line in state.lines.iter_mut() {
436                *line = paste_marker_regex()
437                    .replace_all(line, |caps: &regex::Captures| {
438                        let n: u32 = caps[1].parse().unwrap_or(0);
439                        if n <= id {
440                            caps[0].to_string()
441                        } else {
442                            // Group 2 already carries the leading space + suffix.
443                            let suffix = caps.get(2).map(|m| m.as_str()).unwrap_or("");
444                            format!("[paste #{}{suffix}]", n - 1)
445                        }
446                    })
447                    .into_owned();
448            }
449        }
450    }
451
452    /// Fold a pasted block: short pastes are inserted verbatim; a block over
453    /// the size threshold is stashed and replaced by a `[paste #id …]` marker
454    /// so the input box stays readable. Mirrors upstream's `handlePaste`
455    /// (fold when `>10` lines or `>1000` chars).
456    ///
457    /// A single-line pasted file path is special-cased by the caller (image
458    /// attachment), so this only handles text.
459    pub fn handle_paste(&self, text: &str) {
460        // Decode the CSI-u `Ctrl+<letter>` form a client may hand us, and
461        // normalize line endings + tabs (upstream's `normalizeText`).
462        let decoded = decode_csi_u_ctrl(text)
463            .replace("\r\n", "\n")
464            .replace('\r', "\n")
465            .replace('\t', "    ");
466        // Drop control characters, keeping newlines (upstream filters
467        // `char === "\n" || char.charCodeAt(0) >= 32`).
468        let filtered: String = decoded
469            .chars()
470            .filter(|c| *c == '\n' || (*c as u32) >= 32)
471            .collect();
472        if filtered.is_empty() {
473            return;
474        }
475
476        let line_count = filtered.split('\n').count();
477        let char_count = filtered.chars().count();
478        if line_count > PASTE_FOLD_LINE_THRESHOLD || char_count > PASTE_FOLD_CHAR_THRESHOLD {
479            self.push_undo("insert");
480            if self.has_selection() {
481                self.delete_selection_no_undo();
482            }
483            let (id, by_lines, count) = {
484                let mut counter = self.paste_counter.lock().unwrap();
485                *counter += 1;
486                let id = *counter;
487                if line_count > PASTE_FOLD_LINE_THRESHOLD {
488                    (id, true, line_count)
489                } else {
490                    (id, false, char_count)
491                }
492            };
493            if let Ok(mut pastes) = self.pastes.lock() {
494                pastes.insert(id, PasteEntry { content: filtered });
495            }
496            let marker = if by_lines {
497                format!("[paste #{id} +{count} lines]")
498            } else {
499                format!("[paste #{id} {count} chars]")
500            };
501            self.insert_no_undo(&marker);
502            *self.last_action.lock().unwrap() = Some("insert");
503        } else {
504            self.insert(&filtered);
505        }
506    }
507
508    /// Clear the editor.
509    pub fn clear(&self) {
510        if let Ok(mut state) = self.state.lock() {
511            state.lines = vec![String::new()];
512            state.cursor_row = 0;
513            state.cursor_col = 0;
514            state.selection_anchor = None;
515        }
516        self.reset_pastes();
517        // Upstream's editor notifies change observers for programmatic clears
518        // (`setTextInternal`), so derived UI state (bash-mode border color,
519        // autocomplete) drops the stale draft. `submit()` relies on this to
520        // reset the border after routing a `!command`.
521        self.notify_change();
522    }
523
524    /// Set callback for when text is submitted (Enter pressed).
525    pub fn on_submit(&self, callback: Arc<dyn Fn(&str) + Send + Sync>) {
526        if let Ok(mut cb) = self.on_submit.lock() {
527            *cb = Some(callback);
528        }
529    }
530
531    /// Set callback for when text changes.
532    pub fn on_change(&self, callback: Arc<dyn Fn(&str) + Send + Sync>) {
533        if let Ok(mut cb) = self.on_change.lock() {
534            *cb = Some(callback);
535        }
536    }
537
538    /// Get cursor position.
539    pub fn cursor_position(&self) -> (usize, usize) {
540        self.state
541            .lock()
542            .map(|s| (s.cursor_row, s.cursor_col))
543            .unwrap_or((0, 0))
544    }
545
546    /// Set the cursor position (clamped to text bounds), clearing any
547    /// in-progress selection. Used by autocomplete to place the caret after
548    /// accepting a suggestion (`set_text` resets the caret to the start).
549    ///
550    /// `col` is interpreted as a **byte** offset (callers in the autocomplete
551    /// path pass byte indices from `String::len`/`char_indices`) and snapped to
552    /// the nearest preceding char boundary so multibyte input never lands
553    /// mid-character.
554    pub fn set_cursor(&self, row: usize, col: usize) {
555        if let Ok(mut state) = self.state.lock() {
556            let row = row.min(state.lines.len().saturating_sub(1));
557            let col = snap_boundary(&state.lines[row], col);
558            state.cursor_row = row;
559            state.cursor_col = col;
560            state.selection_anchor = None;
561        }
562    }
563
564    /// Snapshot the current text + caret state (for undo/redo), including the
565    /// folded-paste store so undo can bring a dropped paste back.
566    fn snapshot_state(&self) -> EditorSnapshot {
567        let state = self.state.lock().unwrap();
568        EditorSnapshot {
569            lines: state.lines.clone(),
570            cursor_row: state.cursor_row,
571            cursor_col: state.cursor_col,
572            pastes: self.pastes.lock().map(|p| p.clone()).unwrap_or_default(),
573            paste_counter: self.paste_counter.lock().map(|c| *c).unwrap_or(0),
574        }
575    }
576
577    /// Restore a snapshot, notifying change observers.
578    fn restore_state(&self, snap: &EditorSnapshot) {
579        if let Ok(mut state) = self.state.lock() {
580            state.lines = snap.lines.clone();
581            state.cursor_row = snap.cursor_row;
582            state.cursor_col = snap.cursor_col;
583            // Snapshots don't carry the selection; undo/redo ends it.
584            state.selection_anchor = None;
585        }
586        if let Ok(mut pastes) = self.pastes.lock() {
587            *pastes = snap.pastes.clone();
588        }
589        if let Ok(mut counter) = self.paste_counter.lock() {
590            *counter = snap.paste_counter;
591        }
592        self.notify_change();
593    }
594
595    /// Record an undo snapshot before a mutation. `action` names the mutation
596    /// kind: consecutive `"insert"` actions coalesce into a single undo entry
597    /// (typing a word undoes as one step, mirroring the TS coalescing); any
598    /// other mutation starts a fresh entry. A new mutation clears the redo
599    /// stack (the classic undo/redo semantics).
600    fn push_undo(&self, action: &'static str) {
601        let coalesce = action == "insert" && *self.last_action.lock().unwrap() == Some("insert");
602        if !coalesce {
603            let snap = self.snapshot_state();
604            if let Ok(mut stack) = self.undo_stack.lock() {
605                stack.push(snap);
606            }
607            if let Ok(mut redo) = self.redo_stack.lock() {
608                redo.clear();
609            }
610        }
611        *self.last_action.lock().unwrap() = Some(action);
612    }
613
614    /// Undo the last mutation (Ctrl+-). Restores the prior snapshot; the
615    /// undone state moves to the redo stack for Ctrl+R.
616    pub fn undo(&self) {
617        let popped = self.undo_stack.lock().unwrap().pop();
618        let Some(snap) = popped else { return };
619        // The current state is the redo target.
620        self.redo_stack.lock().unwrap().push(self.snapshot_state());
621        self.restore_state(&snap);
622        *self.last_action.lock().unwrap() = None;
623    }
624
625    /// Redo the last undone mutation (Ctrl+R).
626    pub fn redo(&self) {
627        let popped = self.redo_stack.lock().unwrap().pop();
628        let Some(snap) = popped else { return };
629        self.undo_stack.lock().unwrap().push(self.snapshot_state());
630        self.restore_state(&snap);
631        *self.last_action.lock().unwrap() = None;
632    }
633
634    /// Push killed (deleted) text onto the kill ring, accumulating consecutive
635    /// kills into one entry (mirrors the TS kill-ring semantics).
636    fn kill(&self, text: String, prepend: bool) {
637        if text.is_empty() {
638            return;
639        }
640        let accumulate = *self.last_action.lock().unwrap() == Some("kill");
641        if let Ok(mut ring) = self.kill_ring.lock() {
642            ring.push(
643                &text,
644                PushOptions {
645                    prepend,
646                    accumulate,
647                },
648            );
649        }
650        *self.last_action.lock().unwrap() = Some("kill");
651    }
652
653    /// Yank (paste) the most recent kill at the cursor (Ctrl+Y). Replaces an
654    /// active selection.
655    pub fn yank(&self) {
656        let text = self.kill_ring.lock().unwrap().peek().map(str::to_string);
657        let Some(text) = text else { return };
658        self.push_undo("yank");
659        if self.has_selection() {
660            self.delete_selection_no_undo();
661        }
662        self.insert_no_undo(&text);
663        *self.last_action.lock().unwrap() = Some("yank");
664    }
665
666    /// Yank-pop: rotate the kill ring and yank the next entry (Alt+Y).
667    pub fn yank_pop(&self) {
668        if self.kill_ring.lock().unwrap().len() <= 1 {
669            return;
670        }
671        self.kill_ring.lock().unwrap().rotate();
672        self.yank();
673    }
674
675    // -------------------------------------------------------------------
676    // Selection (Shift+arrows select, Ctrl+X/C/V cut/copy/paste).
677    // -------------------------------------------------------------------
678
679    /// Whether a selection is active.
680    pub fn has_selection(&self) -> bool {
681        self.state.lock().unwrap().selection_anchor.is_some()
682    }
683
684    /// The ordered selection span `(start_row, start_col, end_row, end_col)`
685    /// with start <= end (byte offsets, both on char boundaries).
686    fn selection_ordered(&self) -> Option<(usize, usize, usize, usize)> {
687        let state = self.state.lock().unwrap();
688        let (ar, ac) = state.selection_anchor?;
689        let (br, bc) = (state.cursor_row, state.cursor_col);
690        Some(if (ar, ac) <= (br, bc) {
691            (ar, ac, br, bc)
692        } else {
693            (br, bc, ar, ac)
694        })
695    }
696
697    /// The selected text ("\n"-joined across rows), if any.
698    pub fn selected_text(&self) -> Option<String> {
699        let (sr, sc, er, ec) = self.selection_ordered()?;
700        let lines = self.state.lock().unwrap().lines.clone();
701        if sr == er {
702            Some(lines[sr][sc..ec].to_string())
703        } else {
704            let mut out = String::new();
705            out.push_str(&lines[sr][sc..]);
706            for r in sr + 1..er {
707                out.push('\n');
708                out.push_str(&lines[r]);
709            }
710            out.push('\n');
711            out.push_str(&lines[er][..ec]);
712            Some(out)
713        }
714    }
715
716    /// The selection span on `row`, as `(start_col, end_col)` byte offsets,
717    /// for render-time highlighting. `None` when the row is outside the span.
718    fn selection_span_on_row(&self, state: &EditorState, row: usize) -> Option<(usize, usize)> {
719        let (ar, ac) = state.selection_anchor?;
720        let (br, bc) = (state.cursor_row, state.cursor_col);
721        let (sr, sc, er, ec) = if (ar, ac) <= (br, bc) {
722            (ar, ac, br, bc)
723        } else {
724            (br, bc, ar, ac)
725        };
726        if row < sr || row > er {
727            return None;
728        }
729        if sr == er {
730            (sc != ec).then_some((sc, ec))
731        } else if row == sr {
732            (sc < state.lines[row].len()).then_some((sc, state.lines[row].len()))
733        } else if row == er {
734            (ec > 0).then_some((0, ec))
735        } else {
736            (!state.lines[row].is_empty()).then_some((0, state.lines[row].len()))
737        }
738    }
739
740    /// Seed the selection anchor at the current caret (Shift+direction start).
741    fn begin_selection(&self) {
742        let mut state = self.state.lock().unwrap();
743        if state.selection_anchor.is_none() {
744            state.selection_anchor = Some((state.cursor_row, state.cursor_col));
745        }
746    }
747
748    /// Clear the active selection (plain cursor movement / edits).
749    fn clear_selection(&self) {
750        self.state.lock().unwrap().selection_anchor = None;
751    }
752
753    /// Delete the selected span, leaving the caret at its start. Pushes an
754    /// undo snapshot; no-op without a selection.
755    fn delete_selection(&self) {
756        if !self.has_selection() {
757            return;
758        }
759        self.push_undo("edit");
760        self.delete_selection_no_undo();
761    }
762
763    /// Delete the selected span WITHOUT an undo snapshot (caller pushed one,
764    /// e.g. a replace-through-insert coalesced with the insertion).
765    fn delete_selection_no_undo(&self) {
766        let (sr, sc, er, ec) = match self.selection_ordered() {
767            Some(s) => s,
768            None => return,
769        };
770        let mut state = self.state.lock().unwrap();
771        if sr == er {
772            state.lines[sr].replace_range(sc..ec, "");
773            state.cursor_row = sr;
774            state.cursor_col = sc;
775        } else {
776            let mut joined = state.lines[sr][..sc].to_string();
777            joined.push_str(&state.lines[er][ec..]);
778            state.lines.drain(sr + 1..=er);
779            state.lines[sr] = joined;
780            state.cursor_row = sr;
781            state.cursor_col = sc;
782        }
783        state.selection_anchor = None;
784        drop(state);
785        self.notify_change();
786    }
787
788    /// Copy the selection onto the kill ring (Ctrl+C). No-op without one.
789    pub fn copy_selection(&self) -> bool {
790        let Some(text) = self.selected_text() else {
791            return false;
792        };
793        if text.is_empty() {
794            return false;
795        }
796        if let Ok(mut ring) = self.kill_ring.lock() {
797            ring.push(&text, PushOptions::default());
798        }
799        true
800    }
801
802    /// Cut the selection (copy + delete, Ctrl+X). Returns whether anything
803    /// was cut.
804    pub fn cut_selection(&self) -> bool {
805        if !self.copy_selection() {
806            return false;
807        }
808        self.delete_selection();
809        true
810    }
811
812    /// Insert text at cursor position. Replaces an active selection (one undo
813    /// step: the snapshot is taken before the selection is deleted).
814    pub fn insert(&self, text: &str) {
815        self.push_undo("insert");
816        if self.has_selection() {
817            self.delete_selection_no_undo();
818        }
819        self.insert_no_undo(text);
820    }
821
822    /// Insert without an undo snapshot (caller pushed one — yank/replace
823    /// paths). Notifies change observers.
824    ///
825    /// Line endings are normalized: `\r\n` and a lone `\r` are treated as one
826    /// newline. Pasted Windows text (and the `Event::Paste` payload on Unix,
827    /// which preserves CRLF) would otherwise insert literal carriage returns
828    /// into the line buffer — they render as garbage and break `cursor_col`
829    /// arithmetic on the next edit.
830    fn insert_no_undo(&self, text: &str) {
831        let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
832        if let Ok(mut state) = self.state.lock() {
833            for ch in normalized.chars() {
834                if ch == '\n' {
835                    // Split line at cursor
836                    let row = state.cursor_row;
837                    let col = state.cursor_col;
838                    let current_line = &mut state.lines[row];
839                    let after = current_line.split_off(col);
840                    state.lines.insert(row + 1, after);
841                    state.cursor_row += 1;
842                    state.cursor_col = 0;
843                } else {
844                    let row = state.cursor_row;
845                    let col = state.cursor_col;
846                    state.lines[row].insert(col, ch);
847                    // Advance by the BYTE length of the inserted char so
848                    // `cursor_col` stays a valid char boundary (and a sound
849                    // byte index for the next `String` mutation). The old
850                    // `+= 1` was a char count and panicked on multibyte input
851                    // the moment a second keystroke landed mid-character.
852                    state.cursor_col += ch.len_utf8();
853                }
854            }
855        }
856        self.notify_change();
857    }
858
859    /// Delete character before cursor (Backspace). With a selection, deletes
860    /// the selection instead.
861    fn backspace(&self) {
862        if self.has_selection() {
863            self.delete_selection();
864            return;
865        }
866        // Backspacing over a `[paste #id …]` marker deletes the whole marker
867        // (and drops / renumbers the folded pastes). The marker is one atomic
868        // unit — the user cannot nibble it into an orphaned placeholder.
869        let deleted_marker = {
870            let state = self.state.lock().unwrap();
871            let row = state.cursor_row;
872            (state.cursor_col > 0)
873                .then(|| paste_marker_len_ending_at(&state.lines[row], state.cursor_col))
874                .filter(|len| *len > 0)
875                .map(|len| (row, state.cursor_col - len, len))
876        };
877        if let Some((row, start, len)) = deleted_marker {
878            // Snapshot FIRST: `remove_paste` mutates the paste store and
879            // renumbers the surviving markers, so undo must capture the
880            // pre-deletion state to be able to bring them all back.
881            self.push_undo("edit");
882            let id = self.paste_id_at_marker_start(row, start);
883            // Delete the marker text BEFORE renumbering, so the renumber pass
884            // (which can change a marker's byte length when an id crosses
885            // `#10 -> #9`) never has to keep a stale `start..start+len` valid.
886            if let Ok(mut state) = self.state.lock() {
887                state.lines[row].replace_range(start..start + len, "");
888                state.cursor_col = start;
889            }
890            if let Some(id) = id {
891                self.remove_paste(id);
892            }
893            self.notify_change();
894            return;
895        }
896        self.push_undo("edit");
897        if let Ok(mut state) = self.state.lock() {
898            if state.cursor_col > 0 {
899                let row = state.cursor_row;
900                // Step back by the byte length of the char immediately
901                // before the cursor, then remove that char. (`remove` takes
902                // a byte index and must land on a boundary.) Compute the
903                // length through a shared borrow first so the mutable borrow
904                // for `remove` is the only outstanding alias.
905                let prev_len = state.lines[row][..state.cursor_col]
906                    .chars()
907                    .last()
908                    .map(|c| c.len_utf8())
909                    .unwrap_or(0);
910                let col = state.cursor_col - prev_len;
911                state.lines[row].remove(col);
912                state.cursor_col = col;
913            } else if state.cursor_row > 0 {
914                // Join with previous line
915                let row = state.cursor_row;
916                let current = state.lines.remove(row);
917                state.cursor_row -= 1;
918                let prev_row = state.cursor_row;
919                state.cursor_col = state.lines[prev_row].len();
920                state.lines[prev_row].push_str(&current);
921            }
922        }
923        self.notify_change();
924    }
925
926    /// Delete character at cursor (Delete). With a selection, deletes the
927    /// selection instead.
928    fn delete(&self) {
929        if self.has_selection() {
930            self.delete_selection();
931            return;
932        }
933        self.push_undo("edit");
934        if let Ok(mut state) = self.state.lock() {
935            let row = state.cursor_row;
936            let line_len = state.lines[row].len();
937            if state.cursor_col < line_len {
938                let col = state.cursor_col;
939                state.lines[row].remove(col);
940            } else if row < state.lines.len() - 1 {
941                // Join with next line
942                let next = state.lines.remove(row + 1);
943                state.lines[row].push_str(&next);
944            }
945        }
946        self.notify_change();
947    }
948
949    /// Move cursor left.
950    fn cursor_left(&self) {
951        if let Ok(mut state) = self.state.lock() {
952            if state.cursor_col > 0 {
953                let row = state.cursor_row;
954                let line = &state.lines[row];
955                // A folded paste marker is atomic: step the whole marker.
956                let marker = paste_marker_len_ending_at(line, state.cursor_col);
957                if marker > 0 {
958                    state.cursor_col -= marker;
959                    return;
960                }
961                // Retrear by the byte length of the preceding char so the
962                // caret stays on a char boundary.
963                let prev_len = line[..state.cursor_col]
964                    .chars()
965                    .last()
966                    .map(|c| c.len_utf8())
967                    .unwrap_or(0);
968                state.cursor_col -= prev_len;
969            } else if state.cursor_row > 0 {
970                state.cursor_row -= 1;
971                state.cursor_col = state.lines[state.cursor_row].len();
972            }
973        }
974    }
975
976    /// Move cursor right.
977    fn cursor_right(&self) {
978        if let Ok(mut state) = self.state.lock() {
979            let row = state.cursor_row;
980            let line = &state.lines[row];
981            if state.cursor_col < line.len() {
982                // A folded paste marker is atomic: step the whole marker.
983                for m in paste_marker_regex().find_iter(line) {
984                    if m.start() == state.cursor_col {
985                        state.cursor_col = m.end();
986                        return;
987                    }
988                }
989                // Advance by the byte length of the char at the cursor.
990                let ch_len = line[state.cursor_col..]
991                    .chars()
992                    .next()
993                    .map(|c| c.len_utf8())
994                    .unwrap_or(0);
995                state.cursor_col += ch_len;
996            } else if row < state.lines.len() - 1 {
997                state.cursor_row += 1;
998                state.cursor_col = 0;
999            }
1000        }
1001    }
1002
1003    /// Move cursor up.
1004    fn cursor_up(&self) {
1005        if let Ok(mut state) = self.state.lock() {
1006            if state.cursor_row > 0 {
1007                state.cursor_row -= 1;
1008                let new_row = state.cursor_row;
1009                let max = state.lines[new_row].len();
1010                // The byte column may fall mid-character in the new (shorter
1011                // or differently-encoded) line; snap to a boundary.
1012                state.cursor_col = snap_boundary(&state.lines[new_row], state.cursor_col.min(max));
1013            }
1014        }
1015    }
1016
1017    /// Move cursor down.
1018    fn cursor_down(&self) {
1019        if let Ok(mut state) = self.state.lock() {
1020            if state.cursor_row < state.lines.len() - 1 {
1021                state.cursor_row += 1;
1022                let new_row = state.cursor_row;
1023                let max = state.lines[new_row].len();
1024                state.cursor_col = snap_boundary(&state.lines[new_row], state.cursor_col.min(max));
1025            }
1026        }
1027    }
1028
1029    /// Move cursor to start of line.
1030    fn cursor_home(&self) {
1031        if let Ok(mut state) = self.state.lock() {
1032            state.cursor_col = 0;
1033        }
1034    }
1035
1036    /// Move cursor to end of line.
1037    fn cursor_end(&self) {
1038        if let Ok(mut state) = self.state.lock() {
1039            let row = state.cursor_row;
1040            state.cursor_col = state.lines[row].len();
1041        }
1042    }
1043
1044    /// Kill the word before the cursor (Ctrl+W / Alt+Backspace): Emacs
1045    /// backward-kill-word — skip the current word, then the intervening
1046    /// delimiters, landing on the previous word's start.
1047    fn kill_word_backward(&self) {
1048        self.push_undo("kill");
1049        let killed = if let Ok(mut state) = self.state.lock() {
1050            let row = state.cursor_row;
1051            let col = state.cursor_col;
1052            let start = crate::word_navigation::find_word_backward(&state.lines[row], col);
1053            let dead: String = state.lines[row].drain(start..col).collect();
1054            state.cursor_col = start;
1055            dead
1056        } else {
1057            String::new()
1058        };
1059        self.kill(killed, true);
1060        self.notify_change();
1061    }
1062
1063    /// Kill the word after the cursor (Alt+D / Alt+Delete).
1064    fn kill_word_forward(&self) {
1065        self.push_undo("kill");
1066        let killed = if let Ok(mut state) = self.state.lock() {
1067            let row = state.cursor_row;
1068            let col = state.cursor_col;
1069            // Kill only the current word (not the trailing delimiter) —
1070            // `find_word_end` stops at the last word char.
1071            let end = crate::word_navigation::find_word_end(&state.lines[row], col);
1072            let dead: String = state.lines[row].drain(col..end).collect();
1073            dead
1074        } else {
1075            String::new()
1076        };
1077        self.kill(killed, false);
1078        self.notify_change();
1079    }
1080
1081    /// Jump to the previous word boundary (Alt+Left).
1082    fn cursor_word_left(&self) {
1083        if let Ok(mut state) = self.state.lock() {
1084            let row = state.cursor_row;
1085            state.cursor_col =
1086                crate::word_navigation::find_word_backward(&state.lines[row], state.cursor_col);
1087        }
1088    }
1089
1090    /// Jump to the next word boundary (Alt+Right).
1091    fn cursor_word_right(&self) {
1092        if let Ok(mut state) = self.state.lock() {
1093            let row = state.cursor_row;
1094            state.cursor_col =
1095                crate::word_navigation::find_word_forward(&state.lines[row], state.cursor_col);
1096        }
1097    }
1098
1099    /// Page-scroll the editor caret up/down by a page (pi `tui.editor.pageUp/
1100    /// pageDown`): move the cursor row by a page and clamp the column to the
1101    /// target line (page = 10 rows; a terminal-height-aware version would need
1102    /// the viewport, which the component doesn't own).
1103    fn page_scroll(&self, dir: i32) {
1104        const PAGE: usize = 10;
1105        if let Ok(mut state) = self.state.lock() {
1106            let target = if dir > 0 {
1107                (state.cursor_row + PAGE).min(state.lines.len().saturating_sub(1))
1108            } else {
1109                state.cursor_row.saturating_sub(PAGE)
1110            };
1111            state.cursor_row = target;
1112            let max = state.lines[target].len();
1113            state.cursor_col = snap_boundary(&state.lines[target], state.cursor_col.min(max));
1114        }
1115    }
1116
1117    /// Jump the caret to the next/previous occurrence of `target` (pi
1118    /// `jumpToChar`): scan the current line from after/before the caret, then
1119    /// onward/backward line by line. Byte-safe: `find`/`rfind` return char
1120    /// boundaries and all search offsets start on one.
1121    fn jump_to_char(&self, target: char, dir: i32) {
1122        let forward = dir > 0;
1123        let mut state = self.state.lock().unwrap();
1124        let start_row = state.cursor_row;
1125        let mut row = start_row;
1126        let step: isize = if forward { 1 } else { -1 };
1127        loop {
1128            let line = &state.lines[row];
1129            let search_from = if row == start_row {
1130                if forward {
1131                    (state.cursor_col + target.len_utf8()).min(line.len())
1132                } else {
1133                    state.cursor_col.saturating_sub(target.len_utf8())
1134                }
1135            } else if forward {
1136                0
1137            } else {
1138                line.len()
1139            };
1140            let found = if forward {
1141                line[search_from..].find(target).map(|i| search_from + i)
1142            } else {
1143                line[..search_from].rfind(target)
1144            };
1145            if let Some(idx) = found {
1146                state.cursor_row = row;
1147                state.cursor_col = idx;
1148                return;
1149            }
1150            let next = row as isize + step;
1151            if next < 0 || next >= state.lines.len() as isize {
1152                return;
1153            }
1154            row = next as usize;
1155        }
1156    }
1157
1158    /// Set an explicit top/bottom border color. `None` restores the theme's
1159    /// default border color. Mirrors upstream's `editor.borderColor`.
1160    pub fn set_border_color(&self, color: Option<Color>) {
1161        if let Ok(mut guard) = self.border_color.lock() {
1162            *guard = color;
1163        }
1164    }
1165
1166    /// The current explicit border color, if any.
1167    pub fn border_color(&self) -> Option<Color> {
1168        self.border_color.lock().ok().and_then(|guard| *guard)
1169    }
1170
1171    /// Set the working state. When Some, shows a spinner + elapsed time + message
1172    /// inside the top border. When None, shows the normal border.
1173    pub fn set_working(&self, working: Option<WorkingState>) {
1174        if let Ok(mut guard) = self.working.lock() {
1175            *guard = working;
1176        }
1177    }
1178
1179    /// Get the current working state, if any.
1180    pub fn working(&self) -> Option<WorkingState> {
1181        self.working.lock().ok().and_then(|guard| guard.clone())
1182    }
1183
1184    /// Submit current text (Enter).
1185    fn submit(&self) {
1186        // Send the EXPANDED draft: a folded `[paste #id …]` marker must reach
1187        // the model as the user's real content, not the placeholder.
1188        let text = self.get_expanded_text();
1189        if let Ok(mut state) = self.state.lock() {
1190            if !text.is_empty() {
1191                state.history.push(text.clone());
1192                state.history_index = state.history.len();
1193            }
1194        }
1195        // Submitted text is committed — don't let Ctrl+- resurrect it.
1196        self.undo_stack.lock().unwrap().clear();
1197        self.redo_stack.lock().unwrap().clear();
1198        *self.last_action.lock().unwrap() = None;
1199        if let Ok(cb) = self.on_submit.lock() {
1200            if let Some(callback) = cb.as_ref() {
1201                callback(&text);
1202            }
1203        }
1204        self.clear();
1205    }
1206
1207    /// Notify change callback.
1208    fn notify_change(&self) {
1209        if let Ok(cb) = self.on_change.lock() {
1210            if let Some(callback) = cb.as_ref() {
1211                let text = self.get_text();
1212                callback(&text);
1213            }
1214        }
1215    }
1216
1217    /// Handle keyboard input.
1218    pub fn handle_key(&self, key: crossterm::event::KeyEvent) -> bool {
1219        match (key.modifiers, key.code) {
1220            // Navigation — plain movement clears any active selection;
1221            // Shift+movement extends it.
1222            (KeyModifiers::NONE, KeyCode::Left) => {
1223                self.clear_selection();
1224                self.cursor_left();
1225            }
1226            (KeyModifiers::NONE, KeyCode::Right) => {
1227                self.clear_selection();
1228                self.cursor_right();
1229            }
1230            (KeyModifiers::NONE, KeyCode::Up) => {
1231                self.clear_selection();
1232                self.cursor_up();
1233            }
1234            (KeyModifiers::NONE, KeyCode::Down) => {
1235                self.clear_selection();
1236                self.cursor_down();
1237            }
1238            (KeyModifiers::NONE, KeyCode::Home) => {
1239                self.clear_selection();
1240                self.cursor_home();
1241            }
1242            (KeyModifiers::NONE, KeyCode::End) => {
1243                self.clear_selection();
1244                self.cursor_end();
1245            }
1246            (KeyModifiers::SHIFT, KeyCode::Left) => {
1247                self.begin_selection();
1248                self.cursor_left();
1249            }
1250            (KeyModifiers::SHIFT, KeyCode::Right) => {
1251                self.begin_selection();
1252                self.cursor_right();
1253            }
1254            (KeyModifiers::SHIFT, KeyCode::Up) => {
1255                self.begin_selection();
1256                self.cursor_up();
1257            }
1258            (KeyModifiers::SHIFT, KeyCode::Down) => {
1259                self.begin_selection();
1260                self.cursor_down();
1261            }
1262            (KeyModifiers::SHIFT, KeyCode::Home) => {
1263                self.begin_selection();
1264                self.cursor_home();
1265            }
1266            (KeyModifiers::SHIFT, KeyCode::End) => {
1267                self.begin_selection();
1268                self.cursor_end();
1269            }
1270
1271            // Editing
1272            (KeyModifiers::NONE, KeyCode::Backspace) => self.backspace(),
1273            (KeyModifiers::NONE, KeyCode::Delete) => self.delete(),
1274            (KeyModifiers::NONE, KeyCode::Enter)
1275            | (KeyModifiers::NONE, KeyCode::Char('\n'))
1276            | (KeyModifiers::NONE, KeyCode::Char('\r')) => {
1277                // Submit on Enter (mirrors TS `tui.input.submit`). The
1278                // `Char('\n')`/`Char('\r')` forms cover a client whose terminal
1279                // hands us the codepoint directly (e.g. the kitty keyboard
1280                // protocol's CSI-u path); they used to fall through to the
1281                // generic char arm and insert a literal newline.
1282                self.submit();
1283            }
1284            // Shift+Enter inserts a newline (mirrors `tui.input.newLine`).
1285            (KeyModifiers::SHIFT, KeyCode::Enter)
1286            | (KeyModifiers::SHIFT, KeyCode::Char('\r'))
1287            | (KeyModifiers::SHIFT, KeyCode::Char('\n')) => self.insert("\n"),
1288
1289            // Ctrl shortcuts
1290            (KeyModifiers::CONTROL, KeyCode::Char('a')) => self.cursor_home(),
1291            (KeyModifiers::CONTROL, KeyCode::Char('e')) => self.cursor_end(),
1292            (KeyModifiers::CONTROL, KeyCode::Char('k')) => {
1293                // Kill to end of line (push the killed text onto the ring).
1294                self.push_undo("kill");
1295                let killed = if let Ok(mut state) = self.state.lock() {
1296                    let row = state.cursor_row;
1297                    let col = state.cursor_col;
1298                    let dead: String = state.lines[row].drain(col..).collect();
1299                    state.lines[row].truncate(col);
1300                    dead
1301                } else {
1302                    String::new()
1303                };
1304                self.kill(killed, false);
1305                self.notify_change();
1306            }
1307            (KeyModifiers::CONTROL, KeyCode::Char('u')) => {
1308                // Kill to start of line (killed text prepends to the ring entry
1309                // so Ctrl+Y pastes it back in the same order).
1310                self.push_undo("kill");
1311                let killed = if let Ok(mut state) = self.state.lock() {
1312                    let row = state.cursor_row;
1313                    let col = state.cursor_col;
1314                    let before: String = state.lines[row].drain(..col).collect();
1315                    state.cursor_col = 0;
1316                    before
1317                } else {
1318                    String::new()
1319                };
1320                self.kill(killed, true);
1321                self.notify_change();
1322            }
1323            // Undo / redo (pi binds undo to Ctrl+-; Ctrl+R redo is a Rust port
1324            // convenience since pi has no redo binding).
1325            (KeyModifiers::CONTROL, KeyCode::Char('-')) => self.undo(),
1326            (KeyModifiers::CONTROL, KeyCode::Char('r')) => self.redo(),
1327            // Selection cut/copy/paste (Ctrl+X / Ctrl+C / Ctrl+V).
1328            (KeyModifiers::CONTROL, KeyCode::Char('x')) => {
1329                self.cut_selection();
1330            }
1331            (KeyModifiers::CONTROL, KeyCode::Char('c')) => {
1332                self.copy_selection();
1333            }
1334            (KeyModifiers::CONTROL, KeyCode::Char('v')) => {
1335                self.yank();
1336            }
1337            // Kill-ring yank (Ctrl+Y) / yank-pop (Alt+Y).
1338            (KeyModifiers::CONTROL, KeyCode::Char('y')) => self.yank(),
1339            (KeyModifiers::ALT, KeyCode::Char('y')) => self.yank_pop(),
1340            // Delete the word before the cursor, killing it (Alt+Backspace).
1341            (KeyModifiers::CONTROL, KeyCode::Char('w')) => self.kill_word_backward(),
1342            (KeyModifiers::ALT, KeyCode::Backspace) => self.kill_word_backward(),
1343            (KeyModifiers::ALT, KeyCode::Char('d')) | (KeyModifiers::ALT, KeyCode::Delete) => {
1344                self.kill_word_forward();
1345            }
1346            // Word navigation (Alt+Left / Alt+Right — pi's word-jump bindings).
1347            (KeyModifiers::ALT, KeyCode::Left) => self.cursor_word_left(),
1348            (KeyModifiers::ALT, KeyCode::Right) => self.cursor_word_right(),
1349            // Ctrl+J inserts a newline (pi tui.input.newLine alongside
1350            // Shift+Enter). Ctrl+D deletes the char forward (deleteCharForward).
1351            (KeyModifiers::CONTROL, KeyCode::Char('j')) => self.insert_no_undo(
1352                "
1353",
1354            ),
1355            (KeyModifiers::CONTROL, KeyCode::Char('d')) => self.delete(),
1356            // Editor page scroll (pi tui.editor.pageUp/pageDown — the
1357            // unmodified PageUp/Down are the alt-screen transcript scroll).
1358            (KeyModifiers::CONTROL, KeyCode::PageUp) => self.page_scroll(-1),
1359            (KeyModifiers::CONTROL, KeyCode::PageDown) => self.page_scroll(1),
1360            // Character jump mode (pi jumpForward/jumpBackward): Ctrl+] jumps
1361            // forward to the next typed char, Ctrl+Alt+] backward. NB: a
1362            // CONTROL|ALT pattern in a match arm is an or-pattern (matches
1363            // EITHER modifier), so the backward arm uses a guard.
1364            (_, KeyCode::Char(']')) if key.modifiers.contains(KeyModifiers::CONTROL) => {
1365                // Ctrl+Alt+] jumps backward, Ctrl+] forward. A bare CONTROL
1366                // arm wouldn't match the CONTROL|ALT combination.
1367                if key.modifiers.contains(KeyModifiers::ALT) {
1368                    *self.jump_mode.lock().unwrap() = Some(-1);
1369                } else {
1370                    *self.jump_mode.lock().unwrap() = Some(1);
1371                }
1372            }
1373
1374            // Regular character input — unless a character jump is pending
1375            // (Ctrl+] / Ctrl+Alt+] consumed the key as the jump target).
1376            (KeyModifiers::NONE | KeyModifiers::SHIFT, KeyCode::Char(c)) => {
1377                let jump = self.jump_mode.lock().unwrap().take();
1378                match jump {
1379                    Some(dir) => self.jump_to_char(c, dir),
1380                    None => self.insert(&c.to_string()),
1381                }
1382            }
1383
1384            _ => return false,
1385        }
1386        true
1387    }
1388}
1389
1390impl Component for Editor {
1391    fn render(&self, width: usize) -> Vec<String> {
1392        let state = self.state.lock().map(|s| s.clone()).unwrap_or_default();
1393        let mut content_lines = Vec::new();
1394
1395        let pad = " ".repeat(self.options.padding_x);
1396        // `prompt` is no longer rendered (mirrors the TS editor, which draws a
1397        // bordered box with padding-only lines and no `> ` prefix — see
1398        // `editor.ts:539-578`). The `EditorStyle.prompt` field is retained for
1399        // API compatibility but unread here.
1400        let content_width = width.saturating_sub(self.options.padding_x).max(1);
1401        // Leave one cell for the cursor marker on the active logical line so
1402        // the terminal never performs an implicit wrap at the right border.
1403        let active_width = content_width.saturating_sub(1).max(1);
1404        for (row_idx, line) in state.lines.iter().enumerate() {
1405            let ranges = wrap_line_ranges(
1406                line,
1407                if row_idx == state.cursor_row {
1408                    active_width
1409                } else {
1410                    content_width
1411                },
1412            );
1413            for (chunk_start, chunk_end) in ranges {
1414                let chunk = &line[chunk_start..chunk_end];
1415                // Placeholder (dim) only on the single empty line at the cursor —
1416                // honors callers that set `placeholder`; production drops it.
1417                let content =
1418                    if chunk.is_empty() && row_idx == state.cursor_row && state.lines.len() == 1 {
1419                        if let Some(placeholder) = &self.options.placeholder {
1420                            format!("\x1b[2m{}\x1b[22m", placeholder)
1421                        } else {
1422                            String::new()
1423                        }
1424                    } else {
1425                        chunk.to_string()
1426                    };
1427
1428                // Selection highlight: inverse-video the span on this row (byte
1429                // offsets are char boundaries — both anchor and caret always sit
1430                // on one).
1431                let mut content = content;
1432                if let Some((sc, ec)) = self.selection_span_on_row(&state, row_idx) {
1433                    let local_start = sc.max(chunk_start).min(chunk_end);
1434                    let local_end = ec.max(chunk_start).min(chunk_end);
1435                    if local_start < local_end {
1436                        let before = &line[chunk_start..local_start];
1437                        let sel = &line[local_start..local_end];
1438                        let after = &line[local_end..chunk_end];
1439                        content = format!("{before}\x1b[7m{sel}\x1b[27m{after}");
1440                    }
1441                }
1442
1443                let visible_w = crate::ansi::visible_width(&content);
1444                // Pad the line out to the full width (left padding + content +
1445                // right padding) so the border box has a uniform interior width
1446                // (mirrors editor.ts:522-578).
1447                let right_pad = " ".repeat(
1448                    width
1449                        .saturating_sub(self.options.padding_x)
1450                        .saturating_sub(visible_w),
1451                );
1452                let right_pad_cursor = if state.focused && row_idx == state.cursor_row {
1453                    right_pad
1454                        .get(..right_pad.len().saturating_sub(1))
1455                        .unwrap_or("")
1456                        .to_string()
1457                } else {
1458                    right_pad
1459                };
1460
1461                let mut rendered = format!("{pad}{content}{right_pad_cursor}");
1462
1463                // Cursor marker if this focused row holds the cursor. `cursor_col`
1464                // is a byte offset into `line`; the rendered prefix up to the caret
1465                // is `pad` (ASCII, `padding_x` bytes) + `line[..cursor_col]`.
1466                // Because `content` may carry ANSI escapes (placeholder branch),
1467                // and the prefix length is computed in *bytes* of the source line,
1468                // snap the insertion point to the nearest preceding char boundary
1469                // in `rendered` before byte-slicing — never slice mid-character.
1470                if state.focused
1471                    && row_idx == state.cursor_row
1472                    && state.cursor_col >= chunk_start
1473                    && state.cursor_col <= chunk_end
1474                {
1475                    let cursor_col = UnicodeWidthStr::width(
1476                        line[chunk_start..state.cursor_col.min(chunk_end)]
1477                            .to_string()
1478                            .as_str(),
1479                    );
1480                    rendered = insert_cursor_marker(&rendered, self.options.padding_x + cursor_col);
1481                }
1482
1483                content_lines.push(rendered);
1484            }
1485        }
1486
1487        // Guard against an empty `lines` vector (the invariant is `[""]`, but
1488        // be defensive): emit one blank interior row so the box still renders.
1489        if content_lines.is_empty() {
1490            content_lines.push(format!(
1491                "{pad}{}",
1492                " ".repeat(width.saturating_sub(self.options.padding_x))
1493            ));
1494        }
1495
1496        // Full-width top + bottom border, colored via the theme border color
1497        // (mirrors editor.ts:494,530,587). The global `theme()` is read-only
1498        // after OnceLock init; `Color::fg` wraps the `─` run in the escape.
1499        // An explicit `border_color` (bash mode) wins over the theme default.
1500        let border_color = self
1501            .border_color
1502            .lock()
1503            .ok()
1504            .and_then(|guard| *guard)
1505            .unwrap_or(crate::theme::theme().colors.border);
1506        // Check if we have a working state to display in the top border.
1507        //
1508        // The frame advances once per render, mirroring `Loader::render`: the
1509        // spinner is therefore driven by *repaints*, so it visibly speeds up
1510        // while the model is streaming (each token schedules a frame) and falls
1511        // back to the render tick's cadence when nothing is being produced. A
1512        // timer-only animation looked constant-speed and unrelated to output.
1513        let working_state = self.working.lock().ok().and_then(|mut guard| {
1514            guard.as_mut().map(|state| {
1515                let snapshot = state.clone();
1516                state.frame = state.frame.wrapping_add(1);
1517                snapshot
1518            })
1519        });
1520        let top_border = if let Some(ref working) = working_state {
1521            // Working indicator embedded in the top border: spinner + message +
1522            // elapsed time, with the remaining width filled by border glyphs.
1523            // The frame set is shared with `Loader` so the two never drift.
1524            let frames = crate::loader::SPINNER_FRAMES;
1525            let spinner_char = frames[working.frame % frames.len()];
1526            let colors = crate::theme::theme().colors;
1527
1528            let elapsed = working.started_at.elapsed();
1529            let elapsed_str = if elapsed.as_secs() >= 60 {
1530                format!("{}m{}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
1531            } else {
1532                format!("{}s", elapsed.as_secs())
1533            };
1534
1535            let plain = format!(" {} {} {} ", spinner_char, working.message, elapsed_str);
1536            let indicator_width = crate::ansi::visible_width(&plain);
1537
1538            if width >= indicator_width + 1 {
1539                let colored = format!(
1540                    " {} {} {} ",
1541                    colors.accent.fg(&spinner_char.to_string()),
1542                    colors.muted.fg(&working.message),
1543                    colors.muted.fg(&elapsed_str),
1544                );
1545                let right = border_color.fg(&"─".repeat(width - indicator_width - 1));
1546                format!("{}{}{}", border_color.fg("─"), colored, right)
1547            } else {
1548                border_color.fg(&"─".repeat(width))
1549            }
1550        } else {
1551            border_color.fg(&"─".repeat(width))
1552        };
1553
1554        let bottom_border = {
1555            let horizontal = "─".repeat(width);
1556            border_color.fg(&horizontal)
1557        };
1558
1559        let mut lines = Vec::with_capacity(content_lines.len() + 2);
1560        lines.push(top_border);
1561        lines.extend(content_lines);
1562        lines.push(bottom_border);
1563        lines
1564    }
1565
1566    fn invalidate(&self) {
1567        // Editor doesn't have external cache
1568    }
1569
1570    fn handle_input(&self, _data: &str) -> bool {
1571        // Parse key event from crossterm format
1572        // This is a simplified version - in real usage, you'd receive KeyEvent directly
1573        false
1574    }
1575
1576    fn as_any(&self) -> &dyn Any {
1577        self
1578    }
1579}
1580
1581impl Focusable for Editor {
1582    fn set_focused(&self, focused: bool) {
1583        if let Ok(mut state) = self.state.lock() {
1584            state.focused = focused;
1585        }
1586    }
1587
1588    fn is_focused(&self) -> bool {
1589        self.state.lock().map(|s| s.focused).unwrap_or(false)
1590    }
1591}
1592
1593#[cfg(test)]
1594mod tests {
1595    use super::*;
1596
1597    #[test]
1598    fn working_spinner_advances_per_render_not_per_timer() {
1599        use crate::component::Component;
1600        let editor = Editor::simple();
1601        editor.set_working(Some(WorkingState {
1602            frame: 0,
1603            started_at: std::time::Instant::now(),
1604            message: "Working…".to_string(),
1605        }));
1606
1607        // The indicator lives in the top border and reports the message.
1608        let first = crate::strip_ansi(&editor.render(60)[0]);
1609        assert!(first.contains("Working"), "top border: {first}");
1610
1611        // Each render steps the frame, so repaints driven by streamed output
1612        // visibly advance the spinner; no timer is required for that.
1613        let frame_after_first = editor.working().expect("working state").frame;
1614        assert_eq!(frame_after_first, 1, "render must advance the frame once");
1615        let _ = editor.render(60);
1616        assert_eq!(editor.working().expect("working state").frame, 2);
1617
1618        // Two renders of the same state differ on screen (spinner char moves).
1619        let a = crate::strip_ansi(&editor.render(60)[0]);
1620        let b = crate::strip_ansi(&editor.render(60)[0]);
1621        let spinner_of = |line: &str| line.chars().find(|c| "⠋⠘⠹⠸⠼⠴⠦⠧⠇⠏".contains(*c));
1622        assert_ne!(
1623            spinner_of(&a),
1624            spinner_of(&b),
1625            "consecutive renders must show different spinner frames: {a:?} vs {b:?}"
1626        );
1627
1628        // Idle editors have no indicator.
1629        editor.set_working(None);
1630        let idle = crate::strip_ansi(&editor.render(60)[0]);
1631        assert!(!idle.contains("Working"), "idle border: {idle}");
1632    }
1633
1634    #[test]
1635    fn working_spinner_uses_native_pi_frames_and_cadence() {
1636        // The editor used to hand-copy the frame list and drifted (`⠘` where
1637        // upstream has `⠙`), so the two indicators animated differently. Both
1638        // now share `SPINNER_FRAMES`.
1639        assert_eq!(crate::loader::SPINNER_FRAMES.len(), 10);
1640        assert_eq!(
1641            crate::loader::SPINNER_FRAMES.iter().collect::<String>(),
1642            "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
1643        );
1644        // 10 frames at 80ms = the 800ms full cycle upstream animates at.
1645        assert_eq!(crate::loader::SPINNER_FRAME_MS, 80);
1646
1647        // The rendered indicator steps through exactly that list, in order.
1648        use crate::component::Component;
1649        let editor = Editor::simple();
1650        editor.set_working(Some(WorkingState {
1651            frame: 0,
1652            started_at: std::time::Instant::now(),
1653            message: "Working…".to_string(),
1654        }));
1655        let spinner_of = |line: &str| {
1656            let plain = crate::strip_ansi(line);
1657            crate::loader::SPINNER_FRAMES
1658                .iter()
1659                .copied()
1660                .find(|c| plain.contains(*c))
1661        };
1662        let mut seen = Vec::new();
1663        for _ in 0..10 {
1664            seen.push(spinner_of(&editor.render(60)[0]).expect("a spinner glyph"));
1665        }
1666        // The snapshot is taken before the advance, so the first paint shows
1667        // frame 0 — exactly upstream, whose `start()` paints frame 0 and then
1668        // steps on each interval. Ten paints therefore cover the whole cycle in
1669        // order and wrap back to the start.
1670        let expected: Vec<char> = crate::loader::SPINNER_FRAMES.to_vec();
1671        assert_eq!(seen, expected);
1672        assert_eq!(
1673            editor.working().expect("working").frame % crate::loader::SPINNER_FRAMES.len(),
1674            0,
1675            "ten paints must land back on the first frame"
1676        );
1677    }
1678
1679    #[test]
1680    fn idle_cadence_matches_native_pi() {
1681        // One frame per `SPINNER_FRAME_MS` of wall clock when nothing else is
1682        // repainting. This is the property the tick task relies on: it only
1683        // requests a repaint, and the repaint advances exactly one frame. The
1684        // tick used to *also* advance the frame, which made the idle spinner
1685        // run at 40ms/frame — twice upstream's rate.
1686        use crate::component::Component;
1687        assert_eq!(crate::loader::SPINNER_FRAME_MS, 80);
1688        assert_eq!(
1689            crate::loader::SPINNER_FRAME_MS * crate::loader::SPINNER_FRAMES.len() as u64,
1690            800,
1691            "a full cycle should take 800ms, like upstream"
1692        );
1693
1694        let editor = Editor::simple();
1695        editor.set_working(Some(WorkingState {
1696            frame: 0,
1697            started_at: std::time::Instant::now(),
1698            message: "Working…".to_string(),
1699        }));
1700        // One repaint == one frame, i.e. one tick advances the spinner once.
1701        let before = editor.working().expect("working").frame;
1702        let _ = editor.render(60);
1703        assert_eq!(editor.working().expect("working").frame, before + 1);
1704    }
1705
1706    #[test]
1707    fn explicit_border_color_overrides_the_theme_border() {
1708        use crate::component::Component;
1709        let editor = Editor::simple();
1710        editor.set_focused(true);
1711        editor.insert("echo hi");
1712
1713        let default_border = crate::theme::theme().colors.border.fg(&"─".repeat(20));
1714        assert!(editor.render(20).contains(&default_border));
1715        assert_eq!(editor.border_color(), None);
1716
1717        // Bash mode sets an explicit accent; the border line changes and the
1718        // theme default is gone.
1719        editor.set_border_color(Some(crate::theme::theme().colors.bash_mode));
1720        let bash_border = crate::theme::theme().colors.bash_mode.fg(&"─".repeat(20));
1721        assert_eq!(
1722            editor.border_color(),
1723            Some(crate::theme::theme().colors.bash_mode)
1724        );
1725        let rendered = editor.render(20);
1726        assert!(rendered.contains(&bash_border), "{rendered:?}");
1727        assert!(!rendered.contains(&default_border));
1728
1729        // Clearing restores the theme default (what `updateEditorBorderColor`
1730        // does once the draft no longer starts with `!`).
1731        editor.set_border_color(None);
1732        assert!(editor.render(20).contains(&default_border));
1733    }
1734
1735    #[test]
1736    fn clear_and_set_text_notify_change_observers() {
1737        // Upstream's `setTextInternal` fires onChange for programmatic edits;
1738        // the bash-mode border and autocomplete rely on it to drop stale state.
1739        use std::sync::atomic::{AtomicUsize, Ordering};
1740        let calls = Arc::new(AtomicUsize::new(0));
1741        let calls_cb = calls.clone();
1742        let editor = Editor::simple();
1743        editor.on_change(Arc::new(move |_text: &str| {
1744            calls_cb.fetch_add(1, Ordering::SeqCst);
1745        }));
1746
1747        editor.set_text("!ls");
1748        assert_eq!(calls.load(Ordering::SeqCst), 1);
1749        editor.clear();
1750        assert_eq!(calls.load(Ordering::SeqCst), 2);
1751    }
1752
1753    #[test]
1754    fn snap_boundary_keeps_char_boundaries_including_end() {
1755        // The regression: a caret placed exactly at the END of the text (what
1756        // autocomplete accept does — `set_cursor(0, text.len())`) used to snap
1757        // one char EARLY, rendering "/mode|l" instead of "/model|".
1758        assert_eq!(snap_boundary("/mo", 3), 3);
1759        assert_eq!(snap_boundary("/model", 6), 6);
1760        assert_eq!(snap_boundary("hello", 5), 5);
1761        // Interior boundaries are kept as-is.
1762        assert_eq!(snap_boundary("/model", 0), 0);
1763        assert_eq!(snap_boundary("/model", 2), 2);
1764        // Out-of-range clamps to the end (a valid boundary).
1765        assert_eq!(snap_boundary("/mo", 99), 3);
1766    }
1767
1768    #[test]
1769    fn snap_boundary_mid_multibyte_snaps_down_to_char_start() {
1770        // "你" occupies bytes 0..3: a position inside it must snap to 0.
1771        assert_eq!(snap_boundary("你", 1), 0);
1772        assert_eq!(snap_boundary("你", 2), 0);
1773        // Position after it (end of string) stays 3.
1774        assert_eq!(snap_boundary("你", 3), 3);
1775        // Mixed: "a你b" — 你 is bytes 1..4; end of string is 5.
1776        assert_eq!(snap_boundary("a你b", 5), 5);
1777        assert_eq!(snap_boundary("a你b", 2), 1);
1778    }
1779
1780    #[test]
1781    fn left_arrow_keeps_text_in_render() {
1782        use crate::component::Component;
1783        use crossterm::event::{KeyCode, KeyModifiers};
1784        let mk = |m, c| crossterm::event::KeyEvent::new(c, m);
1785        let editor = Editor::simple();
1786        editor.set_focused(true);
1787        editor.insert("hello world");
1788        editor.handle_key(mk(KeyModifiers::NONE, KeyCode::Left));
1789        let rendered = editor.render(40).join(
1790            "
1791",
1792        );
1793        assert!(
1794            rendered.contains("hello"),
1795            "text must survive Left, got: {rendered:?}"
1796        );
1797    }
1798    #[test]
1799    fn test_char_jump_and_page_scroll() {
1800        use crossterm::event::{KeyCode, KeyModifiers};
1801        let mk = |m, c| crossterm::event::KeyEvent::new(c, m);
1802        let editor = Editor::simple();
1803
1804        // Character jump: from the end, Ctrl+Alt+] then 'x' jumps backward to
1805        // the previous 'x' ("alpha xray xray" — the second "xray" starts at 11).
1806        editor.insert("alpha xray xray");
1807        editor.handle_key(mk(KeyModifiers::CONTROL | KeyModifiers::ALT, KeyCode::End)); // caret at end
1808        editor.handle_key(mk(
1809            KeyModifiers::CONTROL | KeyModifiers::ALT,
1810            KeyCode::Char(']'),
1811        ));
1812        // The next printable char is consumed as the jump target.
1813        editor.handle_key(mk(KeyModifiers::NONE, KeyCode::Char('x')));
1814        assert_eq!(editor.cursor_position(), (0, 11));
1815
1816        // Page scroll on a multi-line buffer clamps to the last row.
1817        editor.set_text(
1818            "l0
1819l1
1820l2
1821l3
1822l4
1823l5
1824l6
1825l7
1826l8
1827l9
1828l10
1829l11
1830l12",
1831        );
1832        editor.set_cursor(0, 2);
1833        editor.handle_key(mk(KeyModifiers::CONTROL, KeyCode::PageDown));
1834        let (row, _) = editor.cursor_position();
1835        assert!(row >= 10, "page-down moves ~10 rows, got {row}");
1836        editor.handle_key(mk(KeyModifiers::CONTROL, KeyCode::PageUp));
1837        let (row2, _) = editor.cursor_position();
1838        assert!(row2 <= 2, "page-up returns near the start, got {row2}");
1839    }
1840    #[test]
1841    fn test_word_ops_and_new_keys() {
1842        use crossterm::event::{KeyCode, KeyModifiers};
1843        let mk = |m, c| crossterm::event::KeyEvent::new(c, m);
1844        let editor = Editor::simple();
1845        editor.insert("alpha beta gamma");
1846
1847        // Ctrl+W kills the word before the cursor.
1848        editor.handle_key(mk(KeyModifiers::CONTROL, KeyCode::End));
1849        editor.handle_key(mk(KeyModifiers::CONTROL, KeyCode::Char('w')));
1850        assert_eq!(editor.get_text(), "alpha beta ");
1851
1852        // Alt+Left jumps back a word; Alt+D kills the word after.
1853        editor.handle_key(mk(KeyModifiers::ALT, KeyCode::Left));
1854        assert_eq!(editor.cursor_position(), (0, 6));
1855        editor.handle_key(mk(KeyModifiers::ALT, KeyCode::Char('d')));
1856        assert_eq!(editor.get_text(), "alpha  ");
1857
1858        // Ctrl+J inserts a newline.
1859        editor.set_text("a");
1860        editor.set_cursor(0, 1);
1861        editor.handle_key(mk(KeyModifiers::CONTROL, KeyCode::Char('j')));
1862        assert_eq!(
1863            editor.get_text(),
1864            "a
1865"
1866        );
1867
1868        // Ctrl+D deletes the char forward (deleteCharForward).
1869        editor.set_text("abc");
1870        editor.set_cursor(0, 1);
1871        editor.handle_key(mk(KeyModifiers::CONTROL, KeyCode::Char('d')));
1872        assert_eq!(editor.get_text(), "ac");
1873    }
1874    #[test]
1875    fn test_selection_cut_copy_paste() {
1876        let editor = Editor::simple();
1877        editor.insert("hello world");
1878        // Caret to the start, then Shift+Right ×5 selects "hello".
1879        editor.handle_key(crossterm::event::KeyEvent::new(
1880            crossterm::event::KeyCode::Home,
1881            crossterm::event::KeyModifiers::NONE,
1882        ));
1883        let shift_right = || {
1884            editor.handle_key(crossterm::event::KeyEvent::new(
1885                crossterm::event::KeyCode::Right,
1886                crossterm::event::KeyModifiers::SHIFT,
1887            ))
1888        };
1889        for _ in 0..5 {
1890            shift_right();
1891        }
1892        assert!(editor.has_selection());
1893        assert_eq!(editor.selected_text().as_deref(), Some("hello"));
1894        // Ctrl+C copies (selection intact), then plain Right clears it.
1895        editor.handle_key(crossterm::event::KeyEvent::new(
1896            crossterm::event::KeyCode::Char('c'),
1897            crossterm::event::KeyModifiers::CONTROL,
1898        ));
1899        assert_eq!(editor.get_text(), "hello world");
1900        editor.handle_key(crossterm::event::KeyEvent::new(
1901            crossterm::event::KeyCode::Right,
1902            crossterm::event::KeyModifiers::NONE,
1903        ));
1904        assert!(!editor.has_selection(), "plain movement clears selection");
1905        // Move to the end and yank the copied text.
1906        editor.handle_key(crossterm::event::KeyEvent::new(
1907            crossterm::event::KeyCode::End,
1908            crossterm::event::KeyModifiers::NONE,
1909        ));
1910        editor.handle_key(crossterm::event::KeyEvent::new(
1911            crossterm::event::KeyCode::Char('v'),
1912            crossterm::event::KeyModifiers::CONTROL,
1913        ));
1914        assert_eq!(editor.get_text(), "hello worldhello");
1915    }
1916
1917    #[test]
1918    fn test_selection_replace_and_cut() {
1919        let editor = Editor::simple();
1920        editor.insert("abcdef");
1921        // Caret to the start, then Shift+Right ×4 selects "abcd".
1922        editor.handle_key(crossterm::event::KeyEvent::new(
1923            crossterm::event::KeyCode::Home,
1924            crossterm::event::KeyModifiers::NONE,
1925        ));
1926        for _ in 0..4 {
1927            editor.handle_key(crossterm::event::KeyEvent::new(
1928                crossterm::event::KeyCode::Right,
1929                crossterm::event::KeyModifiers::SHIFT,
1930            ));
1931        }
1932        // Backspace deletes the selection ("abcd" → "ef").
1933        editor.handle_key(crossterm::event::KeyEvent::new(
1934            crossterm::event::KeyCode::Backspace,
1935            crossterm::event::KeyModifiers::NONE,
1936        ));
1937        assert_eq!(editor.get_text(), "ef", "backspace deletes the selection");
1938        // Undo restores it.
1939        editor.undo();
1940        assert_eq!(editor.get_text(), "abcdef");
1941        // Cut: return to the start, select "ab", Ctrl+X removes + kills it.
1942        editor.handle_key(crossterm::event::KeyEvent::new(
1943            crossterm::event::KeyCode::Home,
1944            crossterm::event::KeyModifiers::NONE,
1945        ));
1946        for _ in 0..2 {
1947            editor.handle_key(crossterm::event::KeyEvent::new(
1948                crossterm::event::KeyCode::Right,
1949                crossterm::event::KeyModifiers::SHIFT,
1950            ));
1951        }
1952        editor.handle_key(crossterm::event::KeyEvent::new(
1953            crossterm::event::KeyCode::Char('x'),
1954            crossterm::event::KeyModifiers::CONTROL,
1955        ));
1956        assert_eq!(editor.get_text(), "cdef");
1957        // Type over a selection replaces it.
1958        editor.handle_key(crossterm::event::KeyEvent::new(
1959            crossterm::event::KeyCode::Right,
1960            crossterm::event::KeyModifiers::SHIFT,
1961        ));
1962        editor.insert("Z");
1963        assert_eq!(editor.get_text(), "Zdef", "typing replaces the selection");
1964    }
1965    #[test]
1966    fn test_undo_redo_roundtrip() {
1967        let editor = Editor::simple();
1968        editor.insert("hello");
1969        editor.insert(" world");
1970        // Consecutive inserts coalesce: one undo restores the empty state.
1971        assert_eq!(editor.get_text(), "hello world");
1972        editor.undo();
1973        assert_eq!(editor.get_text(), "", "coalesced inserts undo as one step");
1974        // Redo restores everything.
1975        editor.redo();
1976        assert_eq!(editor.get_text(), "hello world");
1977        // A fresh edit after undo clears redo.
1978        editor.undo();
1979        editor.insert("hi");
1980        editor.redo();
1981        assert_eq!(editor.get_text(), "hi", "new edit clears the redo stack");
1982    }
1983
1984    #[test]
1985    fn test_kill_ring_yank() {
1986        let editor = Editor::simple();
1987        editor.insert("alpha beta gamma");
1988        // Ctrl+K kills to end of line.
1989        editor.set_cursor(0, 6); // after "alpha "
1990        let key = |m, c| crossterm::event::KeyEvent::new(c, m);
1991        editor.handle_key(key(
1992            crossterm::event::KeyModifiers::CONTROL,
1993            crossterm::event::KeyCode::Char('k'),
1994        ));
1995        assert_eq!(editor.get_text(), "alpha ");
1996        // Ctrl+Y yanks it back.
1997        editor.handle_key(key(
1998            crossterm::event::KeyModifiers::CONTROL,
1999            crossterm::event::KeyCode::Char('y'),
2000        ));
2001        assert_eq!(editor.get_text(), "alpha beta gamma");
2002    }
2003
2004    #[test]
2005    fn test_undo_after_kill() {
2006        let editor = Editor::simple();
2007        editor.insert("hello world");
2008        editor.set_cursor(0, 5);
2009        editor.handle_key(crossterm::event::KeyEvent::new(
2010            crossterm::event::KeyCode::Char('k'),
2011            crossterm::event::KeyModifiers::CONTROL,
2012        ));
2013        assert_eq!(editor.get_text(), "hello");
2014        editor.undo();
2015        assert_eq!(editor.get_text(), "hello world", "kill is undoable");
2016    }
2017
2018    #[test]
2019    fn shift_enter_encodings_insert_newline_without_submitting() {
2020        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2021        for code in [KeyCode::Enter, KeyCode::Char('\r'), KeyCode::Char('\n')] {
2022            let editor = Editor::simple();
2023            editor.on_submit(Arc::new(|_| panic!("Shift+Enter must not submit")));
2024            editor.insert("draft");
2025            editor.handle_key(KeyEvent::new(code, KeyModifiers::SHIFT));
2026            assert_eq!(editor.get_text(), "draft\n");
2027        }
2028    }
2029
2030    #[test]
2031    fn test_submit_clears_undo() {
2032        let editor = Editor::simple();
2033        editor.insert("committed");
2034        editor.handle_key(crossterm::event::KeyEvent::new(
2035            crossterm::event::KeyCode::Enter,
2036            crossterm::event::KeyModifiers::NONE,
2037        ));
2038        editor.undo();
2039        assert_eq!(
2040            editor.get_text(),
2041            "",
2042            "submit clears undo so committed text stays committed"
2043        );
2044    }
2045
2046    /// Regression: a remote/mobile client (SSH, soft keyboard) that encodes
2047    /// its Enter key as LF (0x0A) or CR (0x0D) sends it as a bare character
2048    /// event in raw mode. Those used to fall through to the generic char arm
2049    /// and insert a literal newline — "回车变成了换行,无法发送内容". They must
2050    /// submit just like `KeyCode::Enter`.
2051    #[test]
2052    fn test_remote_lf_cr_enter_submits() {
2053        use std::sync::atomic::{AtomicUsize, Ordering};
2054        for code in [
2055            crossterm::event::KeyCode::Char('\n'),
2056            crossterm::event::KeyCode::Char('\r'),
2057        ] {
2058            let editor = Editor::simple();
2059            let submits = Arc::new(AtomicUsize::new(0));
2060            let submits_for_cb = submits.clone();
2061            editor.on_submit(Arc::new(move |_text: &str| {
2062                submits_for_cb.fetch_add(1, Ordering::SeqCst);
2063            }));
2064            editor.insert("hello");
2065            editor.handle_key(crossterm::event::KeyEvent::new(
2066                code,
2067                crossterm::event::KeyModifiers::NONE,
2068            ));
2069            assert_eq!(submits.load(Ordering::SeqCst), 1, "{code:?} must submit");
2070            assert_eq!(editor.get_text(), "", "editor cleared after submit");
2071        }
2072    }
2073    #[test]
2074    fn test_editor_text() {
2075        let editor = Editor::simple();
2076        editor.set_text("Hello");
2077        assert_eq!(editor.get_text(), "Hello");
2078    }
2079
2080    #[test]
2081    fn test_editor_insert() {
2082        let editor = Editor::simple();
2083        editor.insert("Hello");
2084        assert_eq!(editor.get_text(), "Hello");
2085        editor.insert(" World");
2086        assert_eq!(editor.get_text(), "Hello World");
2087    }
2088
2089    #[test]
2090    fn test_editor_clear() {
2091        let editor = Editor::simple();
2092        editor.set_text("Test");
2093        editor.clear();
2094        assert_eq!(editor.get_text(), "");
2095    }
2096
2097    #[test]
2098    fn test_editor_render_has_borders() {
2099        // The editor renders a native-pi bordered box: a full-width `─` line
2100        // above and below the content, with no `> ` prompt prefix on the text
2101        // lines (mirrors TS editor.ts:525-588).
2102        let editor = Editor::new(
2103            EditorOptions {
2104                padding_x: 1,
2105                ..Default::default()
2106            },
2107            EditorStyle::default(),
2108            Arc::new(Keybindings::new()),
2109        );
2110        editor.set_text("hi");
2111        let lines = editor.render(20);
2112        assert_eq!(lines.len(), 3, "expected [border, content, border]");
2113        // Both borders are a 20-wide `─` run (visible width, ANSI stripped).
2114        let top = crate::ansi::strip_ansi(&lines[0]);
2115        let bottom = crate::ansi::strip_ansi(lines.last().unwrap());
2116        assert_eq!(
2117            top.chars().filter(|c| *c == '─').count(),
2118            20,
2119            "top border not full width: {top:?}"
2120        );
2121        assert_eq!(
2122            bottom.chars().filter(|c| *c == '─').count(),
2123            20,
2124            "bottom border not full width: {bottom:?}"
2125        );
2126        // The content line carries the text with no `> ` prompt.
2127        let content = crate::ansi::strip_ansi(&lines[1]);
2128        assert!(
2129            content.contains("hi"),
2130            "content line missing text: {content:?}"
2131        );
2132        assert!(
2133            !content.contains("> "),
2134            "content line should not have a prompt prefix: {content:?}"
2135        );
2136    }
2137
2138    #[test]
2139    fn test_editor_multibyte_no_panic() {
2140        // Inserting a multibyte char (读 = 3 bytes) and then a second char
2141        // used to panic: `cursor_col += 1` left the caret at byte 1, inside
2142        // '读', so the next `String::insert(1, …)` hit a char boundary. This
2143        // exercises the byte-cursor fix across insert + render + a second
2144        // keystroke.
2145        let editor = Editor::simple();
2146        editor.set_focused(true);
2147        editor.insert("读");
2148        editor.insert("a");
2149        editor.insert("书");
2150        assert_eq!(editor.get_text(), "读a书");
2151        // cursor_col should be at the end (byte offset 7: 读=3 + a=1 + 书=3).
2152        assert_eq!(editor.cursor_position(), (0, 7));
2153        // Render must not panic when placing the cursor marker on the row.
2154        let lines = editor.render(20);
2155        assert_eq!(
2156            lines.len(),
2157            3,
2158            "bordered box keeps [border, content, border]"
2159        );
2160
2161        // Backspace / left/right must also stay on boundaries.
2162        editor.backspace(); // remove 书
2163        assert_eq!(editor.get_text(), "读a");
2164        editor.cursor_left(); // left of 'a'
2165        assert_eq!(editor.cursor_position(), (0, 3));
2166        editor.insert("b"); // insert between 读 and a
2167        assert_eq!(editor.get_text(), "读ba");
2168    }
2169
2170    #[test]
2171    fn test_editor_wraps_long_draft_inside_width() {
2172        let editor = Editor::new(
2173            EditorOptions {
2174                padding_x: 1,
2175                ..Default::default()
2176            },
2177            EditorStyle::default(),
2178            Arc::new(Keybindings::new()),
2179        );
2180        editor.set_focused(true);
2181        editor.set_text("12345678901234567890");
2182        let lines = editor.render(10);
2183        assert_eq!(lines.len(), 5, "two wrapped content rows plus borders");
2184        for line in &lines {
2185            assert!(
2186                crate::ansi::visible_width(line) <= 10,
2187                "line escaped editor width: {line:?}"
2188            );
2189        }
2190        let rendered = crate::ansi::strip_ansi(&lines.join("\n"));
2191        assert!(rendered.contains("1234567"));
2192        assert!(rendered.contains("90123456"));
2193    }
2194
2195    #[test]
2196    fn test_editor_preserves_trailing_newline() {
2197        let editor = Editor::simple();
2198        editor.set_text("first\n");
2199        assert_eq!(editor.get_text(), "first\n");
2200        assert_eq!(editor.cursor_position(), (0, 0));
2201    }
2202
2203    #[test]
2204    fn test_autocomplete_cursor_origin() {
2205        // Origin of the autocomplete panic (autocomplete.rs:279, before the
2206        // fix): the editor handed back a byte `cursor_col` for multibyte
2207        // text, and the slash-command provider sliced `&input[..cursor]`
2208        // mid-character. A multibyte editor line must still round-trip
2209        // through `get_suggestions` without panicking.
2210        use crate::autocomplete::AutocompleteManager;
2211
2212        let editor = Editor::simple();
2213        editor.set_text("读");
2214        editor.set_cursor(0, 3); // byte end of line
2215        let text = editor.get_text();
2216        let (_r, col) = editor.cursor_position();
2217        let cursor = col.min(text.len());
2218        let mgr = AutocompleteManager::new();
2219
2220        // No slash prefix → None; must not panic on the multibyte slice.
2221        assert!(mgr.get_suggestions(&text, cursor).is_none());
2222        // And the slash path with a char-count overshoot also must not panic.
2223        editor.set_text("/读");
2224        editor.set_cursor(0, 4);
2225        let text2 = editor.get_text();
2226        let mgr2 = AutocompleteManager::new();
2227        let _ = mgr2.get_suggestions(&text2, 9); // overshoot past end
2228    }
2229
2230    #[test]
2231    fn insert_normalizes_crlf_and_lone_cr_to_newlines() {
2232        // Pasted Windows text (and the Unix `Event::Paste` payload, which keeps
2233        // CRLF) used to insert literal `\r` bytes into the line buffer: they
2234        // render as garbage and desync `cursor_col` from the string length.
2235        let editor = Editor::simple();
2236        editor.insert("line1\r\nline2\rline3");
2237        assert_eq!(editor.get_text(), "line1\nline2\nline3");
2238
2239        // The buffer is 3 lines and the cursor sits at the end of the last.
2240        let (row, col) = editor.cursor_position();
2241        assert_eq!((row, col), (2, "line3".len()));
2242
2243        // Editing after a normalized paste stays sound (no mid-char split).
2244        editor.insert("!");
2245        assert_eq!(editor.get_text(), "line1\nline2\nline3!");
2246    }
2247
2248    #[test]
2249    fn insert_of_a_lone_crlf_makes_exactly_one_newline() {
2250        let editor = Editor::simple();
2251        editor.insert("a\r\nb");
2252        assert_eq!(editor.get_text().matches('\n').count(), 1);
2253        assert_eq!(editor.get_text(), "a\nb");
2254    }
2255
2256    // ---- Folded pastes (upstream `handlePaste` / `expandPasteMarkers`) ----
2257
2258    #[test]
2259    fn short_paste_is_inserted_verbatim() {
2260        let editor = Editor::simple();
2261        editor.handle_paste("hello\nworld\nthird");
2262        assert_eq!(editor.get_text(), "hello\nworld\nthird");
2263        assert_eq!(editor.get_expanded_text(), "hello\nworld\nthird");
2264    }
2265
2266    #[test]
2267    fn many_line_paste_folds_to_a_marker() {
2268        let editor = Editor::simple();
2269        let payload = (1..=12)
2270            .map(|i| format!("line {i}"))
2271            .collect::<Vec<_>>()
2272            .join("\n");
2273        editor.handle_paste(&payload);
2274
2275        // The document shows only the marker; the body is stashed.
2276        assert_eq!(editor.get_text(), "[paste #1 +12 lines]");
2277        // Submitting reveals the real content.
2278        assert_eq!(editor.get_expanded_text(), payload);
2279    }
2280
2281    #[test]
2282    fn long_single_line_paste_folds_by_char_count() {
2283        let editor = Editor::simple();
2284        let payload = "x".repeat(2000);
2285        editor.handle_paste(&payload);
2286        assert_eq!(editor.get_text(), "[paste #1 2000 chars]");
2287        assert_eq!(editor.get_expanded_text(), payload);
2288    }
2289
2290    #[test]
2291    fn pasted_crlf_and_tabs_are_normalized_before_folding() {
2292        let editor = Editor::simple();
2293        let payload = (1..=12)
2294            .map(|i| format!("a\tb{i}"))
2295            .collect::<Vec<_>>()
2296            .join("\r\n");
2297        editor.handle_paste(&payload);
2298        let expanded = editor.get_expanded_text();
2299        assert!(!expanded.contains('\r'), "CR must be normalized away");
2300        assert!(!expanded.contains('\t'), "tab must expand to spaces");
2301        assert!(expanded.contains("a    b1"), "tab becomes four spaces");
2302        assert_eq!(expanded.split('\n').count(), 12);
2303    }
2304
2305    #[test]
2306    fn two_folded_pastes_get_distinct_ids_and_both_expand() {
2307        let editor = Editor::simple();
2308        let first = "1\n".repeat(11);
2309        let second = "2\n".repeat(11);
2310        editor.handle_paste(&first);
2311        editor.handle_paste(&second);
2312        assert_eq!(
2313            editor.get_text(),
2314            "[paste #1 +12 lines][paste #2 +12 lines]"
2315        );
2316        assert_eq!(editor.get_expanded_text(), format!("{first}{second}"));
2317    }
2318
2319    #[test]
2320    fn backspace_over_a_folded_marker_drops_it_and_renumbers() {
2321        let editor = Editor::simple();
2322        let first = "a\n".repeat(11);
2323        let second = "b\n".repeat(11);
2324        editor.handle_paste(&first);
2325        editor.handle_paste(&second);
2326        assert_eq!(
2327            editor.get_text(),
2328            "[paste #1 +12 lines][paste #2 +12 lines]"
2329        );
2330
2331        // Delete the FIRST marker: one step left lands the caret right after
2332        // marker #1 (markers are adjacent), and Backspace eats it whole.
2333        let _ = editor.handle_key(crossterm::event::KeyEvent::new(
2334            KeyCode::Left,
2335            KeyModifiers::NONE,
2336        ));
2337        let _ = editor.handle_key(crossterm::event::KeyEvent::new(
2338            KeyCode::Backspace,
2339            KeyModifiers::NONE,
2340        ));
2341
2342        // Only the second survives, renumbered to #1, and it still expands.
2343        assert_eq!(editor.get_text(), "[paste #1 +12 lines]");
2344        assert_eq!(editor.get_expanded_text(), second);
2345    }
2346
2347    #[test]
2348    fn undo_restores_a_backspaced_folded_paste() {
2349        let editor = Editor::simple();
2350        let payload = "line\n".repeat(11);
2351        editor.handle_paste(&payload);
2352        assert_eq!(editor.get_text(), "[paste #1 +12 lines]");
2353
2354        let _ = editor.handle_key(crossterm::event::KeyEvent::new(
2355            KeyCode::Backspace,
2356            KeyModifiers::NONE,
2357        ));
2358        assert_eq!(editor.get_text(), "", "marker deleted");
2359
2360        // Undo must resurrect both the marker AND its stashed body.
2361        editor.undo();
2362        assert_eq!(editor.get_text(), "[paste #1 +12 lines]");
2363        assert_eq!(editor.get_expanded_text(), payload);
2364    }
2365
2366    #[test]
2367    fn cursor_arrows_step_over_a_marker_atomically() {
2368        let editor = Editor::simple();
2369        let payload = "z\n".repeat(11);
2370        editor.handle_paste(&payload);
2371        // Caret is after the marker; one step left must land BEFORE it.
2372        let _ = editor.handle_key(crossterm::event::KeyEvent::new(
2373            KeyCode::Left,
2374            KeyModifiers::NONE,
2375        ));
2376        assert_eq!(
2377            editor.cursor_position(),
2378            (0, 0),
2379            "left jumps the whole marker"
2380        );
2381        // And one step right must jump back over the whole marker.
2382        let _ = editor.handle_key(crossterm::event::KeyEvent::new(
2383            KeyCode::Right,
2384            KeyModifiers::NONE,
2385        ));
2386        assert_eq!(
2387            editor.cursor_position(),
2388            (0, "[paste #1 +12 lines]".len()),
2389            "right jumps the whole marker"
2390        );
2391    }
2392
2393    #[test]
2394    fn clear_and_set_text_forget_folded_pastes() {
2395        let editor = Editor::simple();
2396        editor.handle_paste(&"m\n".repeat(11));
2397        editor.clear();
2398        assert_eq!(editor.get_text(), "");
2399        assert_eq!(editor.get_expanded_text(), "");
2400
2401        editor.handle_paste(&"n\n".repeat(11));
2402        editor.set_text("fresh");
2403        // A programmatic replacement drops the store; a leftover marker would
2404        // otherwise expand against a stale body.
2405        assert_eq!(editor.get_text(), "fresh");
2406        assert_eq!(editor.get_expanded_text(), "fresh");
2407    }
2408
2409    #[test]
2410    fn csi_u_ctrl_sequences_decode_then_drop_like_native_pi() {
2411        // `ESC[104;5u` is kitty CSI-u for Ctrl+H. Upstream decodes it to the
2412        // control char (104 - 96 = 8) and then its `charCode >= 32` filter
2413        // drops it, so a stream of them folds to nothing.
2414        let editor = Editor::simple();
2415        editor.handle_paste(&"\x1b[104;5u".repeat(1200));
2416        assert_eq!(editor.get_text(), "");
2417        assert_eq!(editor.get_expanded_text(), "");
2418    }
2419}