Skip to main content

nu_command/platform/input/
list.rs

1use crate::platform::RawModeGuard;
2use crossterm::{
3    cursor::{Hide, MoveDown, MoveToColumn, MoveUp, Show},
4    event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
5    execute,
6    style::Print,
7    terminal::{self, BeginSynchronizedUpdate, Clear, ClearType, EndSynchronizedUpdate},
8};
9use nu_ansi_term::{Style, ansi::RESET};
10use nu_color_config::{Alignment, StyleComputer, TextStyle};
11use nu_engine::{ClosureEval, command_prelude::*, get_columns};
12use nu_protocol::engine::Closure;
13use nu_protocol::{Config, ListStream, Signals, TableMode, shell_error::io::IoError};
14use nu_table::common::nu_value_to_string;
15use nucleo_matcher::{
16    Config as NucleoConfig, Matcher as NucleoMatcher, Utf32Str,
17    pattern::{Atom, AtomKind, CaseMatching, Normalization},
18};
19use std::{
20    borrow::Cow,
21    collections::HashSet,
22    io::{self, Write},
23    sync::mpsc::{self, Receiver, RecvTimeoutError, TryRecvError},
24    thread,
25    time::Duration,
26};
27use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30enum CaseSensitivity {
31    #[default]
32    Smart,
33    CaseSensitive,
34    CaseInsensitive,
35}
36
37#[derive(Debug, Clone)]
38struct InputListConfig {
39    match_text: Style,                 // For fuzzy match highlighting
40    footer: Style,                     // For footer "[1-5 of 10]"
41    separator: Style,                  // For separator line
42    prompt_marker: Style,              // For prompt marker (">") in fuzzy mode
43    selected_marker: Style,            // For selection marker (">") in item list
44    table_header: Style,               // For table column headers
45    table_separator: Style,            // For table column separators
46    show_footer: bool,                 // Whether to show the footer
47    separator_char: String,            // Character(s) for separator line between search and results
48    show_separator: bool,              // Whether to show the separator line
49    prompt_marker_text: String,        // Text for prompt marker (default: "> ")
50    selected_marker_char: char,        // Single character for selection marker (default: '>')
51    table_column_separator: char,      // Character for table column separator (default: '│')
52    table_header_separator: char, // Horizontal line character for header separator (default: '─')
53    table_header_intersection: char, // Intersection character for header separator (default: '┼')
54    case_sensitivity: CaseSensitivity, // Fuzzy match case sensitivity
55}
56
57const DEFAULT_PROMPT_MARKER: &str = "> ";
58const DEFAULT_SELECTED_MARKER: char = '>';
59
60const DEFAULT_TABLE_COLUMN_SEPARATOR: char = '│';
61
62// Streaming behavior tuning knobs.
63//
64// Keeping these as constants makes behavior easy to tweak and avoids hidden magic numbers.
65// - INITIAL_STREAM_COLLECT_TIMEOUT: maximum time to spend trying to collect a finite input before
66//   falling back to live streaming.
67// - INITIAL_STREAM_MAX_ITEMS: safety cap for very fast unbounded streams during initial collection.
68// - STREAM_LOAD_BATCH: rows to fetch for each incremental refill.
69// - STREAM_PREFETCH_MARGIN: how far from the end we begin prefetching.
70// - STREAM_CHANNEL_CAPACITY: rows the background reader can collect before the UI drains them.
71// - STREAM_POLL_INTERVAL: render cadence while a stream is still loading.
72// - STREAM_FOOTER_UPDATE_INTERVAL: visible footer animation/count cadence while rows stream in.
73const INITIAL_STREAM_COLLECT_TIMEOUT: Duration = Duration::from_millis(250);
74const INITIAL_STREAM_MAX_ITEMS: usize = 100_000;
75const STREAM_LOAD_BATCH: usize = 512;
76const STREAM_PREFETCH_MARGIN: usize = 2;
77const STREAM_CHANNEL_CAPACITY: usize = 8192;
78const STREAM_SPINNER_FRAMES: &[&str] = &["-", "\\", "|", "/"];
79const STREAM_DRAIN_TIME_BUDGET: Duration = Duration::from_millis(16);
80const STREAM_POLL_INTERVAL: Duration = Duration::from_millis(16);
81const STREAM_FOOTER_UPDATE_INTERVAL: Duration = Duration::from_millis(125);
82const IDLE_POLL_INTERVAL: Duration = Duration::from_millis(100);
83const FUZZY_FILTER_INTERRUPT_CHECK_INTERVAL: usize = 1024;
84const FUZZY_FILTER_MIN_INTERRUPT_TIME: Duration = Duration::from_millis(16);
85
86fn io_context(context: &'static str) -> impl FnOnce(io::Error) -> io::Error {
87    move |err| io::Error::new(err.kind(), format!("{context}: {err}"))
88}
89
90fn terminal_char_width(c: char, current_column: usize) -> usize {
91    match c {
92        '\t' => {
93            let next_tab_stop = ((current_column / 8) + 1) * 8;
94            next_tab_stop - current_column
95        }
96        c if c.is_control() => 0,
97        c => UnicodeWidthChar::width(c).unwrap_or(0),
98    }
99}
100
101fn terminal_text_width_from(text: &str, start_column: usize) -> usize {
102    let mut current_column = start_column;
103    let mut chars = text.chars().peekable();
104
105    while let Some(c) = chars.next() {
106        if c == '\u{1b}' {
107            skip_ansi_escape(&mut chars);
108        } else {
109            current_column += terminal_char_width(c, current_column);
110        }
111    }
112
113    current_column - start_column
114}
115
116// These display segments keep terminal control text and user text separate. Existing ANSI helpers
117// like strip_ansi_* are useful when ANSI can be discarded entirely, but input list needs to keep
118// color escapes in the rendered output while still mapping fuzzy matches back to the original
119// source characters.
120struct DisplaySegment {
121    source_index: Option<usize>,
122    text: String,
123}
124
125struct SanitizedText {
126    segments: Vec<DisplaySegment>,
127    text: String,
128    source_chars: usize,
129    truncated: bool,
130}
131
132// Skip ANSI CSI/OSC sequences while measuring terminal width. Existing strip/cut helpers do not
133// account for tab stops from an arbitrary starting column, so width calculation stays local to the
134// input list renderer.
135fn skip_ansi_escape<I>(chars: &mut std::iter::Peekable<I>)
136where
137    I: Iterator<Item = char>,
138{
139    match chars.next() {
140        Some('[') => {
141            for c in chars.by_ref() {
142                if ('@'..='~').contains(&c) {
143                    break;
144                }
145            }
146        }
147        Some(']') => {
148            while let Some(c) = chars.next() {
149                if c == '\u{7}' {
150                    break;
151                }
152                if c == '\u{1b}' && chars.next_if_eq(&'\\').is_some() {
153                    break;
154                }
155            }
156        }
157        Some(_) | None => {}
158    }
159}
160
161// Preserve ANSI CSI/OSC sequences as zero-width display segments. This lets rendering retain
162// upstream styling without treating escape bytes as searchable/displayable characters.
163fn collect_ansi_escape<I>(chars: &mut std::iter::Peekable<I>) -> Option<String>
164where
165    I: Iterator<Item = char>,
166{
167    let mut escape = String::from('\u{1b}');
168
169    match chars.next() {
170        Some('[') => {
171            escape.push('[');
172            for c in chars.by_ref() {
173                escape.push(c);
174                if ('@'..='~').contains(&c) {
175                    return Some(escape);
176                }
177            }
178            Some(escape)
179        }
180        Some(']') => {
181            escape.push(']');
182            while let Some(c) = chars.next() {
183                escape.push(c);
184                if c == '\u{7}' {
185                    return Some(escape);
186                }
187                if c == '\u{1b}' && chars.next_if_eq(&'\\').is_some() {
188                    escape.push('\\');
189                    return Some(escape);
190                }
191            }
192            Some(escape)
193        }
194        Some(c) => {
195            escape.push(c);
196            Some(escape)
197        }
198        None => Some(escape),
199    }
200}
201
202fn sanitize_text_for_display(
203    text: &str,
204    target_width: usize,
205    start_column: usize,
206) -> SanitizedText {
207    let mut current_column = start_column;
208    let max_column = start_column + target_width;
209    let mut segments = Vec::new();
210    let mut sanitized = String::new();
211    let mut chars = text.chars().peekable();
212    let mut source_index = 0;
213    let mut truncated = false;
214
215    while let Some(c) = chars.next() {
216        if c == '\u{1b}' {
217            if let Some(escape) = collect_ansi_escape(&mut chars) {
218                sanitized.push_str(&escape);
219                segments.push(DisplaySegment {
220                    source_index: None,
221                    text: escape,
222                });
223            }
224            continue;
225        }
226
227        let char_width = terminal_char_width(c, current_column);
228        if current_column + char_width > max_column {
229            truncated = true;
230            break;
231        }
232
233        let mut display = String::new();
234        if c == '\t' {
235            display.extend(std::iter::repeat_n(' ', char_width));
236        } else if !c.is_control() {
237            display.push(c);
238        }
239
240        if !display.is_empty() {
241            sanitized.push_str(&display);
242            segments.push(DisplaySegment {
243                source_index: Some(source_index),
244                text: display,
245            });
246        }
247        current_column += char_width;
248        source_index += 1;
249    }
250
251    SanitizedText {
252        segments,
253        text: sanitized,
254        source_chars: source_index,
255        truncated,
256    }
257}
258
259#[cfg(test)]
260fn truncate_ansi_aware_text(text: &str, available_width: usize) -> Cow<'_, str> {
261    truncate_ansi_aware_text_at(text, available_width, 0)
262}
263
264fn truncate_ansi_aware_text_at(
265    text: &str,
266    available_width: usize,
267    start_column: usize,
268) -> Cow<'_, str> {
269    let sanitized = sanitize_text_for_display(text, available_width, start_column);
270    if !sanitized.truncated {
271        Cow::Owned(sanitized.text)
272    } else if available_width <= 1 {
273        Cow::Borrowed("…")
274    } else {
275        let target_width = available_width - 1;
276        let mut sanitized = sanitize_text_for_display(text, target_width, start_column).text;
277        sanitized.push('…');
278        Cow::Owned(sanitized)
279    }
280}
281
282/// Maps TableMode to the appropriate vertical separator character
283fn table_mode_to_separator(mode: TableMode) -> char {
284    match mode {
285        // ASCII-based themes
286        TableMode::Basic | TableMode::BasicCompact | TableMode::Psql | TableMode::Markdown => '|',
287        TableMode::AsciiRounded => '|',
288        // Modern unicode (single line)
289        TableMode::Thin
290        | TableMode::Rounded
291        | TableMode::Single
292        | TableMode::Compact
293        | TableMode::Frameless => '│',
294        TableMode::Reinforced | TableMode::Light => '│',
295        // Heavy borders
296        TableMode::Heavy => '┃',
297        // Double line
298        TableMode::Double | TableMode::CompactDouble => '║',
299        // Special themes
300        TableMode::WithLove => '❤',
301        TableMode::Dots => ':',
302        // Minimal/no borders
303        TableMode::Restructured | TableMode::None => ' ',
304    }
305}
306
307/// Maps TableMode to (horizontal_line_char, intersection_char) for header separator
308fn table_mode_to_header_separator(mode: TableMode) -> (char, char) {
309    match mode {
310        // ASCII-based themes
311        TableMode::Basic | TableMode::BasicCompact | TableMode::Psql => ('-', '+'),
312        TableMode::AsciiRounded => ('-', '+'),
313        TableMode::Markdown => ('-', '|'),
314        // Modern unicode (single line)
315        TableMode::Thin
316        | TableMode::Rounded
317        | TableMode::Single
318        | TableMode::Compact
319        | TableMode::Frameless => ('─', '┼'),
320        TableMode::Reinforced => ('─', '┼'),
321        TableMode::Light => ('─', '─'), // Light has no vertical lines, so no intersection
322        // Heavy borders
323        TableMode::Heavy => ('━', '╋'),
324        // Double line
325        TableMode::Double | TableMode::CompactDouble => ('═', '╬'),
326        // Special themes
327        TableMode::WithLove => ('❤', '❤'),
328        TableMode::Dots => ('.', ':'),
329        // Minimal/no borders - use simple dashes
330        TableMode::Restructured | TableMode::None => (' ', ' '),
331    }
332}
333
334impl Default for InputListConfig {
335    fn default() -> Self {
336        Self {
337            match_text: Style::new().fg(nu_ansi_term::Color::Yellow),
338            footer: Style::new().fg(nu_ansi_term::Color::DarkGray),
339            separator: Style::new().fg(nu_ansi_term::Color::DarkGray),
340            prompt_marker: Style::new().fg(nu_ansi_term::Color::Green),
341            selected_marker: Style::new().fg(nu_ansi_term::Color::Green),
342            table_header: Style::new().bold(),
343            table_separator: Style::new().fg(nu_ansi_term::Color::DarkGray),
344            show_footer: true,
345            separator_char: "─".to_string(),
346            show_separator: true,
347            prompt_marker_text: DEFAULT_PROMPT_MARKER.to_string(),
348            selected_marker_char: DEFAULT_SELECTED_MARKER,
349            table_column_separator: DEFAULT_TABLE_COLUMN_SEPARATOR,
350            table_header_separator: '─',
351            table_header_intersection: '┼',
352            case_sensitivity: CaseSensitivity::default(),
353        }
354    }
355}
356
357impl InputListConfig {
358    fn from_nu_config(
359        config: &nu_protocol::Config,
360        style_computer: &StyleComputer,
361        span: Span,
362    ) -> Self {
363        let mut ret = Self::default();
364
365        // Get styles from color_config (same as regular table command and find)
366        let color_config_header = style_computer.compute("header", &Value::string("", span));
367        let color_config_separator = style_computer.compute("separator", &Value::nothing(span));
368        let color_config_search_result =
369            style_computer.compute("search_result", &Value::string("", span));
370        let color_config_hints = style_computer.compute("hints", &Value::nothing(span));
371        let color_config_row_index = style_computer.compute("row_index", &Value::string("", span));
372
373        ret.table_header = color_config_header;
374        ret.table_separator = color_config_separator;
375        ret.separator = color_config_separator;
376        ret.match_text = color_config_search_result;
377        ret.footer = color_config_hints;
378        ret.prompt_marker = color_config_row_index;
379        ret.selected_marker = color_config_row_index;
380
381        // Derive table separators from user's table mode
382        ret.table_column_separator = table_mode_to_separator(config.table.mode);
383        let (header_sep, header_int) = table_mode_to_header_separator(config.table.mode);
384        ret.table_header_separator = header_sep;
385        ret.table_header_intersection = header_int;
386
387        ret
388    }
389}
390
391enum InteractMode {
392    Single(Option<usize>),
393    Multi(Option<Vec<usize>>),
394}
395
396struct SelectItem {
397    name: String, // Search text (concatenated cells in table mode)
398    cells: Option<Vec<(String, TextStyle)>>, // Cell values with TextStyle for type-based styling (None = single-line mode)
399    value: Value,                            // Original value to return
400}
401
402/// Display mode for key-based conversion in streaming mode
403#[derive(Clone)]
404enum DisplayMode {
405    Default,
406    CellPath(Vec<nu_protocol::ast::PathMember>),
407    Closure(Closure),
408}
409
410/// Layout information for table rendering
411struct TableLayout {
412    columns: Vec<String>,   // Column names
413    col_widths: Vec<usize>, // Computed width per column (content only, not separators)
414    truncated_cols: usize, // Number of columns that fit in terminal starting from horizontal_offset
415}
416
417#[derive(Clone)]
418pub struct InputList;
419
420const INTERACT_ERROR: &str = "Interact error, could not process options";
421
422impl Command for InputList {
423    fn name(&self) -> &str {
424        "input list"
425    }
426
427    fn signature(&self) -> Signature {
428        Signature::build("input list")
429            .input_output_types(vec![
430                (Type::List(Box::new(Type::Any)), Type::Any),
431                (Type::Range, Type::Int),
432            ])
433            .optional("prompt", SyntaxShape::String, "The prompt to display.")
434            .switch(
435                "multi",
436                "Use multiple results, you can press a to toggle all, Ctrl+R to refine.",
437                Some('m'),
438            )
439            .switch("fuzzy", "Use a fuzzy select.", Some('f'))
440            .switch("index", "Returns list indexes.", Some('i'))
441            .switch(
442                "no-footer",
443                "Hide the footer showing item count and selection count.",
444                Some('n'),
445            )
446            .switch(
447                "no-separator",
448                "Hide the separator line between the search box and results.",
449                None,
450            )
451            .named(
452                "case-sensitive",
453                SyntaxShape::OneOf(vec![SyntaxShape::Boolean, SyntaxShape::String]),
454                "Case sensitivity for fuzzy matching: true, false, or 'smart' (case-insensitive unless query has uppercase)",
455                Some('s'),
456            )
457            .named(
458                "display",
459                SyntaxShape::OneOf(vec![
460                    SyntaxShape::CellPath,
461                    SyntaxShape::Closure(Some(vec![SyntaxShape::Any])),
462                ]),
463                "Field or closure to generate display value for search (returns original value when selected)",
464                Some('d'),
465            )
466            .switch(
467                "no-table",
468                "Disable table rendering for table input (show as single lines).",
469                Some('t'),
470            )
471            .switch(
472                "per-column",
473                "Match filter text against each column independently (table mode only).",
474                Some('c'),
475            )
476            .allow_variants_without_examples(true)
477            .category(Category::Platform)
478    }
479
480    fn description(&self) -> &str {
481        "Display an interactive list for user selection."
482    }
483
484    fn extra_description(&self) -> &str {
485        r#"Presents an interactive list in the terminal for selecting items.
486
487Four modes are available:
488- Single (default): Select one item with arrow keys, confirm with Enter
489- Multi (--multi): Select multiple items with Space, toggle all with 'a'
490- Fuzzy (--fuzzy): Type to filter, matches are highlighted
491- Fuzzy Multi (--fuzzy --multi): Type to filter AND select multiple items with Tab, toggle all with Alt+A
492
493Multi mode features:
494- The footer always shows the selection count (e.g., "[1-5 of 10, 3 selected]")
495- Use Ctrl+R to "refine" the list: narrow down to only selected items, keeping them
496  selected so you can deselect the ones you don't want. Can be used multiple times.
497
498Table rendering:
499When piping a table (list of records), items are displayed with aligned columns.
500Use Left/Right arrows (or h/l) to scroll horizontally when columns exceed terminal width.
501In fuzzy mode, use Shift+Left/Right for horizontal scrolling.
502Ellipsis (…) shows when more columns are available in each direction.
503In fuzzy mode, the ellipsis is highlighted when matches exist in hidden columns.
504Use --no-table to disable table rendering and show records as single lines.
505Use --per-column to match filter text against each column independently (best match wins).
506This prevents false positives from matches spanning column boundaries.
507Use --display to specify a column or closure for display/search text (disables table mode).
508The --display flag accepts either a cell path (e.g., -d name) or a closure (e.g., -d {|it| $it.name}).
509The closure receives each item and should return the string to display and search on.
510The original value is always returned when selected, regardless of what --display shows.
511
512Keyboard shortcuts:
513- Up/Down, j/k, Ctrl+n/p: Navigate items
514- Left/Right, h/l: Scroll columns horizontally (table mode, single/multi)
515- Shift+Left/Right: Scroll columns horizontally (fuzzy mode)
516- Home/End: Jump to first/last item
517- PageUp/PageDown: Navigate by page
518- Space: Toggle selection (multi mode)
519- Tab: Toggle selection and move down (fuzzy multi mode)
520- Shift+Tab: Toggle selection and move up (fuzzy multi mode)
521- a: Toggle all items (multi mode), Alt+A in fuzzy multi mode
522- Ctrl+R: Refine list to only selected items (multi modes)
523- Alt+C: Cycle case sensitivity (smart -> CASE -> nocase) in fuzzy modes
524- Alt+P: Toggle per-column matching in fuzzy table mode
525- Enter: Confirm selection
526- Esc: Cancel (all modes)
527- q: Cancel (single/multi modes only)
528- Ctrl+C: Cancel (all modes)
529
530Fuzzy mode supports readline-style editing:
531- Ctrl+A/E: Beginning/end of line
532- Ctrl+B/F, Left/Right: Move cursor
533- Alt+B/F: Move by word
534- Ctrl+U/K: Kill to beginning/end of line
535- Ctrl+W, Alt+Backspace: Delete previous word
536- Ctrl+D, Delete: Delete character at cursor
537
538Styling (inherited from $env.config.color_config):
539- search_result: Match highlighting in fuzzy mode
540- hints: Footer text
541- separator: Separator line and table column separators
542- row_index: Prompt marker and selection marker
543- header: Table column headers
544- Table column characters inherit from $env.config.table.mode
545
546Use --no-footer and --no-separator to hide the footer and separator line."#
547    }
548
549    fn search_terms(&self) -> Vec<&str> {
550        vec![
551            "prompt", "ask", "menu", "select", "pick", "choose", "fzf", "fuzzy",
552        ]
553    }
554
555    fn run(
556        &self,
557        engine_state: &EngineState,
558        stack: &mut Stack,
559        call: &Call,
560        input: PipelineData,
561    ) -> Result<PipelineData, ShellError> {
562        let head = call.head;
563        // `RawModeGuard` below enables raw mode; check the detached-stack
564        // precondition first so a completion thread declines before drawing.
565        stack.require_stdin(head)?;
566        let prompt: Option<String> = call.opt(engine_state, stack, 0)?;
567        let multi = call.has_flag(engine_state, stack, "multi")?;
568        let fuzzy = call.has_flag(engine_state, stack, "fuzzy")?;
569        let index = call.has_flag(engine_state, stack, "index")?;
570        let display_flag: Option<Value> = call.get_flag(engine_state, stack, "display")?;
571        let no_footer = call.has_flag(engine_state, stack, "no-footer")?;
572        let no_separator = call.has_flag(engine_state, stack, "no-separator")?;
573        let case_sensitive: Option<Value> = call.get_flag(engine_state, stack, "case-sensitive")?;
574        let no_table = call.has_flag(engine_state, stack, "no-table")?;
575        let per_column = call.has_flag(engine_state, stack, "per-column")?;
576        let config = stack.get_config(engine_state);
577        let style_computer = StyleComputer::from_config(engine_state, stack);
578        let mut input_list_config = InputListConfig::from_nu_config(&config, &style_computer, head);
579        if no_footer {
580            input_list_config.show_footer = false;
581        }
582        if no_separator {
583            input_list_config.show_separator = false;
584        }
585        if let Some(cs) = case_sensitive {
586            input_list_config.case_sensitivity = match &cs {
587                Value::Bool { val: true, .. } => CaseSensitivity::CaseSensitive,
588                Value::Bool { val: false, .. } => CaseSensitivity::CaseInsensitive,
589                Value::String { val, .. } if val == "smart" => CaseSensitivity::Smart,
590                Value::String { val, .. } if val == "true" => CaseSensitivity::CaseSensitive,
591                Value::String { val, .. } if val == "false" => CaseSensitivity::CaseInsensitive,
592                _ => {
593                    return Err(ShellError::InvalidValue {
594                        valid: "true, false, or 'smart'".to_string(),
595                        actual: cs.to_abbreviated_string(&config),
596                        span: cs.span(),
597                    });
598                }
599            };
600        }
601
602        let (initial_values, pending_stream) =
603            Self::initial_values_from_input(input, head, engine_state.signals().clone())?;
604
605        // Map display_mode from display_flag
606        let display_mode = match &display_flag {
607            Some(Value::CellPath { val: cellpath, .. }) => {
608                DisplayMode::CellPath(cellpath.members.clone())
609            }
610            Some(Value::Closure { val: closure, .. }) => {
611                DisplayMode::Closure(Closure::clone(closure))
612            }
613            _ => DisplayMode::Default,
614        };
615
616        // Detect table mode
617        let columns = if matches!(display_mode, DisplayMode::Default) && !no_table {
618            get_columns(&initial_values)
619        } else {
620            vec![]
621        };
622        let is_table_mode = !columns.is_empty();
623
624        // Build initial SelectItem list
625        let options: Vec<SelectItem> = initial_values
626            .into_iter()
627            .map(|val| {
628                InputList::make_select_item(
629                    val,
630                    &columns,
631                    &display_mode,
632                    &config,
633                    engine_state,
634                    stack,
635                    head,
636                )
637            })
638            .collect();
639
640        let table_layout = if is_table_mode {
641            Some(Self::calculate_table_layout(&columns, &options))
642        } else {
643            None
644        };
645
646        if options.is_empty() && pending_stream.is_none() {
647            return Err(ShellError::TypeMismatch {
648                err_message: "expected a list or table, it can also be a problem with the inner type of your list.".to_string(),
649                span: head,
650            });
651        }
652
653        let mode = if multi && fuzzy {
654            SelectMode::FuzzyMulti
655        } else if multi {
656            SelectMode::Multi
657        } else if fuzzy {
658            SelectMode::Fuzzy
659        } else {
660            SelectMode::Single
661        };
662
663        let config_clone = config.clone();
664        let columns_clone = columns.clone();
665        let display_mode_clone = display_mode.clone();
666
667        // Build conversion logic once and reuse it for all lazily-loaded rows.
668        // This guarantees that rows loaded later follow the exact same display rules as rows
669        // loaded during initial priming.
670        let item_generator: Box<dyn FnMut(Value) -> SelectItem + '_> =
671            Box::new(move |val: Value| {
672                InputList::make_select_item(
673                    val,
674                    &columns_clone,
675                    &display_mode_clone,
676                    &config_clone,
677                    engine_state,
678                    stack,
679                    head,
680                )
681            });
682
683        let mut widget = SelectWidget::new(
684            mode,
685            prompt.as_deref(),
686            options,
687            input_list_config,
688            table_layout,
689            per_column,
690            StreamState {
691                stream_reader: pending_stream,
692                item_generator: Some(item_generator),
693            },
694        );
695        // `require_stdin` ran above; this guard restores reedline's raw mode if
696        // we were already in it (menu source / main-thread completer).
697        let _raw_mode = RawModeGuard::enter(head)?;
698        let answer = widget.run().map_err(|err| {
699            IoError::new_with_additional_context(err, call.head, None, INTERACT_ERROR)
700        })?;
701
702        Ok(match answer {
703            InteractMode::Multi(res) => {
704                if index {
705                    match res {
706                        Some(opts) => Value::list(
707                            opts.into_iter()
708                                .map(|s| Value::int(s as i64, head))
709                                .collect(),
710                            head,
711                        ),
712                        None => Value::nothing(head),
713                    }
714                } else {
715                    match res {
716                        Some(opts) => Value::list(
717                            opts.iter()
718                                .map(|s| widget.items[*s].value.clone())
719                                .collect(),
720                            head,
721                        ),
722                        None => Value::nothing(head),
723                    }
724                }
725            }
726            InteractMode::Single(res) => {
727                if index {
728                    match res {
729                        Some(opt) => Value::int(opt as i64, head),
730                        None => Value::nothing(head),
731                    }
732                } else {
733                    match res {
734                        Some(opt) => widget.items[opt].value.clone(),
735                        None => Value::nothing(head),
736                    }
737                }
738            }
739        }
740        .into_pipeline_data())
741    }
742
743    fn examples(&self) -> Vec<Example<'_>> {
744        vec![
745            Example {
746                description: "Return a single value from a list.",
747                example: "[1 2 3 4 5] | input list 'Rate it'",
748                result: None,
749            },
750            Example {
751                description: "Return multiple values from a list.",
752                example: "[Banana Kiwi Pear Peach Strawberry] | input list --multi 'Add fruits to the basket'",
753                result: None,
754            },
755            Example {
756                description: "Return a single record from a table with fuzzy search.",
757                example: "ls | input list --fuzzy 'Select the target'",
758                result: None,
759            },
760            Example {
761                description: "Choose an item from a range.",
762                example: "1..10 | input list",
763                result: None,
764            },
765            Example {
766                description: "Return the index of a selected item.",
767                example: "[Banana Kiwi Pear Peach Strawberry] | input list --index",
768                result: None,
769            },
770            Example {
771                description: "Choose an item from a table using a column as display value.",
772                example: "[[name price]; [Banana 12] [Kiwi 4] [Pear 7]] | input list -d name",
773                result: None,
774            },
775            Example {
776                description: "Choose an item using a closure to generate display text",
777                example: r#"[[name price]; [Banana 12] [Kiwi 4] [Pear 7]] | input list -d {|it| $"($it.name): $($it.price)"}"#,
778                result: None,
779            },
780            Example {
781                description: "Fuzzy search with case-sensitive matching",
782                example: "[abc ABC aBc] | input list --fuzzy --case-sensitive true",
783                result: None,
784            },
785            Example {
786                description: "Fuzzy search without the footer showing item count",
787                example: "ls | input list --fuzzy --no-footer",
788                result: None,
789            },
790            Example {
791                description: "Fuzzy search without the separator line",
792                example: "ls | input list --fuzzy --no-separator",
793                result: None,
794            },
795            Example {
796                description: "Fuzzy search with custom match highlighting color",
797                example: r#"$env.config.color_config.search_result = "red"; ls | input list --fuzzy"#,
798                result: None,
799            },
800            Example {
801                description: "Display a table with column rendering",
802                example: r#"[[name size]; [file1.txt "1.2 KB"] [file2.txt "3.4 KB"]] | input list"#,
803                result: None,
804            },
805            Example {
806                description: "Display a table as single lines (no table rendering)",
807                example: "ls | input list --no-table",
808                result: None,
809            },
810            Example {
811                description: "Fuzzy search with multiple selection (use Tab to toggle)",
812                example: "ls | input list --fuzzy --multi",
813                result: None,
814            },
815        ]
816    }
817}
818
819impl InputList {
820    /// Extract initial values from supported input.
821    ///
822    /// Already materialized lists are returned directly. Only true streams and ranges go through
823    /// the timed initial read so slow or unbounded input can keep the UI responsive.
824    fn initial_values_from_input(
825        input: PipelineData,
826        head: Span,
827        signals: Signals,
828    ) -> Result<(Vec<Value>, Option<StreamReader>), ShellError> {
829        match input {
830            PipelineData::ListStream(stream, ..) => Ok(Self::read_initial_stream_values(stream)),
831            PipelineData::Value(Value::List { vals, .. }, ..) => Ok((vals.into_owned(), None)),
832            input @ PipelineData::Value(Value::Range { .. }, ..) => {
833                let stream = ListStream::new(input.into_iter(), head, signals);
834                Ok(Self::read_initial_stream_values(stream))
835            }
836            _ => Err(ShellError::TypeMismatch {
837                err_message: "expected a list, a table, or a range".to_string(),
838                span: head,
839            }),
840        }
841    }
842
843    /// Read initial values from the upstream stream.
844    ///
845    /// Returns any values available before the initial timeout/cap and a reader for the remaining
846    /// stream when it is not exhausted yet.
847    fn read_initial_stream_values(stream: ListStream) -> (Vec<Value>, Option<StreamReader>) {
848        let mut reader = StreamReader::new(stream);
849        let values =
850            reader.drain_available_until(INITIAL_STREAM_MAX_ITEMS, INITIAL_STREAM_COLLECT_TIMEOUT);
851        let pending_stream = if reader.is_finished() {
852            None
853        } else {
854            Some(reader)
855        };
856
857        (values, pending_stream)
858    }
859
860    /// Convert a raw input `Value` into a `SelectItem`, used for streaming growth
861    fn make_select_item(
862        value: Value,
863        columns: &[String],
864        display_mode: &DisplayMode,
865        config: &Config,
866        engine_state: &EngineState,
867        stack: &mut Stack,
868        span: Span,
869    ) -> SelectItem {
870        if !columns.is_empty() {
871            // Build style computer on demand so streamed rows preserve the same type-aware
872            // formatting behavior as eagerly materialized rows.
873            let style_computer = StyleComputer::from_config(engine_state, stack);
874
875            let cells: Vec<(String, TextStyle)> = columns
876                .iter()
877                .map(|col| {
878                    if let Value::Record { val: record, .. } = &value {
879                        record
880                            .get(col)
881                            .map(|v| nu_value_to_string(v, config, &style_computer))
882                            .unwrap_or_else(|| (String::new(), TextStyle::default()))
883                    } else {
884                        (String::new(), TextStyle::default())
885                    }
886                })
887                .collect();
888
889            let name = cells
890                .iter()
891                .map(|(s, _)| s.as_str())
892                .collect::<Vec<_>>()
893                .join(" ");
894            SelectItem {
895                name,
896                cells: Some(cells),
897                value,
898            }
899        } else {
900            let display_value = match display_mode {
901                DisplayMode::CellPath(cellpath) => value
902                    .follow_cell_path(cellpath)
903                    .map(|v| v.to_expanded_string(", ", config))
904                    .unwrap_or_else(|_| value.to_expanded_string(", ", config)),
905                DisplayMode::Closure(closure) => {
906                    let mut closure_eval =
907                        ClosureEval::new(engine_state, stack, Closure::clone(closure));
908                    closure_eval
909                        .run_with_value(value.clone())
910                        .and_then(|data| data.into_value(span))
911                        .map(|v| v.to_expanded_string(", ", config))
912                        .unwrap_or_else(|_| value.to_expanded_string(", ", config))
913                }
914                DisplayMode::Default => value.to_expanded_string(", ", config),
915            };
916            SelectItem {
917                name: display_value,
918                cells: None,
919                value,
920            }
921        }
922    }
923
924    /// Calculate column widths for table rendering
925    fn calculate_table_layout(columns: &[String], options: &[SelectItem]) -> TableLayout {
926        let mut layout = TableLayout {
927            columns: columns.to_vec(),
928            col_widths: columns.iter().map(|c| c.width()).collect(),
929            truncated_cols: 0, // Will be calculated when terminal width is known
930        };
931
932        Self::update_table_layout_with_items(&mut layout, options);
933        layout
934    }
935
936    fn update_table_layout_with_items(layout: &mut TableLayout, items: &[SelectItem]) -> bool {
937        let mut changed = false;
938        for item in items {
939            if let Some(cells) = &item.cells {
940                for (i, (cell_text, _)) in cells.iter().enumerate() {
941                    if i < layout.col_widths.len() {
942                        let cell_width = terminal_text_width_from(cell_text, 0);
943                        if cell_width > layout.col_widths[i] {
944                            layout.col_widths[i] = cell_width;
945                            changed = true;
946                        }
947                    }
948                }
949            }
950        }
951        changed
952    }
953}
954
955#[derive(Clone, Copy, PartialEq, Eq)]
956enum SelectMode {
957    Single,
958    Multi,
959    Fuzzy,
960    FuzzyMulti,
961}
962
963/// Streaming-specific state injected into `SelectWidget`.
964///
965/// Keeping stream concerns grouped in one struct reduces constructor parameter noise and
966/// keeps the non-streaming widget state easier to reason about.
967struct StreamState<'a> {
968    stream_reader: Option<StreamReader>,
969    item_generator: Option<Box<dyn FnMut(Value) -> SelectItem + 'a>>,
970}
971
972enum StreamMessage {
973    Item(Value),
974    End,
975}
976
977struct StreamReader {
978    receiver: Receiver<StreamMessage>,
979    finished: bool,
980}
981
982impl StreamReader {
983    fn new(stream: ListStream) -> Self {
984        let (sender, receiver) = mpsc::sync_channel(STREAM_CHANNEL_CAPACITY);
985
986        thread::spawn(move || {
987            for value in stream {
988                if sender.send(StreamMessage::Item(value)).is_err() {
989                    return;
990                }
991            }
992
993            let _ = sender.send(StreamMessage::End);
994        });
995
996        Self {
997            receiver,
998            finished: false,
999        }
1000    }
1001
1002    fn is_finished(&self) -> bool {
1003        self.finished
1004    }
1005
1006    fn drain_available(&mut self, count: usize) -> Vec<Value> {
1007        let mut values = Vec::new();
1008
1009        while values.len() < count && !self.finished {
1010            match self.receiver.try_recv() {
1011                Ok(StreamMessage::Item(value)) => values.push(value),
1012                Ok(StreamMessage::End) | Err(TryRecvError::Disconnected) => {
1013                    self.finished = true;
1014                    break;
1015                }
1016                Err(TryRecvError::Empty) => break,
1017            }
1018        }
1019
1020        values
1021    }
1022
1023    fn drain_available_for(&mut self, max_duration: Duration) -> Vec<Value> {
1024        let start = nu_utils::time::Instant::now();
1025        let mut values = Vec::new();
1026
1027        while !self.finished {
1028            match self.receiver.try_recv() {
1029                Ok(StreamMessage::Item(value)) => values.push(value),
1030                Ok(StreamMessage::End) | Err(TryRecvError::Disconnected) => {
1031                    self.finished = true;
1032                    break;
1033                }
1034                Err(TryRecvError::Empty) => break,
1035            }
1036
1037            if start.elapsed() >= max_duration {
1038                break;
1039            }
1040        }
1041
1042        values
1043    }
1044
1045    fn drain_available_until(&mut self, count: usize, max_duration: Duration) -> Vec<Value> {
1046        let start = nu_utils::time::Instant::now();
1047        let mut values = Vec::new();
1048
1049        while values.len() < count && !self.finished {
1050            let elapsed = start.elapsed();
1051            let Some(remaining) = max_duration.checked_sub(elapsed) else {
1052                break;
1053            };
1054
1055            match self.receiver.recv_timeout(remaining) {
1056                Ok(StreamMessage::Item(value)) => values.push(value),
1057                Ok(StreamMessage::End) | Err(RecvTimeoutError::Disconnected) => {
1058                    self.finished = true;
1059                    break;
1060                }
1061                Err(RecvTimeoutError::Timeout) => break,
1062            }
1063        }
1064
1065        values
1066    }
1067}
1068
1069struct SelectWidget<'a> {
1070    mode: SelectMode,
1071    prompt: Option<&'a str>,
1072    items: Vec<SelectItem>,
1073    cursor: usize,
1074    selected: HashSet<usize>,
1075    filter_text: String,
1076    filtered_indices: Vec<usize>,
1077    scroll_offset: usize,
1078    stream_reader: Option<StreamReader>,
1079    item_generator: Option<Box<dyn FnMut(Value) -> SelectItem + 'a>>,
1080    visible_height: u16,
1081    matcher: NucleoMatcher,
1082    last_filter_text: String,
1083    force_full_filter: bool,
1084    rendered_lines: usize,
1085    /// Previous cursor position for efficient cursor-only updates
1086    prev_cursor: usize,
1087    /// Previous scroll offset to detect if we need full redraw
1088    prev_scroll_offset: usize,
1089    /// Whether this is the first render
1090    first_render: bool,
1091    /// In fuzzy mode, cursor is positioned at filter line; this tracks how far up from end
1092    fuzzy_cursor_offset: usize,
1093    /// Whether filter results changed since last render
1094    results_changed: bool,
1095    /// Whether filter text changed since last render
1096    filter_text_changed: bool,
1097    /// Item that was toggled in multi-mode (for checkbox-only update)
1098    toggled_item: Option<usize>,
1099    /// Whether all items were toggled (for bulk checkbox update)
1100    toggled_all: bool,
1101    /// Cursor position within filter_text (byte offset)
1102    filter_cursor: usize,
1103    /// Configuration for input list styles
1104    config: InputListConfig,
1105    /// Cached terminal width for separator line
1106    term_width: u16,
1107    /// Cached separator line (regenerated on terminal resize)
1108    separator_line: String,
1109    /// Table layout for table mode (None if single-line mode)
1110    table_layout: Option<TableLayout>,
1111    /// First visible column index (for horizontal scrolling)
1112    horizontal_offset: usize,
1113    /// Whether horizontal scroll changed since last render
1114    horizontal_scroll_changed: bool,
1115    /// Whether terminal width changed since last render
1116    width_changed: bool,
1117    /// Whether streamed rows changed table column widths since last render
1118    table_layout_changed: bool,
1119    /// Whether the list has been refined to only show selected items (Multi/FuzzyMulti)
1120    refined: bool,
1121    /// Whether streamed rows should keep the cursor pinned to the loaded tail
1122    follow_stream_to_end: bool,
1123    /// Current footer spinner frame while upstream rows are still pending
1124    stream_spinner_frame: usize,
1125    /// Last item count shown in the streaming footer
1126    stream_footer_item_count: usize,
1127    /// Last time the streaming footer spinner/count was advanced
1128    last_stream_footer_update: nu_utils::time::Instant,
1129    /// Base indices for refined mode (the subset to filter from in FuzzyMulti)
1130    refined_base_indices: Vec<usize>,
1131    /// Whether to match filter text against each column independently (table mode only)
1132    per_column: bool,
1133    /// Whether settings changed since last render (for footer update)
1134    settings_changed: bool,
1135    /// Cached selected marker string (computed once, doesn't change at runtime)
1136    selected_marker_cached: String,
1137    /// Cached visible columns calculation (cols_visible, has_more_right)
1138    /// Invalidated when horizontal_offset, term_width, or table_layout changes
1139    visible_columns_cache: Option<(usize, bool)>,
1140}
1141
1142impl<'a> SelectWidget<'a> {
1143    fn make_matcher() -> NucleoMatcher {
1144        NucleoMatcher::new({
1145            let mut config = NucleoConfig::DEFAULT;
1146            config.prefer_prefix = true;
1147            config
1148        })
1149    }
1150
1151    fn new(
1152        mode: SelectMode,
1153        prompt: Option<&'a str>,
1154        items: Vec<SelectItem>,
1155        config: InputListConfig,
1156        table_layout: Option<TableLayout>,
1157        per_column: bool,
1158        stream_state: StreamState<'a>,
1159    ) -> Self {
1160        let filtered_indices: Vec<usize> = (0..items.len()).collect();
1161        let matcher = Self::make_matcher();
1162        // Pre-compute the selected marker string (doesn't change at runtime)
1163        let selected_marker_cached = format!(
1164            "{} ",
1165            config
1166                .selected_marker
1167                .paint(config.selected_marker_char.to_string())
1168        );
1169        let initial_item_count = items.len();
1170        Self {
1171            mode,
1172            prompt,
1173            items,
1174            cursor: 0,
1175            selected: HashSet::new(),
1176            filter_text: String::new(),
1177            filtered_indices,
1178            scroll_offset: 0,
1179            visible_height: 10,
1180            matcher,
1181            last_filter_text: String::new(),
1182            force_full_filter: false,
1183            rendered_lines: 0,
1184            prev_cursor: 0,
1185            prev_scroll_offset: 0,
1186            first_render: true,
1187            fuzzy_cursor_offset: 0,
1188            results_changed: true,
1189            filter_text_changed: false,
1190            toggled_item: None,
1191            toggled_all: false,
1192            filter_cursor: 0,
1193            config,
1194            term_width: 0,
1195            separator_line: String::new(),
1196            table_layout,
1197            horizontal_offset: 0,
1198            horizontal_scroll_changed: false,
1199            width_changed: false,
1200            table_layout_changed: false,
1201            refined: false,
1202            follow_stream_to_end: false,
1203            stream_spinner_frame: 0,
1204            stream_footer_item_count: initial_item_count,
1205            last_stream_footer_update: nu_utils::time::Instant::now(),
1206            refined_base_indices: Vec::new(),
1207            per_column,
1208            settings_changed: false,
1209            selected_marker_cached,
1210            stream_reader: stream_state.stream_reader,
1211            item_generator: stream_state.item_generator,
1212            visible_columns_cache: None,
1213        }
1214    }
1215
1216    /// Generate the separator line based on current terminal width
1217    fn generate_separator_line(&mut self) {
1218        let sep_width = self.config.separator_char.width();
1219        let repeat_count = (self.term_width as usize)
1220            .checked_div(sep_width)
1221            .unwrap_or(self.term_width as usize);
1222        self.separator_line = self.config.separator_char.repeat(repeat_count);
1223    }
1224
1225    /// Get the styled prompt marker string (for fuzzy mode filter line)
1226    fn prompt_marker(&self) -> String {
1227        self.config
1228            .prompt_marker
1229            .paint(&self.config.prompt_marker_text)
1230            .to_string()
1231    }
1232
1233    /// Get the width of the prompt marker in characters
1234    fn prompt_marker_width(&self) -> usize {
1235        self.config.prompt_marker_text.width()
1236    }
1237
1238    /// Position terminal cursor within the fuzzy filter text
1239    fn position_fuzzy_cursor(&self, stderr: &mut impl Write) -> io::Result<()> {
1240        let text_before_cursor = &self.filter_text[..self.filter_cursor];
1241        let cursor_col = self.prompt_marker_width() + text_before_cursor.width();
1242        execute!(stderr, MoveToColumn(cursor_col as u16))
1243    }
1244
1245    /// Get the styled selection marker string (for active items)
1246    fn selected_marker(&self) -> &str {
1247        &self.selected_marker_cached
1248    }
1249
1250    /// Check if we're in table mode
1251    fn is_table_mode(&self) -> bool {
1252        self.table_layout.is_some()
1253    }
1254
1255    /// Check if we're in a multi-selection mode
1256    fn is_multi_mode(&self) -> bool {
1257        self.mode == SelectMode::Multi || self.mode == SelectMode::FuzzyMulti
1258    }
1259
1260    /// Check if we're in a fuzzy mode
1261    fn is_fuzzy_mode(&self) -> bool {
1262        self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti
1263    }
1264
1265    /// Try to convert a value into a SelectItem via the configured generator
1266    fn make_select_item(&mut self, value: Value) -> SelectItem {
1267        if let Some(r#gen) = self.item_generator.as_mut() {
1268            r#gen(value)
1269        } else {
1270            // Defensive fallback for test-only widget construction paths.
1271            // In normal command execution the generator is always present whenever streaming is
1272            // active, so this branch should remain cold.
1273            SelectItem {
1274                name: value.to_expanded_string(", ", &Config::default()),
1275                cells: None,
1276                value,
1277            }
1278        }
1279    }
1280
1281    /// Load more items from upstream stream when near the end of the loaded list.
1282    fn load_more_items(&mut self, count: usize) -> bool {
1283        let Some(reader) = self.stream_reader.as_mut() else {
1284            return false;
1285        };
1286
1287        let values = reader.drain_available(count);
1288        let stream_finished = reader.is_finished();
1289        self.append_streamed_values(values, stream_finished)
1290    }
1291
1292    fn load_more_items_for(&mut self, max_duration: Duration) -> bool {
1293        let Some(reader) = self.stream_reader.as_mut() else {
1294            return false;
1295        };
1296
1297        let values = reader.drain_available_for(max_duration);
1298        let stream_finished = reader.is_finished();
1299        self.append_streamed_values(values, stream_finished)
1300    }
1301
1302    fn append_streamed_values(&mut self, values: Vec<Value>, stream_finished: bool) -> bool {
1303        if stream_finished {
1304            self.stream_reader = None;
1305            self.stream_footer_item_count = self.items.len() + values.len();
1306            self.settings_changed = true;
1307        }
1308
1309        if values.is_empty() {
1310            if stream_finished {
1311                return true;
1312            }
1313            return false;
1314        }
1315
1316        let old_filtered_indices = if self.filter_text.is_empty() && !self.refined {
1317            None
1318        } else {
1319            Some(self.filtered_indices.clone())
1320        };
1321        let start_index = self.items.len();
1322        for value in values {
1323            let item = self.make_select_item(value);
1324            self.items.push(item);
1325        }
1326
1327        if self.items.len() > start_index {
1328            // Table widths may have expanded as more rows are loaded
1329            if self.is_table_mode()
1330                && let Some(layout) = &mut self.table_layout
1331                && InputList::update_table_layout_with_items(layout, &self.items[start_index..])
1332            {
1333                self.table_layout_changed = true;
1334                self.update_table_layout();
1335            }
1336
1337            if self.filter_text.is_empty() && !self.refined {
1338                self.filtered_indices.extend(start_index..self.items.len());
1339            } else {
1340                self.force_full_filter = true;
1341                self.update_filter();
1342            }
1343
1344            if let Some(old_filtered_indices) = old_filtered_indices {
1345                self.results_changed =
1346                    self.results_changed || old_filtered_indices != self.filtered_indices;
1347            }
1348            true
1349        } else {
1350            false
1351        }
1352    }
1353
1354    /// Ensure we have enough items to show around the cursor; stream if needed.
1355    fn maybe_load_more(&mut self) -> bool {
1356        if self.stream_reader.is_none() {
1357            return false;
1358        }
1359
1360        // Prefetch a little before hitting the end of loaded rows to avoid visible refill latency.
1361        let threshold = self.scroll_offset + self.visible_height as usize + STREAM_PREFETCH_MARGIN;
1362        if self.is_fuzzy_mode() && !self.filter_text.is_empty() || threshold >= self.items.len() {
1363            self.load_more_items(STREAM_LOAD_BATCH)
1364        } else {
1365            false
1366        }
1367    }
1368
1369    /// Cycle case sensitivity: Smart -> CaseSensitive -> CaseInsensitive -> Smart
1370    fn toggle_case_sensitivity(&mut self) {
1371        self.config.case_sensitivity = match self.config.case_sensitivity {
1372            CaseSensitivity::Smart => CaseSensitivity::CaseSensitive,
1373            CaseSensitivity::CaseSensitive => CaseSensitivity::CaseInsensitive,
1374            CaseSensitivity::CaseInsensitive => CaseSensitivity::Smart,
1375        };
1376        self.rebuild_matcher();
1377        // Re-run filter with new matcher
1378        if !self.filter_text.is_empty() {
1379            self.force_full_filter = true;
1380            self.update_filter();
1381        }
1382        self.settings_changed = true;
1383    }
1384
1385    /// Toggle per-column matching (only meaningful in table mode)
1386    fn toggle_per_column(&mut self) {
1387        if self.is_table_mode() {
1388            self.per_column = !self.per_column;
1389            // Re-run filter with new matching mode
1390            if !self.filter_text.is_empty() {
1391                self.force_full_filter = true;
1392                self.update_filter();
1393            }
1394            self.settings_changed = true;
1395        }
1396    }
1397
1398    /// Reset the fuzzy matcher's scratch state after matching settings change.
1399    fn rebuild_matcher(&mut self) {
1400        self.matcher = Self::make_matcher();
1401    }
1402
1403    /// Get the settings indicator string for the footer (fuzzy modes only)
1404    /// Returns empty string if not in fuzzy mode, otherwise returns " [settings]"
1405    fn settings_indicator(&self) -> String {
1406        if !self.is_fuzzy_mode() {
1407            return String::new();
1408        }
1409
1410        let case_str = match self.config.case_sensitivity {
1411            CaseSensitivity::Smart => "smart",
1412            CaseSensitivity::CaseSensitive => "CASE",
1413            CaseSensitivity::CaseInsensitive => "nocase",
1414        };
1415
1416        if self.is_table_mode() && self.per_column {
1417            format!(" [{} col]", case_str)
1418        } else {
1419            format!(" [{}]", case_str)
1420        }
1421    }
1422
1423    fn stream_is_pending(&self) -> bool {
1424        self.stream_reader.is_some()
1425    }
1426
1427    fn stream_spinner(&self) -> &'static str {
1428        STREAM_SPINNER_FRAMES[self.stream_spinner_frame % STREAM_SPINNER_FRAMES.len()]
1429    }
1430
1431    fn update_stream_footer(&mut self) {
1432        if !self.stream_is_pending() {
1433            return;
1434        }
1435
1436        if self.last_stream_footer_update.elapsed() >= STREAM_FOOTER_UPDATE_INTERVAL {
1437            self.stream_spinner_frame =
1438                (self.stream_spinner_frame + 1) % STREAM_SPINNER_FRAMES.len();
1439            self.stream_footer_item_count = self.items.len();
1440            self.last_stream_footer_update = nu_utils::time::Instant::now();
1441            self.settings_changed = true;
1442        }
1443    }
1444
1445    /// Generate the footer string, truncating if necessary to fit terminal width
1446    fn generate_footer(&self) -> String {
1447        let total_count = self.current_list_len();
1448        let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
1449        let settings = self.settings_indicator();
1450        let stream_is_pending = self.stream_is_pending();
1451        let count_text = if stream_is_pending {
1452            format!(
1453                "{} {}",
1454                self.stream_footer_item_count,
1455                self.stream_spinner()
1456            )
1457        } else {
1458            total_count.to_string()
1459        };
1460
1461        let position_part = if self.is_multi_mode() {
1462            format!(
1463                "[{}-{} of {}, {} selected]",
1464                self.scroll_offset + 1,
1465                end.min(total_count),
1466                count_text,
1467                self.selected.len()
1468            )
1469        } else {
1470            format!(
1471                "[{}-{} of {}]",
1472                self.scroll_offset + 1,
1473                end.min(total_count),
1474                count_text
1475            )
1476        };
1477
1478        let full_footer = format!("{}{}", position_part, settings);
1479
1480        // Truncate if footer exceeds terminal width
1481        let max_width = self.term_width as usize;
1482        if full_footer.width() <= max_width {
1483            full_footer
1484        } else if max_width <= 3 {
1485            // Too narrow, just show ellipsis
1486            "…".to_string()
1487        } else {
1488            // Try to fit position part + truncated settings, or just position part
1489            if position_part.width() <= max_width {
1490                // Position fits, truncate or drop settings
1491                let remaining = max_width - position_part.width();
1492                if remaining <= 4 {
1493                    // Not enough room for meaningful settings, just show position
1494                    position_part
1495                } else {
1496                    // Truncate settings portion
1497                    let target_width = remaining - 2; // Reserve space for "…]"
1498                    let mut current_width = 0;
1499                    let mut end_pos = 0;
1500
1501                    // Skip the leading " [" in settings
1502                    for (byte_pos, c) in settings.char_indices().skip(2) {
1503                        if c == ']' {
1504                            break;
1505                        }
1506                        let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
1507                        if current_width + char_width > target_width {
1508                            break;
1509                        }
1510                        end_pos = byte_pos + c.len_utf8();
1511                        current_width += char_width;
1512                    }
1513                    if end_pos > 2 {
1514                        format!("{} [{}…]", position_part, &settings[2..end_pos])
1515                    } else {
1516                        position_part
1517                    }
1518                }
1519            } else {
1520                // Even position part doesn't fit, truncate it
1521                let target_width = max_width - 2; // Reserve space for "…]"
1522                let mut current_width = 0;
1523                let mut end_pos = 0;
1524
1525                for (byte_pos, c) in position_part.char_indices() {
1526                    if c == ']' {
1527                        break;
1528                    }
1529                    let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
1530                    if current_width + char_width > target_width {
1531                        break;
1532                    }
1533                    end_pos = byte_pos + c.len_utf8();
1534                    current_width += char_width;
1535                }
1536                format!("{}…]", &position_part[..end_pos])
1537            }
1538        }
1539    }
1540
1541    /// Check if footer should be shown
1542    /// Footer is always shown in fuzzy modes (for settings display), multi modes (for selection
1543    /// count), or when the list fills the item area reserved above the footer.
1544    fn has_footer(&self) -> bool {
1545        self.config.show_footer
1546            && (self.is_fuzzy_mode()
1547                || self.is_multi_mode()
1548                || self.current_list_len() >= self.visible_height as usize
1549                || self.stream_is_pending())
1550    }
1551
1552    /// Render just the footer text at current cursor position (for optimized updates)
1553    fn render_footer_inline(&self, stderr: &mut impl Write) -> io::Result<()> {
1554        let indicator = self.generate_footer();
1555        execute!(
1556            stderr,
1557            MoveToColumn(0),
1558            Print(self.config.footer.paint(&indicator)),
1559            Clear(ClearType::UntilNewLine),
1560        )
1561    }
1562
1563    /// Get the row prefix width (selection marker + optional checkbox)
1564    fn row_prefix_width(&self) -> usize {
1565        match self.mode {
1566            SelectMode::Multi | SelectMode::FuzzyMulti => 6, // "> [x] " or "  [ ] "
1567            _ => 2,                                          // "> " or "  "
1568        }
1569    }
1570
1571    /// Get the table column separator string (e.g., " │ ")
1572    fn table_column_separator(&self) -> String {
1573        format!(" {} ", self.config.table_column_separator)
1574    }
1575
1576    /// Get the width of the table column separator (char width + 2 for surrounding spaces)
1577    fn table_column_separator_width(&self) -> usize {
1578        UnicodeWidthChar::width(self.config.table_column_separator).unwrap_or(1) + 2
1579    }
1580
1581    /// Calculate how many columns fit starting from horizontal_offset
1582    /// Returns (number of columns that fit, whether there are more columns to the right)
1583    /// Uses cached value if available (cache is updated by update_table_layout)
1584    fn calculate_visible_columns(&self) -> (usize, bool) {
1585        // Use cache if available (populated by update_table_layout)
1586        if let Some(cached) = self.visible_columns_cache {
1587            return cached;
1588        }
1589
1590        // Fallback to computation (should rarely happen after first render)
1591        let Some(layout) = &self.table_layout else {
1592            return (0, false);
1593        };
1594
1595        Self::calculate_visible_columns_for_layout(
1596            layout,
1597            self.horizontal_offset,
1598            self.term_width as usize,
1599            self.row_prefix_width(),
1600            self.table_column_separator_width(),
1601        )
1602    }
1603
1604    /// Static helper to calculate visible columns without borrowing self
1605    fn calculate_visible_columns_for_layout(
1606        layout: &TableLayout,
1607        horizontal_offset: usize,
1608        term_width: usize,
1609        prefix_width: usize,
1610        separator_width: usize,
1611    ) -> (usize, bool) {
1612        // Account for scroll indicators: "… │ " on left (1 + separator_width)
1613        let scroll_indicator_width = if horizontal_offset > 0 {
1614            1 + separator_width
1615        } else {
1616            0
1617        };
1618        let available = term_width
1619            .saturating_sub(prefix_width)
1620            .saturating_sub(scroll_indicator_width);
1621
1622        let mut used_width = 0;
1623        let mut cols_fit = 0;
1624
1625        for (i, &col_width) in layout.col_widths.iter().enumerate().skip(horizontal_offset) {
1626            // Add separator width for all but first visible column
1627            let sep_width = if i > horizontal_offset {
1628                separator_width
1629            } else {
1630                0
1631            };
1632            let needed = col_width + sep_width;
1633
1634            // Reserve space for right scroll indicator if not the last column: " │ …" (separator_width + 1)
1635            let reserve_right = if i + 1 < layout.col_widths.len() {
1636                separator_width + 1
1637            } else {
1638                0
1639            };
1640
1641            if used_width + needed + reserve_right <= available {
1642                used_width += needed;
1643                cols_fit += 1;
1644            } else {
1645                break;
1646            }
1647        }
1648
1649        let has_more_right = horizontal_offset + cols_fit < layout.col_widths.len();
1650        (cols_fit.max(1), has_more_right) // Always show at least 1 column
1651    }
1652
1653    /// Update table layout's truncated_cols based on current terminal width
1654    /// Also updates the visible_columns_cache
1655    fn update_table_layout(&mut self) {
1656        let prefix_width = self.row_prefix_width();
1657        let term_width = self.term_width as usize;
1658        let horizontal_offset = self.horizontal_offset;
1659        let separator_width = self.table_column_separator_width();
1660
1661        if let Some(layout) = &mut self.table_layout {
1662            let result = Self::calculate_visible_columns_for_layout(
1663                layout,
1664                horizontal_offset,
1665                term_width,
1666                prefix_width,
1667                separator_width,
1668            );
1669            layout.truncated_cols = result.0;
1670            self.visible_columns_cache = Some(result);
1671        } else {
1672            self.visible_columns_cache = Some((0, false));
1673        }
1674    }
1675
1676    /// Header lines for fuzzy modes (prompt + filter + separator + table header)
1677    fn fuzzy_header_lines(&self) -> u16 {
1678        let mut header_lines: u16 = if self.prompt.is_some() { 2 } else { 1 };
1679        if self.config.show_separator {
1680            header_lines += 1;
1681        }
1682        if self.is_table_mode() {
1683            header_lines += 2;
1684        }
1685        header_lines
1686    }
1687
1688    /// Filter line row index for fuzzy modes
1689    fn fuzzy_filter_row(&self) -> u16 {
1690        if self.prompt.is_some() { 1 } else { 0 }
1691    }
1692
1693    /// Update terminal dimensions and recalculate visible height
1694    fn update_term_size(&mut self, width: u16, height: u16) {
1695        // Subtract 1 to avoid issues with writing to the very last terminal column
1696        let new_width = width.saturating_sub(1);
1697        let width_changed = self.term_width != new_width;
1698        self.term_width = new_width;
1699
1700        // Track width change for full redraw
1701        if width_changed {
1702            self.width_changed = true;
1703        }
1704
1705        // Regenerate separator line if width changed
1706        if width_changed && self.config.show_separator {
1707            self.generate_separator_line();
1708        }
1709
1710        // Update table layout if width changed
1711        if width_changed {
1712            self.update_table_layout();
1713        }
1714
1715        // Recalculate visible height
1716        let mut reserved: u16 = if self.prompt.is_some() { 1 } else { 0 };
1717        if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
1718            reserved += 1; // filter line
1719            if self.config.show_separator {
1720                reserved += 1; // separator line
1721            }
1722        }
1723        if self.is_table_mode() {
1724            reserved += 2; // table header + header separator
1725        }
1726        if self.config.show_footer {
1727            reserved += 1; // footer
1728        }
1729        self.visible_height = height.saturating_sub(reserved).max(1);
1730    }
1731
1732    fn run(&mut self) -> io::Result<InteractMode> {
1733        let mut stderr = io::stderr();
1734
1735        // Only hide cursor for non-fuzzy modes (fuzzy modes need visible cursor for text input)
1736        if self.mode != SelectMode::Fuzzy && self.mode != SelectMode::FuzzyMulti {
1737            execute!(stderr, Hide).map_err(io_context("hide terminal cursor"))?;
1738        }
1739        scopeguard::defer! {
1740            let _ = execute!(io::stderr(), Show);
1741        }
1742
1743        // Get initial terminal size and cache it
1744        let (term_width, term_height) =
1745            terminal::size().map_err(io_context("read terminal size"))?;
1746        self.update_term_size(term_width, term_height);
1747
1748        self.render(&mut stderr)
1749            .map_err(io_context("render input list"))?;
1750
1751        loop {
1752            let poll_interval = if self.stream_is_pending() {
1753                STREAM_POLL_INTERVAL
1754            } else {
1755                IDLE_POLL_INTERVAL
1756            };
1757            let has_event =
1758                event::poll(poll_interval).map_err(io_context("poll terminal event"))?;
1759
1760            if has_event {
1761                match event::read().map_err(io_context("read terminal event"))? {
1762                    Event::Key(key_event) => {
1763                        match self.handle_key(key_event) {
1764                            KeyAction::Continue => {}
1765                            KeyAction::Cancel => {
1766                                self.clear_display(&mut stderr)
1767                                    .map_err(io_context("clear input list after cancel"))?;
1768                                return Ok(match self.mode {
1769                                    SelectMode::Multi => InteractMode::Multi(None),
1770                                    _ => InteractMode::Single(None),
1771                                });
1772                            }
1773                            KeyAction::Confirm => {
1774                                self.clear_display(&mut stderr)
1775                                    .map_err(io_context("clear input list after confirm"))?;
1776                                return Ok(self.get_result());
1777                            }
1778                        }
1779                        self.render(&mut stderr)
1780                            .map_err(io_context("render input list after key event"))?;
1781                    }
1782                    Event::Resize(width, height) => {
1783                        // Clear old content first - terminal reflow may have corrupted positions
1784                        self.clear_display(&mut stderr)
1785                            .map_err(io_context("clear input list after resize"))?;
1786                        self.update_term_size(width, height);
1787                        // Force full redraw on resize
1788                        self.first_render = true;
1789                        self.render(&mut stderr)
1790                            .map_err(io_context("render input list after resize"))?;
1791                    }
1792                    _ => {}
1793                }
1794            } else if self.stream_is_pending() {
1795                self.render(&mut stderr)
1796                    .map_err(io_context("render input list after stream update"))?;
1797            }
1798        }
1799    }
1800
1801    fn handle_key(&mut self, key: KeyEvent) -> KeyAction {
1802        // Only handle key press and repeat events, not release
1803        // This is important on Windows where crossterm sends press, repeat, and release events
1804        // We need Repeat events for key repeat to work when holding down a key on Windows
1805        if key.kind == KeyEventKind::Release {
1806            return KeyAction::Continue;
1807        }
1808
1809        // Ctrl+C always cancels
1810        if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
1811            return KeyAction::Cancel;
1812        }
1813
1814        match self.mode {
1815            SelectMode::Single => self.handle_single_key(key),
1816            SelectMode::Multi => self.handle_multi_key(key),
1817            SelectMode::Fuzzy => self.handle_fuzzy_key(key),
1818            SelectMode::FuzzyMulti => self.handle_fuzzy_multi_key(key),
1819        }
1820    }
1821
1822    fn handle_single_key(&mut self, key: KeyEvent) -> KeyAction {
1823        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1824
1825        match key.code {
1826            KeyCode::Esc | KeyCode::Char('q') => KeyAction::Cancel,
1827            KeyCode::Enter => KeyAction::Confirm,
1828            KeyCode::Char('p' | 'P') if ctrl => {
1829                self.navigate_up();
1830                KeyAction::Continue
1831            }
1832            KeyCode::Up | KeyCode::Char('k') => {
1833                self.navigate_up();
1834                KeyAction::Continue
1835            }
1836            KeyCode::Char('n' | 'N') if ctrl => {
1837                self.navigate_down();
1838                KeyAction::Continue
1839            }
1840            KeyCode::Down | KeyCode::Char('j') => {
1841                self.navigate_down();
1842                KeyAction::Continue
1843            }
1844            KeyCode::Left | KeyCode::Char('h') => {
1845                self.scroll_columns_left();
1846                KeyAction::Continue
1847            }
1848            KeyCode::Right | KeyCode::Char('l') => {
1849                self.scroll_columns_right();
1850                KeyAction::Continue
1851            }
1852            KeyCode::Home => {
1853                self.navigate_home();
1854                KeyAction::Continue
1855            }
1856            KeyCode::End => {
1857                self.navigate_end();
1858                KeyAction::Continue
1859            }
1860            KeyCode::PageUp => {
1861                self.navigate_page_up();
1862                KeyAction::Continue
1863            }
1864            KeyCode::PageDown => {
1865                self.navigate_page_down();
1866                KeyAction::Continue
1867            }
1868            KeyCode::Tab => {
1869                self.navigate_down();
1870                KeyAction::Continue
1871            }
1872            KeyCode::BackTab => {
1873                self.navigate_up();
1874                KeyAction::Continue
1875            }
1876            _ => KeyAction::Continue,
1877        }
1878    }
1879
1880    fn handle_multi_key(&mut self, key: KeyEvent) -> KeyAction {
1881        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1882
1883        match key.code {
1884            KeyCode::Esc | KeyCode::Char('q') => KeyAction::Cancel,
1885            KeyCode::Enter => KeyAction::Confirm,
1886            // Ctrl+R: Refine list to only show selected items
1887            KeyCode::Char('r' | 'R') if ctrl => {
1888                self.refine_list();
1889                KeyAction::Continue
1890            }
1891            KeyCode::Char('p' | 'P') if ctrl => {
1892                self.navigate_up();
1893                KeyAction::Continue
1894            }
1895            KeyCode::Up | KeyCode::Char('k') => {
1896                self.navigate_up();
1897                KeyAction::Continue
1898            }
1899            KeyCode::Char('n' | 'N') if ctrl => {
1900                self.navigate_down();
1901                KeyAction::Continue
1902            }
1903            KeyCode::Down | KeyCode::Char('j') => {
1904                self.navigate_down();
1905                KeyAction::Continue
1906            }
1907            KeyCode::Left | KeyCode::Char('h') => {
1908                self.scroll_columns_left();
1909                KeyAction::Continue
1910            }
1911            KeyCode::Right | KeyCode::Char('l') => {
1912                self.scroll_columns_right();
1913                KeyAction::Continue
1914            }
1915            KeyCode::Char(' ') => {
1916                self.toggle_current();
1917                KeyAction::Continue
1918            }
1919            KeyCode::Char('a') => {
1920                self.toggle_all();
1921                KeyAction::Continue
1922            }
1923            KeyCode::Home => {
1924                self.navigate_home();
1925                KeyAction::Continue
1926            }
1927            KeyCode::End => {
1928                self.navigate_end();
1929                KeyAction::Continue
1930            }
1931            KeyCode::PageUp => {
1932                self.navigate_page_up();
1933                KeyAction::Continue
1934            }
1935            KeyCode::PageDown => {
1936                self.navigate_page_down();
1937                KeyAction::Continue
1938            }
1939            KeyCode::Tab => {
1940                self.toggle_current();
1941                self.navigate_down();
1942                KeyAction::Continue
1943            }
1944            KeyCode::BackTab => {
1945                self.navigate_up();
1946                self.toggle_current();
1947                KeyAction::Continue
1948            }
1949            _ => KeyAction::Continue,
1950        }
1951    }
1952
1953    fn handle_fuzzy_key(&mut self, key: KeyEvent) -> KeyAction {
1954        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1955        let alt = key.modifiers.contains(KeyModifiers::ALT);
1956        let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1957
1958        match key.code {
1959            KeyCode::Esc => KeyAction::Cancel,
1960            KeyCode::Enter => KeyAction::Confirm,
1961
1962            // Tab: navigate down (mirrors single/multi mode behavior)
1963            KeyCode::Tab | KeyCode::Char('\t') => {
1964                self.navigate_down();
1965                KeyAction::Continue
1966            }
1967            KeyCode::BackTab => {
1968                self.navigate_up();
1969                KeyAction::Continue
1970            }
1971
1972            // List navigation
1973            KeyCode::Up | KeyCode::Char('p' | 'P') if ctrl => {
1974                self.navigate_up();
1975                KeyAction::Continue
1976            }
1977            KeyCode::Down | KeyCode::Char('n' | 'N') if ctrl => {
1978                self.navigate_down();
1979                KeyAction::Continue
1980            }
1981            KeyCode::Up => {
1982                self.navigate_up();
1983                KeyAction::Continue
1984            }
1985            KeyCode::Down => {
1986                self.navigate_down();
1987                KeyAction::Continue
1988            }
1989
1990            // Horizontal scrolling for table mode (Shift+Left/Right)
1991            KeyCode::Left if shift => {
1992                self.scroll_columns_left();
1993                KeyAction::Continue
1994            }
1995            KeyCode::Right if shift => {
1996                self.scroll_columns_right();
1997                KeyAction::Continue
1998            }
1999
2000            // Readline: Cursor movement
2001            KeyCode::Char('a' | 'A') if ctrl => {
2002                // Ctrl-A: Move to beginning of line
2003                self.filter_cursor = 0;
2004                self.filter_text_changed = true;
2005                KeyAction::Continue
2006            }
2007            KeyCode::Char('e' | 'E') if ctrl => {
2008                // Ctrl-E: Move to end of line
2009                self.filter_cursor = self.filter_text.len();
2010                self.filter_text_changed = true;
2011                KeyAction::Continue
2012            }
2013            KeyCode::Char('b' | 'B') if ctrl => {
2014                // Ctrl-B: Move back one character
2015                self.move_filter_cursor_left();
2016                self.filter_text_changed = true;
2017                KeyAction::Continue
2018            }
2019            KeyCode::Char('f' | 'F') if ctrl => {
2020                // Ctrl-F: Move forward one character
2021                self.move_filter_cursor_right();
2022                self.filter_text_changed = true;
2023                KeyAction::Continue
2024            }
2025            KeyCode::Char('b' | 'B') if alt => {
2026                // Alt-B: Move back one word
2027                self.move_filter_cursor_word_left();
2028                self.filter_text_changed = true;
2029                KeyAction::Continue
2030            }
2031            KeyCode::Char('f' | 'F') if alt => {
2032                // Alt-F: Move forward one word
2033                self.move_filter_cursor_word_right();
2034                self.filter_text_changed = true;
2035                KeyAction::Continue
2036            }
2037            // Settings toggles
2038            KeyCode::Char('c' | 'C') if alt => {
2039                // Alt-C: Toggle case sensitivity
2040                self.toggle_case_sensitivity();
2041                KeyAction::Continue
2042            }
2043            KeyCode::Char('p' | 'P') if alt => {
2044                // Alt-P: Toggle per-column matching (table mode only)
2045                self.toggle_per_column();
2046                KeyAction::Continue
2047            }
2048            KeyCode::Left if ctrl || alt => {
2049                // Ctrl/Alt-Left: Move back one word
2050                self.move_filter_cursor_word_left();
2051                self.filter_text_changed = true;
2052                KeyAction::Continue
2053            }
2054            KeyCode::Right if ctrl || alt => {
2055                // Ctrl/Alt-Right: Move forward one word
2056                self.move_filter_cursor_word_right();
2057                self.filter_text_changed = true;
2058                KeyAction::Continue
2059            }
2060            KeyCode::Left => {
2061                self.move_filter_cursor_left();
2062                self.filter_text_changed = true;
2063                KeyAction::Continue
2064            }
2065            KeyCode::Right => {
2066                self.move_filter_cursor_right();
2067                self.filter_text_changed = true;
2068                KeyAction::Continue
2069            }
2070
2071            // Readline: Deletion
2072            KeyCode::Char('u' | 'U') if ctrl => {
2073                // Ctrl-U: Kill to beginning of line
2074                self.filter_text.drain(..self.filter_cursor);
2075                self.filter_cursor = 0;
2076                self.update_filter();
2077                KeyAction::Continue
2078            }
2079            KeyCode::Char('k' | 'K') if ctrl => {
2080                // Ctrl-K: Kill to end of line
2081                self.filter_text.truncate(self.filter_cursor);
2082                self.update_filter();
2083                KeyAction::Continue
2084            }
2085            KeyCode::Char('d' | 'D') if ctrl => {
2086                // Ctrl-D: Delete character at cursor
2087                if self.filter_cursor < self.filter_text.len() {
2088                    self.filter_text.remove(self.filter_cursor);
2089                    self.update_filter();
2090                }
2091                KeyAction::Continue
2092            }
2093            KeyCode::Delete => {
2094                // Delete: Delete character at cursor
2095                if self.filter_cursor < self.filter_text.len() {
2096                    self.filter_text.remove(self.filter_cursor);
2097                    self.update_filter();
2098                }
2099                KeyAction::Continue
2100            }
2101            KeyCode::Char('d' | 'D') if alt => {
2102                // Alt-D: Delete word forward
2103                self.delete_word_forwards();
2104                self.update_filter();
2105                KeyAction::Continue
2106            }
2107            // Ctrl-W or Ctrl-H (Ctrl-Backspace) to delete previous word
2108            KeyCode::Char('w' | 'W' | 'h' | 'H') if ctrl => {
2109                self.delete_word_backwards();
2110                self.update_filter();
2111                KeyAction::Continue
2112            }
2113            // Alt-Backspace: delete previous word
2114            KeyCode::Backspace if alt => {
2115                self.delete_word_backwards();
2116                self.update_filter();
2117                KeyAction::Continue
2118            }
2119            KeyCode::Backspace => {
2120                // Delete character before cursor (handle UTF-8)
2121                if self.filter_cursor > 0 {
2122                    // Find previous char boundary
2123                    let mut new_pos = self.filter_cursor - 1;
2124                    while new_pos > 0 && !self.filter_text.is_char_boundary(new_pos) {
2125                        new_pos -= 1;
2126                    }
2127                    self.filter_cursor = new_pos;
2128                    self.filter_text.remove(self.filter_cursor);
2129                    self.update_filter();
2130                }
2131                KeyAction::Continue
2132            }
2133            // Ctrl-T: Transpose characters
2134            KeyCode::Char('t' | 'T') if ctrl => {
2135                let old_text = self.filter_text.clone();
2136                self.transpose_chars();
2137                if self.filter_text != old_text {
2138                    self.update_filter();
2139                }
2140                KeyAction::Continue
2141            }
2142
2143            // Character input
2144            KeyCode::Char(c) => {
2145                self.filter_text.insert(self.filter_cursor, c);
2146                self.filter_cursor += c.len_utf8();
2147                self.update_filter();
2148                KeyAction::Continue
2149            }
2150
2151            // List navigation with Home/End/PageUp/PageDown
2152            KeyCode::Home => {
2153                self.navigate_home();
2154                KeyAction::Continue
2155            }
2156            KeyCode::End => {
2157                self.navigate_end();
2158                KeyAction::Continue
2159            }
2160            KeyCode::PageUp => {
2161                self.navigate_page_up();
2162                KeyAction::Continue
2163            }
2164            KeyCode::PageDown => {
2165                self.navigate_page_down();
2166                KeyAction::Continue
2167            }
2168            _ => KeyAction::Continue,
2169        }
2170    }
2171
2172    fn handle_fuzzy_multi_key(&mut self, key: KeyEvent) -> KeyAction {
2173        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
2174        let alt = key.modifiers.contains(KeyModifiers::ALT);
2175        let shift = key.modifiers.contains(KeyModifiers::SHIFT);
2176
2177        match key.code {
2178            KeyCode::Esc => KeyAction::Cancel,
2179            KeyCode::Enter => KeyAction::Confirm,
2180
2181            // Ctrl+R: Refine list to only show selected items
2182            KeyCode::Char('r' | 'R') if ctrl => {
2183                self.refine_list();
2184                KeyAction::Continue
2185            }
2186
2187            // Tab: Toggle selection of current item and move down
2188            // Note: Some terminals may report Tab as Char('\t')
2189            KeyCode::Tab | KeyCode::Char('\t') => {
2190                self.toggle_current_fuzzy();
2191                self.navigate_down();
2192                KeyAction::Continue
2193            }
2194
2195            // Shift-Tab: Toggle selection and move up
2196            KeyCode::BackTab => {
2197                self.navigate_up();
2198                self.toggle_current_fuzzy();
2199                KeyAction::Continue
2200            }
2201
2202            // List navigation
2203            KeyCode::Up | KeyCode::Char('p' | 'P') if ctrl => {
2204                self.navigate_up();
2205                KeyAction::Continue
2206            }
2207            KeyCode::Down | KeyCode::Char('n' | 'N') if ctrl => {
2208                self.navigate_down();
2209                KeyAction::Continue
2210            }
2211            KeyCode::Up => {
2212                self.navigate_up();
2213                KeyAction::Continue
2214            }
2215            KeyCode::Down => {
2216                self.navigate_down();
2217                KeyAction::Continue
2218            }
2219
2220            // Horizontal scrolling for table mode (Shift+Left/Right)
2221            KeyCode::Left if shift => {
2222                self.scroll_columns_left();
2223                KeyAction::Continue
2224            }
2225            KeyCode::Right if shift => {
2226                self.scroll_columns_right();
2227                KeyAction::Continue
2228            }
2229
2230            // Readline: Cursor movement
2231            KeyCode::Char('a' | 'A') if ctrl => {
2232                self.filter_cursor = 0;
2233                self.filter_text_changed = true;
2234                KeyAction::Continue
2235            }
2236            KeyCode::Char('e' | 'E') if ctrl => {
2237                self.filter_cursor = self.filter_text.len();
2238                self.filter_text_changed = true;
2239                KeyAction::Continue
2240            }
2241            KeyCode::Char('b' | 'B') if ctrl => {
2242                self.move_filter_cursor_left();
2243                self.filter_text_changed = true;
2244                KeyAction::Continue
2245            }
2246            KeyCode::Char('f' | 'F') if ctrl => {
2247                self.move_filter_cursor_right();
2248                self.filter_text_changed = true;
2249                KeyAction::Continue
2250            }
2251            KeyCode::Char('b' | 'B') if alt => {
2252                self.move_filter_cursor_word_left();
2253                self.filter_text_changed = true;
2254                KeyAction::Continue
2255            }
2256            KeyCode::Char('f' | 'F') if alt => {
2257                self.move_filter_cursor_word_right();
2258                self.filter_text_changed = true;
2259                KeyAction::Continue
2260            }
2261            // Settings toggles
2262            KeyCode::Char('c' | 'C') if alt => {
2263                // Alt-C: Toggle case sensitivity
2264                self.toggle_case_sensitivity();
2265                KeyAction::Continue
2266            }
2267            KeyCode::Char('p' | 'P') if alt => {
2268                // Alt-P: Toggle per-column matching (table mode only)
2269                self.toggle_per_column();
2270                KeyAction::Continue
2271            }
2272            KeyCode::Left if ctrl || alt => {
2273                self.move_filter_cursor_word_left();
2274                self.filter_text_changed = true;
2275                KeyAction::Continue
2276            }
2277            KeyCode::Right if ctrl || alt => {
2278                self.move_filter_cursor_word_right();
2279                self.filter_text_changed = true;
2280                KeyAction::Continue
2281            }
2282            KeyCode::Left => {
2283                self.move_filter_cursor_left();
2284                self.filter_text_changed = true;
2285                KeyAction::Continue
2286            }
2287            KeyCode::Right => {
2288                self.move_filter_cursor_right();
2289                self.filter_text_changed = true;
2290                KeyAction::Continue
2291            }
2292
2293            // Readline: Deletion
2294            KeyCode::Char('u' | 'U') if ctrl => {
2295                self.filter_text.drain(..self.filter_cursor);
2296                self.filter_cursor = 0;
2297                self.update_filter();
2298                KeyAction::Continue
2299            }
2300            KeyCode::Char('k' | 'K') if ctrl => {
2301                self.filter_text.truncate(self.filter_cursor);
2302                self.update_filter();
2303                KeyAction::Continue
2304            }
2305            KeyCode::Char('d' | 'D') if ctrl => {
2306                if self.filter_cursor < self.filter_text.len() {
2307                    self.filter_text.remove(self.filter_cursor);
2308                    self.update_filter();
2309                }
2310                KeyAction::Continue
2311            }
2312            KeyCode::Delete => {
2313                if self.filter_cursor < self.filter_text.len() {
2314                    self.filter_text.remove(self.filter_cursor);
2315                    self.update_filter();
2316                }
2317                KeyAction::Continue
2318            }
2319            KeyCode::Char('d' | 'D') if alt => {
2320                self.delete_word_forwards();
2321                self.update_filter();
2322                KeyAction::Continue
2323            }
2324            KeyCode::Char('w' | 'W' | 'h' | 'H') if ctrl => {
2325                self.delete_word_backwards();
2326                self.update_filter();
2327                KeyAction::Continue
2328            }
2329            KeyCode::Backspace if alt => {
2330                self.delete_word_backwards();
2331                self.update_filter();
2332                KeyAction::Continue
2333            }
2334            KeyCode::Backspace => {
2335                if self.filter_cursor > 0 {
2336                    let mut new_pos = self.filter_cursor - 1;
2337                    while new_pos > 0 && !self.filter_text.is_char_boundary(new_pos) {
2338                        new_pos -= 1;
2339                    }
2340                    self.filter_cursor = new_pos;
2341                    self.filter_text.remove(self.filter_cursor);
2342                    self.update_filter();
2343                }
2344                KeyAction::Continue
2345            }
2346            KeyCode::Char('t' | 'T') if ctrl => {
2347                let old_text = self.filter_text.clone();
2348                self.transpose_chars();
2349                if self.filter_text != old_text {
2350                    self.update_filter();
2351                }
2352                KeyAction::Continue
2353            }
2354
2355            // Alt-A: Toggle all filtered items in fuzzy multi mode
2356            KeyCode::Char('a' | 'A') if alt => {
2357                self.toggle_all_fuzzy();
2358                KeyAction::Continue
2359            }
2360
2361            // Character input
2362            KeyCode::Char(c) => {
2363                self.filter_text.insert(self.filter_cursor, c);
2364                self.filter_cursor += c.len_utf8();
2365                self.update_filter();
2366                KeyAction::Continue
2367            }
2368
2369            // List navigation with Home/End/PageUp/PageDown
2370            KeyCode::Home => {
2371                self.navigate_home();
2372                KeyAction::Continue
2373            }
2374            KeyCode::End => {
2375                self.navigate_end();
2376                KeyAction::Continue
2377            }
2378            KeyCode::PageUp => {
2379                self.navigate_page_up();
2380                KeyAction::Continue
2381            }
2382            KeyCode::PageDown => {
2383                self.navigate_page_down();
2384                KeyAction::Continue
2385            }
2386            _ => KeyAction::Continue,
2387        }
2388    }
2389
2390    /// Move cursor up with wrapping
2391    fn navigate_up(&mut self) {
2392        self.follow_stream_to_end = false;
2393        let list_len = self.current_list_len();
2394        if self.cursor > 0 {
2395            self.cursor -= 1;
2396            self.adjust_scroll_up();
2397        } else if list_len > 0 {
2398            self.maybe_load_more();
2399            let list_len = self.current_list_len();
2400            self.cursor = list_len.saturating_sub(1);
2401            self.adjust_scroll_down();
2402        }
2403    }
2404
2405    /// Move cursor down with wrapping
2406    fn navigate_down(&mut self) {
2407        self.follow_stream_to_end = false;
2408        self.maybe_load_more();
2409
2410        let list_len = self.current_list_len();
2411        if self.cursor + 1 < list_len {
2412            self.cursor += 1;
2413            self.adjust_scroll_down();
2414        } else {
2415            // If we still have a pending stream, attempt to load more and stay in place
2416            if self.stream_reader.is_some() {
2417                self.load_more_items(STREAM_LOAD_BATCH);
2418                let list_len = self.current_list_len();
2419                if self.cursor + 1 < list_len {
2420                    self.cursor += 1;
2421                    self.adjust_scroll_down();
2422                    return;
2423                }
2424            }
2425
2426            // Wrap to top
2427            self.cursor = 0;
2428            self.scroll_offset = 0;
2429        }
2430    }
2431
2432    fn adjust_scroll_down(&mut self) {
2433        let max_visible = self.scroll_offset + self.visible_height as usize;
2434        if self.cursor >= max_visible {
2435            self.scroll_offset = self.cursor - self.visible_height as usize + 1;
2436        }
2437    }
2438
2439    fn adjust_scroll_up(&mut self) {
2440        if self.cursor < self.scroll_offset {
2441            self.scroll_offset = self.cursor;
2442        }
2443    }
2444
2445    /// Get the current list length (filtered for fuzzy modes or refined multi, full for others)
2446    fn current_list_len(&self) -> usize {
2447        match self.mode {
2448            SelectMode::Fuzzy | SelectMode::FuzzyMulti => self.filtered_indices.len(),
2449            SelectMode::Multi if self.refined => self.filtered_indices.len(),
2450            _ => self.items.len(),
2451        }
2452    }
2453
2454    /// Navigate to the start of the list
2455    fn navigate_home(&mut self) {
2456        self.follow_stream_to_end = false;
2457        self.cursor = 0;
2458        self.scroll_offset = 0;
2459    }
2460
2461    /// Navigate to the end of the list
2462    fn navigate_end(&mut self) {
2463        self.follow_stream_to_end = true;
2464        self.load_more_items(STREAM_CHANNEL_CAPACITY);
2465        self.cursor = self.current_list_len().saturating_sub(1);
2466        self.adjust_scroll_down();
2467    }
2468
2469    /// Navigate page up: go to top of current page, or previous page if already at top
2470    fn navigate_page_up(&mut self) {
2471        self.follow_stream_to_end = false;
2472        let page_top = self.scroll_offset;
2473        if self.cursor == page_top {
2474            // Already at top of page, go to previous page
2475            self.cursor = self.cursor.saturating_sub(self.visible_height as usize);
2476            self.adjust_scroll_up();
2477        } else {
2478            // Go to top of current page
2479            self.cursor = page_top;
2480        }
2481    }
2482
2483    /// Navigate page down: go to bottom of current page, or next page if already at bottom
2484    fn navigate_page_down(&mut self) {
2485        self.follow_stream_to_end = false;
2486        self.maybe_load_more();
2487
2488        let list_len = self.current_list_len();
2489        let page_bottom =
2490            (self.scroll_offset + self.visible_height as usize - 1).min(list_len.saturating_sub(1));
2491        if self.cursor == page_bottom {
2492            // Already at bottom of page, go to next page
2493            self.cursor =
2494                (self.cursor + self.visible_height as usize).min(list_len.saturating_sub(1));
2495            self.adjust_scroll_down();
2496        } else {
2497            // Go to bottom of current page
2498            self.cursor = page_bottom;
2499        }
2500
2501        self.maybe_load_more();
2502    }
2503
2504    /// Scroll table columns left (show earlier columns)
2505    fn scroll_columns_left(&mut self) -> bool {
2506        if !self.is_table_mode() || self.horizontal_offset == 0 {
2507            return false;
2508        }
2509        self.horizontal_offset -= 1;
2510        self.horizontal_scroll_changed = true;
2511        self.update_table_layout();
2512        true
2513    }
2514
2515    /// Scroll table columns right (show later columns)
2516    fn scroll_columns_right(&mut self) -> bool {
2517        let Some(layout) = &self.table_layout else {
2518            return false;
2519        };
2520        let (cols_visible, has_more_right) = self.calculate_visible_columns();
2521        if !has_more_right {
2522            return false;
2523        }
2524        // Don't scroll past the last column
2525        if self.horizontal_offset + cols_visible >= layout.col_widths.len() {
2526            return false;
2527        }
2528        self.horizontal_offset += 1;
2529        self.horizontal_scroll_changed = true;
2530        self.update_table_layout();
2531        true
2532    }
2533
2534    fn toggle_current(&mut self) {
2535        // Guard against empty list when refined
2536        if self.refined && self.filtered_indices.is_empty() {
2537            return;
2538        }
2539        // Get the real item index (may differ from cursor when refined)
2540        let real_idx = if self.refined {
2541            self.filtered_indices[self.cursor]
2542        } else {
2543            self.cursor
2544        };
2545        self.toggle_index(real_idx);
2546    }
2547
2548    /// Toggle selection of a specific item by its real index
2549    fn toggle_index(&mut self, real_idx: usize) {
2550        if self.selected.contains(&real_idx) {
2551            self.selected.remove(&real_idx);
2552        } else {
2553            self.selected.insert(real_idx);
2554        }
2555        self.toggled_item = Some(self.cursor);
2556    }
2557
2558    /// Toggle selection of current item in fuzzy multi mode (uses filtered_indices)
2559    /// Returns true if an item was toggled, false if list was empty
2560    fn toggle_current_fuzzy(&mut self) -> bool {
2561        if self.filtered_indices.is_empty() {
2562            return false;
2563        }
2564        let real_idx = self.filtered_indices[self.cursor];
2565        self.toggle_index(real_idx);
2566        true
2567    }
2568
2569    fn toggle_all(&mut self) {
2570        // Check if all current items are selected
2571        let all_selected = if self.refined {
2572            self.filtered_indices
2573                .iter()
2574                .all(|i| self.selected.contains(i))
2575        } else {
2576            (0..self.items.len()).all(|i| self.selected.contains(&i))
2577        };
2578
2579        if all_selected {
2580            // Deselect all current items
2581            if self.refined {
2582                for i in &self.filtered_indices {
2583                    self.selected.remove(i);
2584                }
2585            } else {
2586                self.selected.clear();
2587            }
2588        } else {
2589            // Select all current items
2590            if self.refined {
2591                self.selected.extend(self.filtered_indices.iter().copied());
2592            } else {
2593                self.selected.extend(0..self.items.len());
2594            }
2595        }
2596        self.toggled_all = true;
2597    }
2598
2599    /// Toggle all items in fuzzy multi mode (only the currently filtered items)
2600    fn toggle_all_fuzzy(&mut self) {
2601        if self.filtered_indices.is_empty() {
2602            return;
2603        }
2604
2605        // Check if all filtered items are selected
2606        let all_selected = self
2607            .filtered_indices
2608            .iter()
2609            .all(|i| self.selected.contains(i));
2610
2611        if all_selected {
2612            // Deselect all filtered items
2613            for i in &self.filtered_indices {
2614                self.selected.remove(i);
2615            }
2616        } else {
2617            // Select all filtered items
2618            self.selected.extend(self.filtered_indices.iter().copied());
2619        }
2620        self.toggled_all = true;
2621    }
2622
2623    /// Refine the list to only show currently selected items
2624    /// This allows users to narrow down to their selections and continue selecting
2625    fn refine_list(&mut self) {
2626        if self.selected.is_empty() {
2627            return;
2628        }
2629
2630        // Set filtered_indices to sorted selected indices
2631        let mut indices: Vec<usize> = self.selected.iter().copied().collect();
2632        indices.sort();
2633
2634        // Store as base indices for filtering in FuzzyMulti mode
2635        // Clone once for both vectors instead of cloning refined_base_indices
2636        self.filtered_indices = indices.clone();
2637        self.refined_base_indices = indices;
2638
2639        // Reset cursor and scroll
2640        self.cursor = 0;
2641        self.scroll_offset = 0;
2642
2643        // Keep all items selected (don't clear selection)
2644        // User can deselect items they don't want
2645
2646        // Clear filter text in FuzzyMulti mode
2647        if self.mode == SelectMode::FuzzyMulti {
2648            self.filter_text.clear();
2649            self.filter_cursor = 0;
2650            self.last_filter_text.clear();
2651            self.force_full_filter = true;
2652            self.filter_text_changed = true;
2653        }
2654
2655        // Mark as refined (for Multi mode rendering)
2656        self.refined = true;
2657
2658        // Force full redraw
2659        self.first_render = true;
2660    }
2661
2662    // Filter cursor movement helpers
2663    fn move_filter_cursor_left(&mut self) {
2664        if self.filter_cursor > 0 {
2665            // Move back one character (handle UTF-8)
2666            let mut new_pos = self.filter_cursor - 1;
2667            while new_pos > 0 && !self.filter_text.is_char_boundary(new_pos) {
2668                new_pos -= 1;
2669            }
2670            self.filter_cursor = new_pos;
2671        }
2672    }
2673
2674    fn move_filter_cursor_right(&mut self) {
2675        if self.filter_cursor < self.filter_text.len() {
2676            // Move forward one character (handle UTF-8)
2677            let mut new_pos = self.filter_cursor + 1;
2678            while new_pos < self.filter_text.len() && !self.filter_text.is_char_boundary(new_pos) {
2679                new_pos += 1;
2680            }
2681            self.filter_cursor = new_pos;
2682        }
2683    }
2684
2685    fn move_filter_cursor_word_left(&mut self) {
2686        if self.filter_cursor == 0 {
2687            return;
2688        }
2689        let bytes = self.filter_text.as_bytes();
2690        let mut pos = self.filter_cursor;
2691        // Skip whitespace
2692        while pos > 0 && bytes[pos - 1].is_ascii_whitespace() {
2693            pos -= 1;
2694        }
2695        // Skip word characters
2696        while pos > 0 && !bytes[pos - 1].is_ascii_whitespace() {
2697            pos -= 1;
2698        }
2699        self.filter_cursor = pos;
2700    }
2701
2702    fn move_filter_cursor_word_right(&mut self) {
2703        let len = self.filter_text.len();
2704        if self.filter_cursor >= len {
2705            return;
2706        }
2707        let bytes = self.filter_text.as_bytes();
2708        let mut pos = self.filter_cursor;
2709        // Skip current word characters
2710        while pos < len && !bytes[pos].is_ascii_whitespace() {
2711            pos += 1;
2712        }
2713        // Skip whitespace
2714        while pos < len && bytes[pos].is_ascii_whitespace() {
2715            pos += 1;
2716        }
2717        self.filter_cursor = pos;
2718    }
2719
2720    fn delete_word_backwards(&mut self) {
2721        if self.filter_cursor == 0 {
2722            return;
2723        }
2724        let start = self.filter_cursor;
2725        // Skip whitespace
2726        while self.filter_cursor > 0
2727            && self.filter_text.as_bytes()[self.filter_cursor - 1].is_ascii_whitespace()
2728        {
2729            self.filter_cursor -= 1;
2730        }
2731        // Skip word characters
2732        while self.filter_cursor > 0
2733            && !self.filter_text.as_bytes()[self.filter_cursor - 1].is_ascii_whitespace()
2734        {
2735            self.filter_cursor -= 1;
2736        }
2737        self.filter_text.drain(self.filter_cursor..start);
2738    }
2739
2740    fn delete_word_forwards(&mut self) {
2741        let len = self.filter_text.len();
2742        if self.filter_cursor >= len {
2743            return;
2744        }
2745        let start = self.filter_cursor;
2746        let bytes = self.filter_text.as_bytes();
2747        let mut end = start;
2748        // Skip word characters
2749        while end < len && !bytes[end].is_ascii_whitespace() {
2750            end += 1;
2751        }
2752        // Skip whitespace
2753        while end < len && bytes[end].is_ascii_whitespace() {
2754            end += 1;
2755        }
2756        self.filter_text.drain(start..end);
2757    }
2758
2759    fn transpose_chars(&mut self) {
2760        // Ctrl-T: swap the two characters before the cursor
2761        // If at end of line, swap last two chars
2762        // If at position 1 or beyond with at least 2 chars, swap char before cursor with one before that
2763        let len = self.filter_text.len();
2764        if len < 2 {
2765            return;
2766        }
2767
2768        // If cursor is at start, nothing to transpose
2769        if self.filter_cursor == 0 {
2770            return;
2771        }
2772
2773        // If cursor is at end, transpose last two characters and keep cursor at end
2774        // Otherwise, transpose char at cursor-1 with char at cursor, then move cursor right
2775        let pos = if self.filter_cursor >= len {
2776            len - 1
2777        } else {
2778            self.filter_cursor
2779        };
2780
2781        if pos == 0 {
2782            return;
2783        }
2784
2785        // Only transpose if both positions are ASCII (single-byte) characters.
2786        // For multi-byte UTF-8 characters, transposition is more complex and skipped.
2787        if self.filter_text.is_char_boundary(pos - 1)
2788            && self.filter_text.is_char_boundary(pos)
2789            && pos < len
2790            && self.filter_text.is_char_boundary(pos + 1)
2791        {
2792            // Check both chars are single-byte ASCII
2793            let bytes = self.filter_text.as_bytes();
2794            if bytes[pos - 1].is_ascii() && bytes[pos].is_ascii() {
2795                // SAFETY: We verified both bytes are ASCII, so swapping them is safe
2796                let bytes = unsafe { self.filter_text.as_bytes_mut() };
2797                bytes.swap(pos - 1, pos);
2798
2799                // Move cursor right if not at end
2800                if self.filter_cursor < len {
2801                    self.filter_cursor += 1;
2802                }
2803            }
2804        }
2805    }
2806
2807    fn case_matching(&self) -> CaseMatching {
2808        match self.config.case_sensitivity {
2809            CaseSensitivity::Smart => CaseMatching::Smart,
2810            CaseSensitivity::CaseSensitive => CaseMatching::Respect,
2811            CaseSensitivity::CaseInsensitive => CaseMatching::Ignore,
2812        }
2813    }
2814
2815    fn fuzzy_atom(&self) -> Atom {
2816        Atom::new(
2817            &self.filter_text,
2818            self.case_matching(),
2819            Normalization::Smart,
2820            AtomKind::Fuzzy,
2821            false,
2822        )
2823    }
2824
2825    fn score_text(
2826        matcher: &mut NucleoMatcher,
2827        atom: &Atom,
2828        text: &str,
2829        buf: &mut Vec<char>,
2830    ) -> Option<u16> {
2831        atom.score(Utf32Str::new(text, buf), matcher)
2832    }
2833
2834    fn fuzzy_text_matches(&self, text: &str) -> bool {
2835        let atom = self.fuzzy_atom();
2836        let mut matcher = Self::make_matcher();
2837        let mut buf = Vec::new();
2838        Self::score_text(&mut matcher, &atom, text, &mut buf).is_some()
2839    }
2840
2841    fn fuzzy_match_indices(&self, text: &str) -> Option<Vec<usize>> {
2842        let atom = self.fuzzy_atom();
2843        let mut matcher = Self::make_matcher();
2844        let mut buf = Vec::new();
2845        let mut indices = Vec::new();
2846        atom.indices(Utf32Str::new(text, &mut buf), &mut matcher, &mut indices)?;
2847
2848        let mut indices = indices
2849            .into_iter()
2850            .map(usize::try_from)
2851            .collect::<Result<Vec<_>, _>>()
2852            .ok()?;
2853        indices.sort_unstable();
2854        indices.dedup();
2855        Some(indices)
2856    }
2857
2858    /// Score an item using per-column matching (best column wins)
2859    fn score_per_column(
2860        matcher: &mut NucleoMatcher,
2861        atom: &Atom,
2862        item: &SelectItem,
2863        buf: &mut Vec<char>,
2864    ) -> Option<u16> {
2865        item.cells.as_ref().and_then(|cells| {
2866            cells
2867                .iter()
2868                .filter_map(|(cell_text, _)| Self::score_text(matcher, atom, cell_text, buf))
2869                .max()
2870        })
2871    }
2872
2873    /// Score an item - uses per-column matching if enabled and in table mode
2874    fn score_item(
2875        matcher: &mut NucleoMatcher,
2876        atom: &Atom,
2877        per_column: bool,
2878        item: &SelectItem,
2879        buf: &mut Vec<char>,
2880    ) -> Option<u16> {
2881        if per_column && item.cells.is_some() {
2882            Self::score_per_column(matcher, atom, item, buf)
2883        } else {
2884            Self::score_text(matcher, atom, &item.name, buf)
2885        }
2886    }
2887
2888    fn should_yield_filter(start: nu_utils::time::Instant, checked: usize) -> bool {
2889        checked > 0
2890            && checked.is_multiple_of(FUZZY_FILTER_INTERRUPT_CHECK_INTERVAL)
2891            && start.elapsed() >= FUZZY_FILTER_MIN_INTERRUPT_TIME
2892            && event::poll(Duration::ZERO).is_ok_and(|has_event| has_event)
2893    }
2894
2895    fn score_filter_candidates<I>(
2896        &mut self,
2897        candidates: I,
2898        atom: &Atom,
2899        start: nu_utils::time::Instant,
2900    ) -> Option<Vec<(usize, u16)>>
2901    where
2902        I: Iterator<Item = usize>,
2903    {
2904        let mut scored = Vec::new();
2905        let mut buf = Vec::new();
2906        for (checked, i) in candidates.enumerate() {
2907            if Self::should_yield_filter(start, checked) {
2908                return None;
2909            }
2910
2911            if let Some(score) = Self::score_item(
2912                &mut self.matcher,
2913                atom,
2914                self.per_column,
2915                &self.items[i],
2916                &mut buf,
2917            ) {
2918                scored.push((i, score));
2919            }
2920        }
2921
2922        Some(scored)
2923    }
2924
2925    fn update_filter(&mut self) {
2926        let old_indices = std::mem::take(&mut self.filtered_indices);
2927        let start = nu_utils::time::Instant::now();
2928
2929        // Determine whether to filter from refined subset or all items
2930        let use_refined = self.refined && !self.refined_base_indices.is_empty();
2931
2932        if self.filter_text.is_empty() {
2933            // When empty, copy the base indices
2934            self.filtered_indices = if use_refined {
2935                self.refined_base_indices.clone()
2936            } else {
2937                (0..self.items.len()).collect()
2938            };
2939            self.last_filter_text.clear();
2940            self.force_full_filter = false;
2941        } else {
2942            let atom = self.fuzzy_atom();
2943            let can_reuse_previous = !self.force_full_filter
2944                && !self.last_filter_text.is_empty()
2945                && self.filter_text.starts_with(&self.last_filter_text);
2946
2947            let mut scored = if can_reuse_previous {
2948                self.score_filter_candidates(old_indices.iter().copied(), &atom, start)
2949            } else if use_refined {
2950                let refined_base_indices = self.refined_base_indices.clone();
2951                self.score_filter_candidates(refined_base_indices.into_iter(), &atom, start)
2952            } else {
2953                self.score_filter_candidates(0..self.items.len(), &atom, start)
2954            };
2955
2956            let Some(mut scored) = scored.take() else {
2957                self.filtered_indices = old_indices;
2958                self.results_changed = false;
2959                self.filter_text_changed = true;
2960                return;
2961            };
2962            // Sort by score descending
2963            scored.sort_by_key(|entry| std::cmp::Reverse(entry.1));
2964            self.filtered_indices = scored.into_iter().map(|(i, _)| i).collect();
2965            self.last_filter_text = self.filter_text.clone();
2966            self.force_full_filter = false;
2967        }
2968
2969        // Check if results actually changed
2970        self.results_changed = old_indices != self.filtered_indices;
2971        self.filter_text_changed = true;
2972
2973        // Only reset cursor/scroll if results changed
2974        if self.results_changed {
2975            self.cursor = 0;
2976            self.scroll_offset = 0;
2977        }
2978
2979        // In table mode, auto-scroll horizontally to show the first column with matches
2980        if self.is_table_mode() && !self.filter_text.is_empty() && !self.filtered_indices.is_empty()
2981        {
2982            self.auto_scroll_to_match_column();
2983        }
2984    }
2985
2986    /// In table mode, scroll horizontally to ensure the first column with matches is visible
2987    fn auto_scroll_to_match_column(&mut self) {
2988        let Some(layout) = &self.table_layout else {
2989            return;
2990        };
2991
2992        // Look at the top result to find which column has the best match
2993        let first_idx = self.filtered_indices[0];
2994        let item = &self.items[first_idx];
2995        let Some(cells) = &item.cells else {
2996            return;
2997        };
2998
2999        // Find the first column (leftmost) that has a match
3000        let mut first_match_col: Option<usize> = None;
3001        for (col_idx, (cell_text, _)) in cells.iter().enumerate() {
3002            if self.per_column {
3003                // Per-column mode: check each cell individually
3004                if self.fuzzy_text_matches(cell_text) {
3005                    first_match_col = Some(col_idx);
3006                    break;
3007                }
3008            } else {
3009                // Standard mode: check if this cell's portion of item.name has matches
3010                // Calculate the character offset for this cell in the concatenated name
3011                let cell_start: usize = cells[..col_idx]
3012                    .iter()
3013                    .map(|(s, _)| s.chars().count() + 1) // +1 for space separator
3014                    .sum();
3015                let cell_char_count = cell_text.chars().count();
3016
3017                if let Some(indices) = self.fuzzy_match_indices(&item.name) {
3018                    // Check if any match indices fall within this cell
3019                    if indices
3020                        .iter()
3021                        .any(|&idx| idx >= cell_start && idx < cell_start + cell_char_count)
3022                    {
3023                        first_match_col = Some(col_idx);
3024                        break;
3025                    }
3026                }
3027            }
3028        }
3029
3030        // If we found a matching column, ensure it's visible
3031        if let Some(match_col) = first_match_col {
3032            let (cols_visible, _) = self.calculate_visible_columns();
3033            let visible_start = self.horizontal_offset;
3034            let visible_end = self.horizontal_offset + cols_visible;
3035
3036            if match_col < visible_start {
3037                // Match is to the left, scroll left
3038                self.horizontal_offset = match_col;
3039                self.horizontal_scroll_changed = true;
3040                self.update_table_layout();
3041            } else if match_col >= visible_end {
3042                // Match is to the right, scroll right
3043                // Set offset so match_col is the first visible column
3044                self.horizontal_offset = match_col;
3045                // But don't scroll past what's possible
3046                let max_offset = layout.col_widths.len().saturating_sub(1);
3047                self.horizontal_offset = self.horizontal_offset.min(max_offset);
3048                self.horizontal_scroll_changed = true;
3049                self.update_table_layout();
3050            }
3051        }
3052    }
3053
3054    fn get_result(&self) -> InteractMode {
3055        match self.mode {
3056            SelectMode::Single => InteractMode::Single(Some(self.cursor)),
3057            SelectMode::Multi => {
3058                let mut indices: Vec<usize> = self.selected.iter().copied().collect();
3059                indices.sort();
3060                InteractMode::Multi(Some(indices))
3061            }
3062            SelectMode::Fuzzy => {
3063                if self.filtered_indices.is_empty() {
3064                    InteractMode::Single(None)
3065                } else {
3066                    InteractMode::Single(Some(self.filtered_indices[self.cursor]))
3067                }
3068            }
3069            SelectMode::FuzzyMulti => {
3070                // Return all selected items regardless of current filter
3071                // This allows selecting items across multiple filter searches
3072                let mut indices: Vec<usize> = self.selected.iter().copied().collect();
3073                indices.sort();
3074                InteractMode::Multi(Some(indices))
3075            }
3076        }
3077    }
3078
3079    /// Check if we can do a toggle-only update in multi mode
3080    /// (just toggled a single visible item, no cursor movement)
3081    fn can_do_multi_toggle_only_update(&self) -> bool {
3082        if self.first_render || self.width_changed || self.mode != SelectMode::Multi {
3083            return false;
3084        }
3085        if self.table_layout_changed {
3086            return false;
3087        }
3088        // If the cursor also moved (e.g. Tab toggles and navigates), a full redraw
3089        // is needed so the ">" indicator follows the cursor.
3090        if self.cursor != self.prev_cursor {
3091            return false;
3092        }
3093        if let Some(toggled) = self.toggled_item {
3094            // Check if toggled item is visible
3095            let visible_start = self.scroll_offset;
3096            let visible_end = self.scroll_offset + self.visible_height as usize;
3097            toggled >= visible_start && toggled < visible_end
3098        } else {
3099            false
3100        }
3101    }
3102
3103    /// Check if we can do a toggle+move update in fuzzy multi mode
3104    /// (toggled an item and moved cursor, both visible, no scroll change)
3105    fn can_do_fuzzy_multi_toggle_update(&self) -> bool {
3106        if self.first_render || self.width_changed || self.mode != SelectMode::FuzzyMulti {
3107            return false;
3108        }
3109        if self.table_layout_changed {
3110            return false;
3111        }
3112        if self.scroll_offset != self.prev_scroll_offset {
3113            return false; // Scrolled, need full redraw
3114        }
3115        if self.filter_text_changed || self.results_changed {
3116            return false; // Filter changed, need full redraw
3117        }
3118        if let Some(toggled) = self.toggled_item {
3119            // Check if both toggled item and new cursor are visible
3120            let visible_start = self.scroll_offset;
3121            let visible_end = self.scroll_offset + self.visible_height as usize;
3122            let toggled_visible = toggled >= visible_start && toggled < visible_end;
3123            let cursor_visible = self.cursor >= visible_start && self.cursor < visible_end;
3124            toggled_visible && cursor_visible
3125        } else {
3126            false
3127        }
3128    }
3129
3130    /// Check if we can do a toggle-all update in fuzzy multi mode
3131    /// (toggled all filtered items with Alt+A)
3132    fn can_do_fuzzy_multi_toggle_all_update(&self) -> bool {
3133        !self.first_render
3134            && !self.width_changed
3135            && self.mode == SelectMode::FuzzyMulti
3136            && self.toggled_all
3137            && !self.filter_text_changed
3138            && !self.results_changed
3139            && self.scroll_offset == self.prev_scroll_offset
3140            && !self.horizontal_scroll_changed
3141            && !self.table_layout_changed
3142    }
3143
3144    /// Check if we can do a toggle-all update in multi mode
3145    /// (toggled all items with 'a' key)
3146    fn can_do_multi_toggle_all_update(&self) -> bool {
3147        !self.first_render
3148            && !self.width_changed
3149            && self.mode == SelectMode::Multi
3150            && self.toggled_all
3151            && !self.table_layout_changed
3152    }
3153
3154    /// FuzzyMulti mode: update toggled row and new cursor row
3155    fn render_fuzzy_multi_toggle_update(&mut self, stderr: &mut impl Write) -> io::Result<()> {
3156        let toggled = self.toggled_item.expect("toggled_item must be Some");
3157        execute!(stderr, BeginSynchronizedUpdate)?;
3158
3159        // Calculate header lines (prompt + filter + separator + table header)
3160        let header_lines = self.fuzzy_header_lines();
3161
3162        let toggled_display_row = (toggled - self.scroll_offset) as u16;
3163        let cursor_display_row = (self.cursor - self.scroll_offset) as u16;
3164
3165        let toggled_item_row = header_lines + toggled_display_row;
3166        let cursor_item_row = header_lines + cursor_display_row;
3167
3168        // We're at the filter line
3169        let filter_row = self.fuzzy_filter_row();
3170
3171        // Move to toggled row and redraw it (checkbox changed, marker removed)
3172        let down_to_toggled = toggled_item_row.saturating_sub(filter_row);
3173        execute!(stderr, MoveDown(down_to_toggled), MoveToColumn(0))?;
3174
3175        // Redraw toggled row (now without marker, checkbox state changed)
3176        let toggled_real_idx = self.filtered_indices[toggled];
3177        let toggled_item = &self.items[toggled_real_idx];
3178        let toggled_checked = self.selected.contains(&toggled_real_idx);
3179        if self.is_table_mode() {
3180            self.render_table_row_fuzzy_multi(stderr, toggled_item, toggled_checked, false)?;
3181        } else {
3182            self.render_fuzzy_multi_item_inline(
3183                stderr,
3184                &toggled_item.name,
3185                toggled_checked,
3186                false,
3187            )?;
3188        }
3189
3190        // Move to cursor row and redraw it (marker added)
3191        if cursor_item_row > toggled_item_row {
3192            let lines_down = cursor_item_row - toggled_item_row;
3193            execute!(stderr, MoveDown(lines_down), MoveToColumn(0))?;
3194        } else if cursor_item_row < toggled_item_row {
3195            let lines_up = toggled_item_row - cursor_item_row;
3196            execute!(stderr, MoveUp(lines_up), MoveToColumn(0))?;
3197        }
3198
3199        let cursor_real_idx = self.filtered_indices[self.cursor];
3200        let cursor_item = &self.items[cursor_real_idx];
3201        let cursor_checked = self.selected.contains(&cursor_real_idx);
3202        if self.is_table_mode() {
3203            self.render_table_row_fuzzy_multi(stderr, cursor_item, cursor_checked, true)?;
3204        } else {
3205            self.render_fuzzy_multi_item_inline(stderr, &cursor_item.name, cursor_checked, true)?;
3206        }
3207
3208        // Update footer to reflect new selection count
3209        if self.has_footer() {
3210            // Calculate footer row position
3211            let total_count = self.current_list_len();
3212            let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
3213            let visible_count = (end - self.scroll_offset) as u16;
3214            let footer_row = header_lines + visible_count;
3215
3216            // Move from cursor row to footer
3217            let down_to_footer = footer_row.saturating_sub(cursor_item_row);
3218            execute!(stderr, MoveDown(down_to_footer))?;
3219
3220            // Update footer
3221            self.render_footer_inline(stderr)?;
3222
3223            // Move back to filter line
3224            let up_to_filter = footer_row.saturating_sub(filter_row);
3225            execute!(stderr, MoveUp(up_to_filter))?;
3226        } else {
3227            // Move back to filter line
3228            let up_to_filter = cursor_item_row.saturating_sub(filter_row);
3229            execute!(stderr, MoveUp(up_to_filter))?;
3230        }
3231
3232        // Position cursor within filter text
3233        self.position_fuzzy_cursor(stderr)?;
3234
3235        // Update state
3236        self.prev_cursor = self.cursor;
3237        self.toggled_item = None;
3238
3239        execute!(stderr, EndSynchronizedUpdate)?;
3240        stderr.flush()
3241    }
3242
3243    /// Multi mode: only update the checkbox for the toggled item
3244    fn render_multi_toggle_only(&mut self, stderr: &mut impl Write) -> io::Result<()> {
3245        let toggled = self.toggled_item.expect("toggled_item must be Some");
3246        execute!(stderr, BeginSynchronizedUpdate)?;
3247
3248        let mut header_lines: u16 = if self.prompt.is_some() { 1 } else { 0 };
3249        if self.is_table_mode() {
3250            header_lines += 2; // table header + header separator line
3251        }
3252
3253        // Calculate display position of toggled item relative to scroll
3254        let display_row = (toggled - self.scroll_offset) as u16;
3255
3256        // Current position is at end of rendered content
3257        let items_rendered = self.rendered_lines - header_lines as usize;
3258
3259        // Move to the toggled row
3260        // Cursor is at end of last content line, so subtract 1 from items_rendered
3261        let lines_up = (items_rendered as u16)
3262            .saturating_sub(1)
3263            .saturating_sub(display_row);
3264        execute!(stderr, MoveUp(lines_up))?;
3265
3266        // Move to checkbox column (after "> " or "  ")
3267        execute!(stderr, MoveToColumn(2))?;
3268
3269        // Write new checkbox state
3270        let checkbox = if self.selected.contains(&toggled) {
3271            "[x]"
3272        } else {
3273            "[ ]"
3274        };
3275        execute!(stderr, Print(checkbox))?;
3276
3277        // Move back to end position (footer line if shown, else last item line)
3278        execute!(stderr, MoveDown(lines_up))?;
3279
3280        // Update footer to reflect new selection count
3281        if self.has_footer() {
3282            self.render_footer_inline(stderr)?;
3283        }
3284
3285        // Reset toggle tracking
3286        self.toggled_item = None;
3287
3288        execute!(stderr, EndSynchronizedUpdate)?;
3289        stderr.flush()
3290    }
3291
3292    /// Multi mode: update all visible checkboxes (toggle all with 'a')
3293    fn render_multi_toggle_all(&mut self, stderr: &mut impl Write) -> io::Result<()> {
3294        execute!(stderr, BeginSynchronizedUpdate)?;
3295
3296        let mut header_lines: u16 = if self.prompt.is_some() { 1 } else { 0 };
3297        if self.is_table_mode() {
3298            header_lines += 2; // table header + header separator line
3299        }
3300
3301        // Current position is at end of rendered content
3302        let items_rendered = self.rendered_lines - header_lines as usize;
3303
3304        // Calculate visible range
3305        let visible_end = (self.scroll_offset + self.visible_height as usize).min(self.items.len());
3306        let visible_count = visible_end - self.scroll_offset;
3307
3308        // Move to first item row
3309        // Cursor is at end of last content line, so subtract 1 to get to first item
3310        execute!(stderr, MoveUp((items_rendered as u16).saturating_sub(1)))?;
3311
3312        // Update each visible item's checkbox
3313        for i in 0..visible_count {
3314            let item_idx = self.scroll_offset + i;
3315            let checkbox = if self.selected.contains(&item_idx) {
3316                "[x]"
3317            } else {
3318                "[ ]"
3319            };
3320            // Move to checkbox column and update
3321            execute!(stderr, MoveToColumn(2), Print(checkbox))?;
3322            if i + 1 < visible_count {
3323                execute!(stderr, MoveDown(1))?;
3324            }
3325        }
3326
3327        // Move back to end position (footer line if shown, else last item line)
3328        let remaining = items_rendered as u16 - visible_count as u16;
3329        if remaining > 0 {
3330            execute!(stderr, MoveDown(remaining))?;
3331        }
3332
3333        // Update footer to reflect new selection count
3334        if self.has_footer() {
3335            self.render_footer_inline(stderr)?;
3336        }
3337
3338        // Reset toggle tracking
3339        self.toggled_all = false;
3340
3341        execute!(stderr, EndSynchronizedUpdate)?;
3342        stderr.flush()
3343    }
3344
3345    /// FuzzyMulti mode: update all visible rows (toggle all with Alt+A)
3346    fn render_fuzzy_multi_toggle_all_update(&mut self, stderr: &mut impl Write) -> io::Result<()> {
3347        execute!(stderr, BeginSynchronizedUpdate)?;
3348
3349        // Calculate header lines (prompt + filter + separator + table header)
3350        let header_lines = self.fuzzy_header_lines();
3351
3352        let total_count = self.current_list_len();
3353        let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
3354        let visible_count = end.saturating_sub(self.scroll_offset);
3355
3356        // We're at the filter line
3357        let filter_row = self.fuzzy_filter_row();
3358
3359        // Move to first item row
3360        let down_to_first = header_lines.saturating_sub(filter_row);
3361        execute!(stderr, MoveDown(down_to_first), MoveToColumn(0))?;
3362
3363        for (i, idx) in (self.scroll_offset..end).enumerate() {
3364            let real_idx = self.filtered_indices[idx];
3365            let item = &self.items[real_idx];
3366            let checked = self.selected.contains(&real_idx);
3367            let active = idx == self.cursor;
3368
3369            if self.is_table_mode() {
3370                self.render_table_row_fuzzy_multi(stderr, item, checked, active)?;
3371            } else {
3372                self.render_fuzzy_multi_item_inline(stderr, &item.name, checked, active)?;
3373            }
3374
3375            if i + 1 < visible_count {
3376                execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3377            }
3378        }
3379
3380        // Move to footer (if present) and update it
3381        if self.has_footer() {
3382            let footer_row = header_lines + visible_count as u16;
3383            let last_item_row = header_lines + visible_count.saturating_sub(1) as u16;
3384            let down_to_footer = footer_row.saturating_sub(last_item_row);
3385            execute!(stderr, MoveDown(down_to_footer))?;
3386            self.render_footer_inline(stderr)?;
3387            let up_to_filter = footer_row.saturating_sub(filter_row);
3388            execute!(stderr, MoveUp(up_to_filter))?;
3389        } else {
3390            let up_to_filter =
3391                (header_lines + visible_count.saturating_sub(1) as u16).saturating_sub(filter_row);
3392            execute!(stderr, MoveUp(up_to_filter))?;
3393        }
3394
3395        // Position cursor within filter text
3396        self.position_fuzzy_cursor(stderr)?;
3397
3398        // Reset toggle tracking
3399        self.toggled_all = false;
3400
3401        execute!(stderr, EndSynchronizedUpdate)?;
3402        stderr.flush()
3403    }
3404
3405    #[allow(clippy::collapsible_if)]
3406    fn render(&mut self, stderr: &mut impl Write) -> io::Result<()> {
3407        // Keep streamed rows live-updating even when the user is not scrolling. This only drains
3408        // values already delivered by the background reader, so rendering stays responsive for
3409        // slow or infinite inputs.
3410        let loaded_stream_items = self.load_more_items_for(STREAM_DRAIN_TIME_BUDGET);
3411        if loaded_stream_items && self.follow_stream_to_end {
3412            self.cursor = self.current_list_len().saturating_sub(1);
3413            self.adjust_scroll_down();
3414        }
3415        self.update_stream_footer();
3416
3417        // Check for fuzzy multi mode toggle-all optimization
3418        if self.can_do_fuzzy_multi_toggle_all_update() {
3419            return self.render_fuzzy_multi_toggle_all_update(stderr);
3420        }
3421
3422        // Check for multi mode toggle-all optimization
3423        if self.can_do_multi_toggle_all_update() {
3424            return self.render_multi_toggle_all(stderr);
3425        }
3426
3427        // Check for multi mode toggle-only optimization
3428        if self.can_do_multi_toggle_only_update() {
3429            return self.render_multi_toggle_only(stderr);
3430        }
3431
3432        // Check for fuzzy multi mode toggle+move optimization
3433        if self.can_do_fuzzy_multi_toggle_update() {
3434            return self.render_fuzzy_multi_toggle_update(stderr);
3435        }
3436
3437        // The old cursor-only navigation optimizations were removed because
3438        // they were brittle and caused wrapping bugs.  We now always perform a
3439        // full redraw for simple cursor moves; other optimizations (toggle
3440        // updates) are still available above.
3441
3442        // If nothing changed (e.g., PageDown at bottom of list), skip render entirely
3443        if !self.first_render
3444            && !self.width_changed
3445            && self.cursor == self.prev_cursor
3446            && self.scroll_offset == self.prev_scroll_offset
3447            && !loaded_stream_items
3448            && !self.results_changed
3449            && !self.filter_text_changed
3450            && !self.horizontal_scroll_changed
3451            && !self.table_layout_changed
3452            && !self.settings_changed
3453            && !self.toggled_all
3454        {
3455            return Ok(());
3456        }
3457
3458        execute!(stderr, BeginSynchronizedUpdate)?;
3459
3460        // Calculate how many lines we'll render
3461        let total_count = self.current_list_len();
3462        let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
3463        // Show footer in fuzzy modes (for settings), multi modes (for selection count), or when scrolling is needed
3464        let has_scroll_indicator = self.has_footer();
3465        let items_to_render = end - self.scroll_offset;
3466
3467        // Calculate total lines needed for this render
3468        let mut lines_needed: usize = 0;
3469        if self.prompt.is_some() {
3470            lines_needed += 1;
3471        }
3472        if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
3473            lines_needed += 1; // filter line
3474            if self.config.show_separator {
3475                lines_needed += 1;
3476            }
3477        }
3478        if self.is_table_mode() {
3479            lines_needed += 2; // table header + header separator
3480        }
3481        lines_needed += items_to_render;
3482        if has_scroll_indicator {
3483            lines_needed += 1;
3484        }
3485
3486        // On first render, claim vertical space by printing newlines (causes scroll if needed)
3487        if self.first_render && lines_needed > 1 {
3488            for _ in 0..(lines_needed - 1) {
3489                execute!(stderr, Print("\n"))?;
3490            }
3491            execute!(stderr, MoveUp((lines_needed - 1) as u16))?;
3492        }
3493
3494        // In fuzzy mode, cursor may be at filter line; move to last content line first
3495        if self.fuzzy_cursor_offset > 0 {
3496            execute!(stderr, MoveDown(self.fuzzy_cursor_offset as u16))?;
3497            self.fuzzy_cursor_offset = 0;
3498        }
3499
3500        // If streaming added enough rows to grow the rendered area, claim the extra lines before
3501        // moving back to the top. Otherwise the terminal may scroll underneath the existing
3502        // header/footer and leave stale rows on screen.
3503        if !self.first_render && lines_needed > self.rendered_lines {
3504            let lines_to_add = lines_needed - self.rendered_lines;
3505            for _ in 0..lines_to_add {
3506                execute!(stderr, Print("\n"))?;
3507            }
3508            execute!(stderr, MoveUp(lines_to_add as u16))?;
3509        }
3510
3511        // Move to start of our render area (first line, column 0)
3512        // Cursor is on last content line, move up to first line
3513        if self.rendered_lines > 1 {
3514            execute!(stderr, MoveUp((self.rendered_lines - 1) as u16))?;
3515        }
3516        execute!(stderr, MoveToColumn(0))?;
3517
3518        let mut lines_rendered: usize = 0;
3519
3520        // Render prompt (only on first render, it doesn't change)
3521        if self.first_render {
3522            if let Some(prompt) = self.prompt {
3523                execute!(stderr, Print(prompt), Clear(ClearType::UntilNewLine))?;
3524            }
3525        }
3526        if self.prompt.is_some() {
3527            lines_rendered += 1;
3528            if lines_rendered < lines_needed {
3529                execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3530            }
3531        }
3532
3533        // Render filter line for fuzzy modes
3534        if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
3535            execute!(
3536                stderr,
3537                Print(self.prompt_marker()),
3538                Print(&self.filter_text),
3539                Clear(ClearType::UntilNewLine),
3540            )?;
3541            lines_rendered += 1;
3542            if lines_rendered < lines_needed {
3543                execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3544            }
3545
3546            // Render separator line
3547            if self.config.show_separator {
3548                execute!(
3549                    stderr,
3550                    Print(self.config.separator.paint(&self.separator_line)),
3551                    Clear(ClearType::UntilNewLine),
3552                )?;
3553                lines_rendered += 1;
3554                if lines_rendered < lines_needed {
3555                    execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3556                }
3557            }
3558        }
3559
3560        // Render table header and separator if in table mode.
3561        // Redraw when column positioning or widths changed.
3562        if self.is_table_mode() {
3563            let need_header_redraw =
3564                self.first_render || self.horizontal_scroll_changed || self.table_layout_changed;
3565            if need_header_redraw {
3566                self.render_table_header(stderr)?;
3567            }
3568            lines_rendered += 1;
3569            if lines_rendered < lines_needed {
3570                execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3571            }
3572            if need_header_redraw {
3573                self.render_table_header_separator(stderr)?;
3574            }
3575            lines_rendered += 1;
3576            if lines_rendered < lines_needed {
3577                execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3578            }
3579        }
3580
3581        // Render items
3582        for idx in self.scroll_offset..end {
3583            let is_active = idx == self.cursor;
3584            let is_last_line = lines_rendered + 1 == lines_needed;
3585
3586            if self.is_table_mode() {
3587                // Table mode rendering
3588                match self.mode {
3589                    SelectMode::Single => {
3590                        let item = &self.items[idx];
3591                        self.render_table_row_single(stderr, item, is_active)?;
3592                    }
3593                    SelectMode::Multi => {
3594                        let real_idx = if self.refined {
3595                            self.filtered_indices[idx]
3596                        } else {
3597                            idx
3598                        };
3599                        let item = &self.items[real_idx];
3600                        let is_checked = self.selected.contains(&real_idx);
3601                        self.render_table_row_multi(stderr, item, is_checked, is_active)?;
3602                    }
3603                    SelectMode::Fuzzy => {
3604                        let real_idx = self.filtered_indices[idx];
3605                        let item = &self.items[real_idx];
3606                        self.render_table_row_fuzzy(stderr, item, is_active)?;
3607                    }
3608                    SelectMode::FuzzyMulti => {
3609                        let real_idx = self.filtered_indices[idx];
3610                        let item = &self.items[real_idx];
3611                        let is_checked = self.selected.contains(&real_idx);
3612                        self.render_table_row_fuzzy_multi(stderr, item, is_checked, is_active)?;
3613                    }
3614                }
3615            } else {
3616                // Single-line mode rendering
3617                match self.mode {
3618                    SelectMode::Single => {
3619                        let item = &self.items[idx];
3620                        self.render_single_item_inline(stderr, &item.name, is_active)?;
3621                    }
3622                    SelectMode::Multi => {
3623                        let real_idx = if self.refined {
3624                            self.filtered_indices[idx]
3625                        } else {
3626                            idx
3627                        };
3628                        let item = &self.items[real_idx];
3629                        let is_checked = self.selected.contains(&real_idx);
3630                        self.render_multi_item_inline(stderr, &item.name, is_checked, is_active)?;
3631                    }
3632                    SelectMode::Fuzzy => {
3633                        let real_idx = self.filtered_indices[idx];
3634                        let item = &self.items[real_idx];
3635                        self.render_fuzzy_item_inline(stderr, &item.name, is_active)?;
3636                    }
3637                    SelectMode::FuzzyMulti => {
3638                        let real_idx = self.filtered_indices[idx];
3639                        let item = &self.items[real_idx];
3640                        let is_checked = self.selected.contains(&real_idx);
3641                        self.render_fuzzy_multi_item_inline(
3642                            stderr, &item.name, is_checked, is_active,
3643                        )?;
3644                    }
3645                }
3646            }
3647            lines_rendered += 1;
3648            if !is_last_line {
3649                execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3650            }
3651        }
3652
3653        // Show scroll indicator if needed
3654        if has_scroll_indicator {
3655            let indicator = self.generate_footer();
3656            execute!(
3657                stderr,
3658                Print(self.config.footer.paint(&indicator)),
3659                Clear(ClearType::UntilNewLine),
3660            )?;
3661            lines_rendered += 1;
3662        }
3663
3664        // Clear any extra lines from previous render
3665        // Cursor is on last rendered line
3666        if lines_rendered < self.rendered_lines {
3667            let extra_lines = self.rendered_lines - lines_rendered;
3668            for _ in 0..extra_lines {
3669                execute!(
3670                    stderr,
3671                    MoveDown(1),
3672                    MoveToColumn(0),
3673                    Clear(ClearType::CurrentLine)
3674                )?;
3675            }
3676            // Move back to last content line
3677            execute!(stderr, MoveUp(extra_lines as u16))?;
3678        }
3679
3680        // Update state
3681        self.rendered_lines = lines_rendered;
3682        self.prev_cursor = self.cursor;
3683        self.prev_scroll_offset = self.scroll_offset;
3684        self.first_render = false;
3685        self.filter_text_changed = false;
3686        self.results_changed = false;
3687        self.horizontal_scroll_changed = false;
3688        self.width_changed = false;
3689        self.table_layout_changed = false;
3690        self.toggled_item = None;
3691        self.toggled_all = false;
3692        self.settings_changed = false;
3693
3694        // In fuzzy modes, position cursor within filter text
3695        if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
3696            // Cursor is on last content line, move up to filter line
3697            let filter_row = self.fuzzy_filter_row() as usize;
3698            self.fuzzy_cursor_offset = lines_rendered.saturating_sub(filter_row + 1);
3699            if self.fuzzy_cursor_offset > 0 {
3700                execute!(stderr, MoveUp(self.fuzzy_cursor_offset as u16))?;
3701            }
3702            // Position cursor after prompt marker + text up to filter_cursor
3703            self.position_fuzzy_cursor(stderr)?;
3704        }
3705
3706        execute!(stderr, EndSynchronizedUpdate)?;
3707        stderr.flush()
3708    }
3709
3710    fn render_single_item_inline(
3711        &self,
3712        stderr: &mut impl Write,
3713        text: &str,
3714        active: bool,
3715    ) -> io::Result<()> {
3716        let prefix = if active { self.selected_marker() } else { "  " };
3717        let prefix_width = 2;
3718
3719        execute!(stderr, Print(prefix))?;
3720        self.render_truncated_text(stderr, text, prefix_width)?;
3721        execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3722        Ok(())
3723    }
3724
3725    fn render_multi_item_inline(
3726        &self,
3727        stderr: &mut impl Write,
3728        text: &str,
3729        checked: bool,
3730        active: bool,
3731    ) -> io::Result<()> {
3732        let cursor = if active { self.selected_marker() } else { "  " };
3733        let checkbox = if checked { "[x] " } else { "[ ] " };
3734        let prefix_width = 6; // "> [x] " or "  [ ] "
3735
3736        execute!(stderr, Print(cursor), Print(checkbox))?;
3737        self.render_truncated_text(stderr, text, prefix_width)?;
3738        execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3739        Ok(())
3740    }
3741
3742    fn render_fuzzy_item_inline(
3743        &self,
3744        stderr: &mut impl Write,
3745        text: &str,
3746        active: bool,
3747    ) -> io::Result<()> {
3748        let prefix = if active { self.selected_marker() } else { "  " };
3749        let prefix_width = 2;
3750        execute!(stderr, Print(prefix))?;
3751
3752        if self.filter_text.is_empty() {
3753            self.render_truncated_text(stderr, text, prefix_width)?;
3754        } else if let Some(indices) = self.fuzzy_match_indices(text) {
3755            self.render_truncated_fuzzy_text(stderr, text, &indices, prefix_width)?;
3756        } else {
3757            self.render_truncated_text(stderr, text, prefix_width)?;
3758        }
3759        execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3760        Ok(())
3761    }
3762
3763    fn render_fuzzy_multi_item_inline(
3764        &self,
3765        stderr: &mut impl Write,
3766        text: &str,
3767        checked: bool,
3768        active: bool,
3769    ) -> io::Result<()> {
3770        let cursor = if active { self.selected_marker() } else { "  " };
3771        let checkbox = if checked { "[x] " } else { "[ ] " };
3772        let prefix_width = 6; // "> [x] " or "  [ ] "
3773        execute!(stderr, Print(cursor), Print(checkbox))?;
3774
3775        if self.filter_text.is_empty() {
3776            self.render_truncated_text(stderr, text, prefix_width)?;
3777        } else if let Some(indices) = self.fuzzy_match_indices(text) {
3778            self.render_truncated_fuzzy_text(stderr, text, &indices, prefix_width)?;
3779        } else {
3780            self.render_truncated_text(stderr, text, prefix_width)?;
3781        }
3782        execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3783        Ok(())
3784    }
3785
3786    /// Render text, truncating with ellipsis if it exceeds available width.
3787    fn item_text_width(&self, prefix_width: usize) -> usize {
3788        // Keep one printable cell free so drawing a full-width item does not leave the terminal in
3789        // a wrap-pending state before the footer or next row is rendered.
3790        self.term_width
3791            .saturating_sub(prefix_width as u16)
3792            .saturating_sub(1) as usize
3793    }
3794
3795    fn render_truncated_text(
3796        &self,
3797        stderr: &mut impl Write,
3798        text: &str,
3799        prefix_width: usize,
3800    ) -> io::Result<()> {
3801        let available_width = self.item_text_width(prefix_width);
3802        let text = truncate_ansi_aware_text_at(text, available_width, prefix_width);
3803        execute!(stderr, Print(text.as_ref()))?;
3804        Ok(())
3805    }
3806
3807    fn render_display_segments(
3808        &self,
3809        stderr: &mut impl Write,
3810        sanitized: &SanitizedText,
3811        match_indices: Option<&[usize]>,
3812        base_style: Option<Style>,
3813    ) -> io::Result<()> {
3814        let mut match_iter = match_indices.map(|indices| indices.iter().peekable());
3815
3816        for segment in &sanitized.segments {
3817            let is_match = if let (Some(source_index), Some(match_iter)) =
3818                (segment.source_index, match_iter.as_mut())
3819            {
3820                while match_iter.peek().is_some_and(|&&idx| idx < source_index) {
3821                    match_iter.next();
3822                }
3823                match_iter.peek().is_some_and(|&&idx| idx == source_index)
3824            } else {
3825                false
3826            };
3827
3828            if is_match {
3829                execute!(stderr, Print(self.config.match_text.paint(&segment.text)))?;
3830            } else if let Some(style) = base_style {
3831                execute!(stderr, Print(style.paint(&segment.text)))?;
3832            } else {
3833                execute!(stderr, Print(&segment.text))?;
3834            }
3835        }
3836
3837        Ok(())
3838    }
3839
3840    /// Render fuzzy-highlighted text, truncating with ellipsis if needed.
3841    /// The ellipsis is highlighted if any matches fall in the truncated portion.
3842    fn render_truncated_fuzzy_text(
3843        &self,
3844        stderr: &mut impl Write,
3845        text: &str,
3846        match_indices: &[usize],
3847        prefix_width: usize,
3848    ) -> io::Result<()> {
3849        let available_width = self.item_text_width(prefix_width);
3850
3851        if available_width <= 1 {
3852            // Only room for ellipsis
3853            let has_any_matches = !match_indices.is_empty();
3854            if has_any_matches {
3855                execute!(stderr, Print(self.config.match_text.paint("…")))?;
3856            } else {
3857                execute!(stderr, Print("…"))?;
3858            }
3859            return Ok(());
3860        }
3861
3862        let sanitized = sanitize_text_for_display(text, available_width, prefix_width);
3863        if !sanitized.truncated {
3864            self.render_display_segments(stderr, &sanitized, Some(match_indices), None)?;
3865            return Ok(());
3866        }
3867
3868        let sanitized = sanitize_text_for_display(text, available_width - 1, prefix_width);
3869        self.render_display_segments(stderr, &sanitized, Some(match_indices), None)?;
3870
3871        let has_hidden_matches = match_indices
3872            .iter()
3873            .any(|&idx| idx >= sanitized.source_chars);
3874        if has_hidden_matches {
3875            execute!(stderr, Print(self.config.match_text.paint("…")))?;
3876        } else {
3877            execute!(stderr, Print("…"))?;
3878        }
3879        Ok(())
3880    }
3881
3882    /// Render the table header row
3883    fn render_table_header(&self, stderr: &mut impl Write) -> io::Result<()> {
3884        let Some(layout) = &self.table_layout else {
3885            return Ok(());
3886        };
3887
3888        let prefix_width = self.row_prefix_width();
3889        let (cols_visible, has_more_right) = self.calculate_visible_columns();
3890        let has_more_left = self.horizontal_offset > 0;
3891
3892        // Render prefix space (no marker for header)
3893        execute!(stderr, Print(" ".repeat(prefix_width)))?;
3894
3895        // Left scroll indicator (ellipsis + column separator)
3896        if has_more_left {
3897            let sep = self.table_column_separator();
3898            execute!(
3899                stderr,
3900                Print(self.config.table_separator.paint("…")),
3901                Print(self.config.table_separator.paint(&sep))
3902            )?;
3903        }
3904
3905        // Render visible column headers
3906        let visible_range = self.horizontal_offset..(self.horizontal_offset + cols_visible);
3907        for (i, col_idx) in visible_range.enumerate() {
3908            if col_idx >= layout.columns.len() {
3909                break;
3910            }
3911
3912            // Separator between columns
3913            if i > 0 {
3914                let sep = self.table_column_separator();
3915                execute!(stderr, Print(self.config.table_separator.paint(&sep)))?;
3916            }
3917
3918            // Render column header, center-aligned to column width
3919            let header = &layout.columns[col_idx];
3920            let col_width = layout.col_widths[col_idx];
3921            let header_width = header.width();
3922            let padding = col_width.saturating_sub(header_width);
3923            let left_pad = padding / 2;
3924            let right_pad = padding - left_pad;
3925            let header_padded = format!(
3926                "{}{}{}",
3927                " ".repeat(left_pad),
3928                header,
3929                " ".repeat(right_pad)
3930            );
3931            execute!(
3932                stderr,
3933                Print(self.config.table_header.paint(&header_padded))
3934            )?;
3935        }
3936
3937        // Right scroll indicator (column separator + ellipsis)
3938        if has_more_right {
3939            let sep = self.table_column_separator();
3940            execute!(
3941                stderr,
3942                Print(self.config.table_separator.paint(&sep)),
3943                Print(self.config.table_separator.paint("…"))
3944            )?;
3945        }
3946
3947        execute!(stderr, Clear(ClearType::UntilNewLine))?;
3948        Ok(())
3949    }
3950
3951    /// Render the separator line between table header and data rows
3952    fn render_table_header_separator(&self, stderr: &mut impl Write) -> io::Result<()> {
3953        let Some(layout) = &self.table_layout else {
3954            return Ok(());
3955        };
3956
3957        let prefix_width = self.row_prefix_width();
3958        let (cols_visible, has_more_right) = self.calculate_visible_columns();
3959        let has_more_left = self.horizontal_offset > 0;
3960
3961        let h_char = self.config.table_header_separator;
3962        let int_char = self.config.table_header_intersection;
3963
3964        // Render prefix as horizontal line
3965        let prefix_line: String = std::iter::repeat_n(h_char, prefix_width).collect();
3966        execute!(
3967            stderr,
3968            Print(self.config.table_separator.paint(&prefix_line))
3969        )?;
3970
3971        // Left scroll indicator (as horizontal continuation with intersection)
3972        // Width matches "… │ " = 1 + separator_width
3973        if has_more_left {
3974            let left_indicator = format!("{}{}{}{}", h_char, h_char, int_char, h_char);
3975            execute!(
3976                stderr,
3977                Print(self.config.table_separator.paint(&left_indicator))
3978            )?;
3979        }
3980
3981        // Render horizontal lines for visible columns with intersections
3982        let visible_range = self.horizontal_offset..(self.horizontal_offset + cols_visible);
3983        for (i, col_idx) in visible_range.enumerate() {
3984            if col_idx >= layout.col_widths.len() {
3985                break;
3986            }
3987
3988            // Intersection between columns (must match width of column separator " │ ")
3989            if i > 0 {
3990                let intersection = format!("{}{}{}", h_char, int_char, h_char);
3991                execute!(
3992                    stderr,
3993                    Print(self.config.table_separator.paint(&intersection))
3994                )?;
3995            }
3996
3997            // Horizontal line for this column's width
3998            let col_width = layout.col_widths[col_idx];
3999            let line: String = std::iter::repeat_n(h_char, col_width).collect();
4000            execute!(stderr, Print(self.config.table_separator.paint(&line)))?;
4001        }
4002
4003        // Right scroll indicator (as horizontal continuation with intersection)
4004        // Width matches " │ …" = separator_width + 1
4005        if has_more_right {
4006            let right_indicator = format!("{}{}{}{}", h_char, int_char, h_char, h_char);
4007            execute!(
4008                stderr,
4009                Print(self.config.table_separator.paint(&right_indicator))
4010            )?;
4011        }
4012
4013        execute!(stderr, Clear(ClearType::UntilNewLine))?;
4014        Ok(())
4015    }
4016
4017    /// Render a table row in single-select mode
4018    fn render_table_row_single(
4019        &self,
4020        stderr: &mut impl Write,
4021        item: &SelectItem,
4022        active: bool,
4023    ) -> io::Result<()> {
4024        let prefix = if active { self.selected_marker() } else { "  " };
4025        execute!(stderr, Print(prefix))?;
4026        self.render_table_cells(stderr, item, None)?;
4027        execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
4028        Ok(())
4029    }
4030
4031    /// Render a table row in multi-select mode
4032    fn render_table_row_multi(
4033        &self,
4034        stderr: &mut impl Write,
4035        item: &SelectItem,
4036        checked: bool,
4037        active: bool,
4038    ) -> io::Result<()> {
4039        let cursor = if active { self.selected_marker() } else { "  " };
4040        let checkbox = if checked { "[x] " } else { "[ ] " };
4041        execute!(stderr, Print(cursor), Print(checkbox))?;
4042        self.render_table_cells(stderr, item, None)?;
4043        execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
4044        Ok(())
4045    }
4046
4047    /// Render a table row in fuzzy mode with match highlighting
4048    fn render_table_row_fuzzy(
4049        &self,
4050        stderr: &mut impl Write,
4051        item: &SelectItem,
4052        active: bool,
4053    ) -> io::Result<()> {
4054        let prefix = if active { self.selected_marker() } else { "  " };
4055        execute!(stderr, Print(prefix))?;
4056
4057        // Get match indices for highlighting (skip if per_column - handled in render_table_cells)
4058        let match_indices = if !self.filter_text.is_empty() && !self.per_column {
4059            self.fuzzy_match_indices(&item.name)
4060        } else {
4061            None
4062        };
4063
4064        self.render_table_cells(stderr, item, match_indices.as_deref())?;
4065        execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
4066        Ok(())
4067    }
4068
4069    /// Render a table row in fuzzy-multi mode with match highlighting and checkbox
4070    fn render_table_row_fuzzy_multi(
4071        &self,
4072        stderr: &mut impl Write,
4073        item: &SelectItem,
4074        checked: bool,
4075        active: bool,
4076    ) -> io::Result<()> {
4077        let cursor = if active { self.selected_marker() } else { "  " };
4078        let checkbox = if checked { "[x] " } else { "[ ] " };
4079        execute!(stderr, Print(cursor), Print(checkbox))?;
4080
4081        // Get match indices for highlighting (skip if per_column - handled in render_table_cells)
4082        let match_indices = if !self.filter_text.is_empty() && !self.per_column {
4083            self.fuzzy_match_indices(&item.name)
4084        } else {
4085            None
4086        };
4087
4088        self.render_table_cells(stderr, item, match_indices.as_deref())?;
4089        execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
4090        Ok(())
4091    }
4092
4093    /// Render table cells with proper alignment and optional fuzzy highlighting
4094    fn render_table_cells(
4095        &self,
4096        stderr: &mut impl Write,
4097        item: &SelectItem,
4098        match_indices: Option<&[usize]>,
4099    ) -> io::Result<()> {
4100        let Some(layout) = &self.table_layout else {
4101            return Ok(());
4102        };
4103        let Some(cells) = &item.cells else {
4104            return Ok(());
4105        };
4106
4107        let (cols_visible, has_more_right) = self.calculate_visible_columns();
4108        let has_more_left = self.horizontal_offset > 0;
4109
4110        // Track if there are matches in hidden columns (for scroll indicator highlighting)
4111        let mut matches_in_hidden_left = false;
4112        let mut matches_in_hidden_right = false;
4113
4114        // For per-column mode, pre-compute match indices for each cell
4115        let per_column_matches: Vec<Option<Vec<usize>>> =
4116            if self.per_column && !self.filter_text.is_empty() {
4117                cells
4118                    .iter()
4119                    .map(|(cell_text, _)| self.fuzzy_match_indices(cell_text))
4120                    .collect()
4121            } else {
4122                vec![]
4123            };
4124
4125        // Calculate character offset for each cell to map match indices (for non-per-column mode)
4126        // The search text (item.name) is space-separated cells, so we need to track offsets
4127        let cell_offsets: Vec<usize> = if match_indices.is_some() {
4128            let mut offsets = Vec::with_capacity(cells.len());
4129            let mut offset = 0;
4130            for (i, (cell_text, _)) in cells.iter().enumerate() {
4131                offsets.push(offset);
4132                offset += cell_text.chars().count();
4133                if i + 1 < cells.len() {
4134                    offset += 1; // For the space separator
4135                }
4136            }
4137            offsets
4138        } else {
4139            vec![]
4140        };
4141
4142        // Check for matches in hidden left columns
4143        if self.per_column && !self.filter_text.is_empty() {
4144            for col_idx in 0..self.horizontal_offset {
4145                if col_idx < per_column_matches.len() && per_column_matches[col_idx].is_some() {
4146                    matches_in_hidden_left = true;
4147                    break;
4148                }
4149            }
4150        } else if let Some(indices) = match_indices {
4151            for col_idx in 0..self.horizontal_offset {
4152                if col_idx < cell_offsets.len() && col_idx + 1 < cell_offsets.len() {
4153                    let cell_start = cell_offsets[col_idx];
4154                    let cell_end = cell_offsets[col_idx + 1].saturating_sub(1); // -1 for space
4155                    if indices.iter().any(|&i| i >= cell_start && i < cell_end) {
4156                        matches_in_hidden_left = true;
4157                        break;
4158                    }
4159                }
4160            }
4161        }
4162
4163        // Left scroll indicator (ellipsis + column separator)
4164        if has_more_left {
4165            let sep = self.table_column_separator();
4166            if matches_in_hidden_left {
4167                execute!(
4168                    stderr,
4169                    Print(self.config.match_text.paint("…")),
4170                    Print(self.config.table_separator.paint(&sep))
4171                )?;
4172            } else {
4173                execute!(
4174                    stderr,
4175                    Print(self.config.table_separator.paint("…")),
4176                    Print(self.config.table_separator.paint(&sep))
4177                )?;
4178            }
4179        }
4180
4181        // Render visible cells
4182        let visible_range = self.horizontal_offset..(self.horizontal_offset + cols_visible);
4183        for (i, col_idx) in visible_range.enumerate() {
4184            if col_idx >= cells.len() {
4185                break;
4186            }
4187
4188            // Separator between columns
4189            if i > 0 {
4190                let sep = self.table_column_separator();
4191                execute!(stderr, Print(self.config.table_separator.paint(&sep)))?;
4192            }
4193
4194            let (cell_text, cell_style) = &cells[col_idx];
4195            let col_width = layout.col_widths[col_idx];
4196
4197            // Get match indices for this cell
4198            let cell_matches: Option<Vec<usize>> =
4199                if self.per_column && !self.filter_text.is_empty() {
4200                    // Per-column mode: use pre-computed per-cell indices
4201                    per_column_matches.get(col_idx).cloned().flatten()
4202                } else if let Some(indices) = match_indices {
4203                    // Standard mode: map global indices to cell-relative
4204                    if col_idx < cell_offsets.len() {
4205                        let cell_start = cell_offsets[col_idx];
4206                        // Filter indices that fall within this cell and adjust to cell-relative
4207                        let cell_char_count = cell_text.chars().count();
4208                        let relative_indices: Vec<usize> = indices
4209                            .iter()
4210                            .filter_map(|&idx| {
4211                                if idx >= cell_start && idx < cell_start + cell_char_count {
4212                                    Some(idx - cell_start)
4213                                } else {
4214                                    None
4215                                }
4216                            })
4217                            .collect();
4218                        if relative_indices.is_empty() {
4219                            None
4220                        } else {
4221                            Some(relative_indices)
4222                        }
4223                    } else {
4224                        None
4225                    }
4226                } else {
4227                    None
4228                };
4229
4230            // Render cell with padding and type-based styling
4231            self.render_table_cell(
4232                stderr,
4233                cell_text,
4234                cell_style,
4235                col_width,
4236                cell_matches.as_deref(),
4237            )?;
4238        }
4239
4240        // Check for matches in hidden right columns
4241        if self.per_column && !self.filter_text.is_empty() {
4242            for col_idx in (self.horizontal_offset + cols_visible)..cells.len() {
4243                if col_idx < per_column_matches.len() && per_column_matches[col_idx].is_some() {
4244                    matches_in_hidden_right = true;
4245                    break;
4246                }
4247            }
4248        } else if let Some(indices) = match_indices {
4249            for col_idx in (self.horizontal_offset + cols_visible)..cells.len() {
4250                if col_idx < cell_offsets.len() {
4251                    let cell_start = cell_offsets[col_idx];
4252                    let cell_end = if col_idx + 1 < cell_offsets.len() {
4253                        cell_offsets[col_idx + 1].saturating_sub(1)
4254                    } else {
4255                        item.name.chars().count()
4256                    };
4257                    if indices.iter().any(|&i| i >= cell_start && i < cell_end) {
4258                        matches_in_hidden_right = true;
4259                        break;
4260                    }
4261                }
4262            }
4263        }
4264
4265        // Right scroll indicator (column separator + ellipsis)
4266        if has_more_right {
4267            let sep = self.table_column_separator();
4268            if matches_in_hidden_right {
4269                execute!(
4270                    stderr,
4271                    Print(self.config.table_separator.paint(&sep)),
4272                    Print(self.config.match_text.paint("…"))
4273                )?;
4274            } else {
4275                execute!(
4276                    stderr,
4277                    Print(self.config.table_separator.paint(&sep)),
4278                    Print(self.config.table_separator.paint("…"))
4279                )?;
4280            }
4281        }
4282
4283        Ok(())
4284    }
4285
4286    /// Render a single table cell with padding, type-based styling, alignment, and optional match highlighting
4287    fn render_table_cell(
4288        &self,
4289        stderr: &mut impl Write,
4290        cell: &str,
4291        cell_style: &TextStyle,
4292        col_width: usize,
4293        match_indices: Option<&[usize]>,
4294    ) -> io::Result<()> {
4295        let cell_width = terminal_text_width_from(cell, 0);
4296        let padding_needed = col_width.saturating_sub(cell_width);
4297
4298        // Calculate left and right padding based on alignment from TextStyle
4299        let (left_pad, right_pad) = match cell_style.alignment {
4300            Alignment::Left => (0, padding_needed),
4301            Alignment::Right => (padding_needed, 0),
4302            Alignment::Center => {
4303                let left = padding_needed / 2;
4304                (left, padding_needed - left)
4305            }
4306        };
4307
4308        // Add left padding
4309        if left_pad > 0 {
4310            execute!(stderr, Print(" ".repeat(left_pad)))?;
4311        }
4312
4313        // Keep tab expansion cell-relative so the rendered content width matches the width used
4314        // for table layout and padding.
4315        let sanitized = sanitize_text_for_display(cell, cell_width, 0);
4316        self.render_display_segments(stderr, &sanitized, match_indices, cell_style.color_style)?;
4317
4318        // Add right padding
4319        if right_pad > 0 {
4320            execute!(stderr, Print(" ".repeat(right_pad)))?;
4321        }
4322
4323        Ok(())
4324    }
4325
4326    fn clear_display(&mut self, stderr: &mut impl Write) -> io::Result<()> {
4327        // In fuzzy mode, cursor may be at filter line; move back to end first
4328        if self.fuzzy_cursor_offset > 0 {
4329            execute!(stderr, MoveDown(self.fuzzy_cursor_offset as u16))?;
4330            self.fuzzy_cursor_offset = 0;
4331        }
4332
4333        if self.rendered_lines > 0 {
4334            // Clear each line by moving up from current position and clearing.
4335            // This doesn't assume we know exactly where the cursor is.
4336            // First, move to column 0 and clear current line.
4337            execute!(stderr, MoveToColumn(0), Clear(ClearType::CurrentLine))?;
4338            // Then move up and clear each remaining line
4339            for _ in 1..self.rendered_lines {
4340                execute!(
4341                    stderr,
4342                    MoveUp(1),
4343                    MoveToColumn(0),
4344                    Clear(ClearType::CurrentLine)
4345                )?;
4346            }
4347            // Now we're at the first rendered line, which is where output should go
4348        }
4349        self.rendered_lines = 0;
4350        stderr.flush()
4351    }
4352}
4353
4354enum KeyAction {
4355    Continue,
4356    Cancel,
4357    Confirm,
4358}
4359
4360#[cfg(test)]
4361mod test {
4362    use super::*;
4363
4364    fn make_widget(items: &[&str]) -> SelectWidget<'static> {
4365        let options: Vec<SelectItem> = items
4366            .iter()
4367            .map(|s| SelectItem {
4368                name: s.to_string(),
4369                cells: None,
4370                value: nu_protocol::Value::nothing(nu_protocol::Span::test_data()),
4371            })
4372            .collect();
4373
4374        SelectWidget::new(
4375            SelectMode::Single,
4376            None,
4377            options,
4378            InputListConfig::default(),
4379            None,
4380            false,
4381            StreamState {
4382                stream_reader: None,
4383                item_generator: None,
4384            },
4385        )
4386    }
4387
4388    #[test]
4389    fn wrap_up_and_down_cycles() {
4390        let mut w = make_widget(&["A", "B", "C"]);
4391        // navigate up three times, expect proper cycling
4392        w.navigate_up();
4393        assert_eq!(w.cursor, 2);
4394        w.navigate_up();
4395        assert_eq!(w.cursor, 1);
4396        w.navigate_up();
4397        assert_eq!(w.cursor, 0);
4398
4399        // navigate down three times, expect cycling as well
4400        w.navigate_down();
4401        assert_eq!(w.cursor, 1);
4402        w.navigate_down();
4403        assert_eq!(w.cursor, 2);
4404        w.navigate_down();
4405        assert_eq!(w.cursor, 0);
4406    }
4407
4408    #[test]
4409    fn down_navigation_cycles_with_full_redraw() -> io::Result<()> {
4410        let mut w = make_widget(&["Banana", "Kiwi", "Pear"]);
4411        w.first_render = false;
4412        w.prev_cursor = 0;
4413        w.prev_scroll_offset = 0;
4414        w.cursor = 0;
4415        w.scroll_offset = 0;
4416
4417        let mut output = Vec::new();
4418
4419        for _ in 0..7 {
4420            w.navigate_down();
4421            w.render(&mut output)?;
4422            assert_eq!(w.scroll_offset, 0);
4423        }
4424
4425        Ok(())
4426    }
4427
4428    #[test]
4429    fn up_arrow_sequence_state_and_render() -> io::Result<()> {
4430        let mut w = make_widget(&["Banana", "Kiwi", "Pear"]);
4431        w.first_render = false;
4432        w.prev_cursor = 0;
4433        w.prev_scroll_offset = 0;
4434        w.cursor = 0;
4435        w.scroll_offset = 0;
4436
4437        let mut output = Vec::new();
4438
4439        w.render(&mut output)?;
4440        assert_eq!(w.cursor, 0);
4441
4442        w.navigate_up();
4443        w.render(&mut output)?;
4444        assert_eq!(w.cursor, 2);
4445
4446        w.navigate_up();
4447        w.render(&mut output)?;
4448        assert_eq!(w.cursor, 1);
4449
4450        Ok(())
4451    }
4452
4453    #[test]
4454    fn ansi_styled_text_that_visibly_fits_is_not_truncated() {
4455        let text = "\u{1b}[1;37mabcdef\u{1b}[0m";
4456
4457        let rendered = truncate_ansi_aware_text(text, 6);
4458
4459        assert_eq!(
4460            nu_utils::strip_ansi_unlikely(rendered.as_ref()).as_ref(),
4461            "abcdef"
4462        );
4463        assert!(!rendered.contains('…'));
4464    }
4465
4466    #[test]
4467    fn ansi_styled_text_truncates_by_visible_width() {
4468        let text = "\u{1b}[1;37mabcdef\u{1b}[0m";
4469
4470        let rendered = truncate_ansi_aware_text(text, 4);
4471
4472        assert_eq!(
4473            nu_utils::strip_ansi_unlikely(rendered.as_ref()).as_ref(),
4474            "abc…"
4475        );
4476    }
4477
4478    #[test]
4479    fn tabbed_text_truncates_by_terminal_width() {
4480        let rendered = truncate_ansi_aware_text("ab\tcdef", 6);
4481
4482        assert_eq!(rendered.as_ref(), "ab…");
4483    }
4484
4485    #[test]
4486    fn tabbed_text_truncates_from_prefixed_column() {
4487        let rendered = truncate_ansi_aware_text_at("\t--hostname-bin", 6, 2);
4488
4489        assert_eq!(rendered.as_ref(), "…");
4490    }
4491
4492    #[test]
4493    fn tabbed_text_expands_when_not_truncated() {
4494        let rendered = truncate_ansi_aware_text_at("\t--hostname-bin", 32, 2);
4495
4496        assert_eq!(rendered.as_ref(), "      --hostname-bin");
4497    }
4498
4499    #[test]
4500    fn sanitizer_tracks_source_indices_after_expanding_tabs() {
4501        let sanitized = sanitize_text_for_display("a\tb\u{7}c", 16, 0);
4502
4503        assert_eq!(sanitized.text, "a       bc");
4504        assert_eq!(
4505            sanitized
4506                .segments
4507                .iter()
4508                .filter_map(|segment| segment.source_index)
4509                .collect::<Vec<_>>(),
4510            vec![0, 1, 2, 4]
4511        );
4512    }
4513
4514    #[test]
4515    fn item_text_width_reserves_prefix() {
4516        let mut w = make_widget(&[""]);
4517        w.term_width = 129;
4518
4519        let available_width = w.item_text_width(2);
4520        let rendered = truncate_ansi_aware_text_at(
4521            "\t--hostname-bin # Run a program to get this system's hostname",
4522            available_width,
4523            2,
4524        );
4525
4526        assert_eq!(available_width, 126);
4527        assert!(terminal_text_width_from(rendered.as_ref(), 2) <= available_width);
4528    }
4529
4530    #[test]
4531    fn table_layout_uses_sanitized_terminal_width() {
4532        let span = nu_protocol::Span::test_data();
4533        let columns = vec!["name".to_string()];
4534        let items = vec![SelectItem {
4535            name: "\tname".to_string(),
4536            cells: Some(vec![("\tname".to_string(), TextStyle::default())]),
4537            value: Value::nothing(span),
4538        }];
4539
4540        let layout = InputList::calculate_table_layout(&columns, &items);
4541
4542        assert_eq!(layout.col_widths, vec![12]);
4543    }
4544
4545    #[test]
4546    fn fuzzy_filter_does_not_drain_pending_stream() {
4547        let span = nu_protocol::Span::test_data();
4548        let mut w = make_widget(&["needle"]);
4549        w.mode = SelectMode::Fuzzy;
4550        w.filter_text = "needle".to_string();
4551        w.filter_cursor = w.filter_text.len();
4552        w.stream_reader = Some(StreamReader::new(ListStream::new(
4553            (0..10_000).map(move |i| Value::string(format!("row-{i}"), span)),
4554            span,
4555            nu_protocol::Signals::empty(),
4556        )));
4557
4558        w.update_filter();
4559
4560        assert_eq!(w.items.len(), 1);
4561        assert!(w.stream_is_pending());
4562    }
4563
4564    #[test]
4565    #[cfg_attr(ci, serial)]
4566    fn initial_read_collects_fast_finite_stream() {
4567        let span = nu_protocol::Span::test_data();
4568        let stream = ListStream::new(
4569            (0..5).map(move |i| Value::int(i, span)),
4570            span,
4571            nu_protocol::Signals::empty(),
4572        );
4573
4574        let (values, pending_stream) = InputList::read_initial_stream_values(stream);
4575
4576        assert_eq!(values.len(), 5);
4577        assert!(pending_stream.is_none());
4578    }
4579
4580    #[test]
4581    #[cfg_attr(ci, serial)]
4582    fn initial_read_stops_before_exhausting_unbounded_stream() {
4583        let span = nu_protocol::Span::test_data();
4584        let stream = ListStream::new(
4585            (0..).map(move |i| Value::int(i, span)),
4586            span,
4587            nu_protocol::Signals::empty(),
4588        );
4589
4590        let (values, pending_stream) = InputList::read_initial_stream_values(stream);
4591
4592        assert!(!values.is_empty());
4593        assert!(values.len() <= INITIAL_STREAM_MAX_ITEMS);
4594        assert!(pending_stream.is_some());
4595    }
4596
4597    #[test]
4598    #[serial]
4599    fn initial_read_timeout_does_not_block_on_slow_stream() {
4600        let span = nu_protocol::Span::test_data();
4601        let (sender, receiver) = std::sync::mpsc::channel::<Value>();
4602        let stream = ListStream::new(receiver.into_iter(), span, nu_protocol::Signals::empty());
4603        let start = nu_utils::time::Instant::now();
4604
4605        let (values, pending_stream) = InputList::read_initial_stream_values(stream);
4606
4607        assert!(values.is_empty());
4608        assert!(pending_stream.is_some());
4609        assert!(start.elapsed() < INITIAL_STREAM_COLLECT_TIMEOUT * 2);
4610
4611        drop(sender);
4612    }
4613
4614    #[test]
4615    fn materialized_list_input_is_not_streamed() {
4616        let span = nu_protocol::Span::test_data();
4617        let values: Vec<Value> = (0..=INITIAL_STREAM_MAX_ITEMS)
4618            .map(|i| Value::int(i as i64, span))
4619            .collect();
4620        let input = Value::list(values, span).into_pipeline_data();
4621
4622        let (values, pending_stream) =
4623            InputList::initial_values_from_input(input, span, nu_protocol::Signals::empty())
4624                .expect("materialized list input should be accepted");
4625
4626        assert_eq!(values.len(), INITIAL_STREAM_MAX_ITEMS + 1);
4627        assert!(pending_stream.is_none());
4628    }
4629
4630    #[test]
4631    fn footer_marks_pending_stream() {
4632        let span = nu_protocol::Span::test_data();
4633        let mut w = make_widget(&["one", "two"]);
4634        w.term_width = 80;
4635        w.stream_reader = Some(StreamReader::new(ListStream::new(
4636            (0..100).map(move |i| Value::string(format!("row-{i}"), span)),
4637            span,
4638            nu_protocol::Signals::empty(),
4639        )));
4640
4641        assert_eq!(w.generate_footer(), "[1-2 of 2 -]");
4642    }
4643
4644    #[test]
4645    fn streaming_footer_updates_at_slower_interval() {
4646        let span = nu_protocol::Span::test_data();
4647        let mut w = make_widget(&["one"]);
4648        let (_sender, receiver) = mpsc::sync_channel(1);
4649        w.stream_reader = Some(StreamReader {
4650            receiver,
4651            finished: false,
4652        });
4653        w.items.push(SelectItem {
4654            name: "two".to_string(),
4655            cells: None,
4656            value: Value::string("two", span),
4657        });
4658        w.settings_changed = false;
4659
4660        w.update_stream_footer();
4661
4662        assert_eq!(w.stream_spinner_frame, 0);
4663        assert_eq!(w.stream_footer_item_count, 1);
4664        assert!(!w.settings_changed);
4665
4666        w.last_stream_footer_update =
4667            nu_utils::time::Instant::now() - STREAM_FOOTER_UPDATE_INTERVAL;
4668
4669        w.update_stream_footer();
4670
4671        assert_eq!(w.stream_spinner_frame, 1);
4672        assert_eq!(w.stream_footer_item_count, 2);
4673        assert!(w.settings_changed);
4674    }
4675
4676    #[test]
4677    fn footer_shows_when_items_fill_reserved_area() {
4678        let mut w = make_widget(&["one", "two"]);
4679        w.term_width = 80;
4680        w.visible_height = 2;
4681
4682        assert!(w.has_footer());
4683        assert_eq!(w.generate_footer(), "[1-2 of 2]");
4684    }
4685
4686    #[test]
4687    fn final_stream_drain_marks_footer_dirty() {
4688        let span = nu_protocol::Span::test_data();
4689        let mut w = make_widget(&["one"]);
4690        w.term_width = 80;
4691        let (sender, receiver) = mpsc::sync_channel(8);
4692        for i in 0..2 {
4693            sender
4694                .send(StreamMessage::Item(Value::string(format!("row-{i}"), span)))
4695                .expect("test stream receiver should be open");
4696        }
4697        drop(sender);
4698        w.stream_reader = Some(StreamReader {
4699            receiver,
4700            finished: false,
4701        });
4702        w.settings_changed = false;
4703
4704        assert!(w.load_more_items(STREAM_LOAD_BATCH));
4705
4706        assert!(w.stream_reader.is_none());
4707        assert!(w.settings_changed);
4708        assert_eq!(w.generate_footer(), "[1-3 of 3]");
4709    }
4710
4711    #[test]
4712    fn streamed_table_width_growth_marks_header_dirty() {
4713        let span = nu_protocol::Span::test_data();
4714        let columns = vec!["name".to_string()];
4715        let items = vec![SelectItem {
4716            name: "sh".to_string(),
4717            cells: Some(vec![("sh".to_string(), TextStyle::default())]),
4718            value: Value::nothing(span),
4719        }];
4720        let table_layout = InputList::calculate_table_layout(&columns, &items);
4721        let (sender, receiver) = mpsc::sync_channel(1);
4722        sender
4723            .send(StreamMessage::Item(Value::string(
4724                "long-streamed-name",
4725                span,
4726            )))
4727            .expect("test stream receiver should be open");
4728        drop(sender);
4729
4730        let mut w = SelectWidget::new(
4731            SelectMode::Single,
4732            None,
4733            items,
4734            InputListConfig::default(),
4735            Some(table_layout),
4736            false,
4737            StreamState {
4738                stream_reader: Some(StreamReader {
4739                    receiver,
4740                    finished: false,
4741                }),
4742                item_generator: Some(Box::new(move |value| SelectItem {
4743                    name: "long-streamed-name".to_string(),
4744                    cells: Some(vec![(
4745                        "long-streamed-name".to_string(),
4746                        TextStyle::default(),
4747                    )]),
4748                    value,
4749                })),
4750            },
4751        );
4752
4753        assert!(w.load_more_items(STREAM_LOAD_BATCH));
4754        assert!(w.table_layout_changed);
4755    }
4756
4757    #[test]
4758    fn end_navigation_uses_available_streamed_rows() {
4759        let span = nu_protocol::Span::test_data();
4760        let mut w = make_widget(&["initial"]);
4761        let (sender, receiver) = mpsc::sync_channel(8);
4762        for i in 0..5 {
4763            sender
4764                .send(StreamMessage::Item(Value::string(format!("row-{i}"), span)))
4765                .expect("test stream receiver should be open");
4766        }
4767        drop(sender);
4768        w.stream_reader = Some(StreamReader {
4769            receiver,
4770            finished: false,
4771        });
4772
4773        w.navigate_end();
4774
4775        assert_eq!(w.items.len(), 6);
4776        assert_eq!(w.cursor, 5);
4777        assert!(w.follow_stream_to_end);
4778    }
4779
4780    #[test]
4781    fn test_examples() -> nu_test_support::Result {
4782        nu_test_support::test().examples(InputList)
4783    }
4784}