xcodeai 2.1.0

Autonomous AI coding agent — zero human intervention, sbox sandboxed, OpenAI-compatible
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
// src/repl/input.rs
//
// Crossterm-based line editor with real-time slash-command suggestions.
//
// This replaces rustyline's `rl.readline()` for the REPL main loop.
// The key difference: we process keypresses one at a time (raw mode),
// so we can update the suggestion list on *every* character — no Tab needed.
//
// ┌─────────────────────────────────────────────────────────┐
// │  xcodeai›  /he_                                         │
// │  /help      Show all commands                           │  ← suggestions
// │  /hooks     Manage hook configurations                  │    rendered live
// └─────────────────────────────────────────────────────────┘
//
// For Rust learners:
//   - `crossterm::terminal::enable_raw_mode()` puts the terminal into a mode
//     where keypresses are delivered immediately (no line buffering) and the
//     terminal doesn't echo characters by itself — we do it manually.
//   - `crossterm::event::read()` blocks until a key is pressed, then returns
//     a `KeyEvent` describing exactly what was pressed.
//   - We manage a `Vec<char>` buffer and a cursor position ourselves, just
//     like a text editor would — insert/delete characters, move left/right.
//   - After each keypress we erase the previous suggestion area and redraw it
//     using ANSI escape sequences via crossterm's `queue!` macro.

use crossterm::{
    cursor,
    event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
    style::{Color, Print, SetForegroundColor, ResetColor},
    terminal::{self, ClearType},
    ExecutableCommand, QueueableCommand,
};
use std::io::{self, Write};

use super::commands::COMMANDS;
use unicode_width::UnicodeWidthChar;

// ── Public result type ────────────────────────────────────────────────────────

/// What `readline_with_suggestions` returns to the caller.
pub enum ReadResult {
    /// User typed a line and pressed Enter.
    Line(String),
    /// Ctrl-C was pressed (maps to "Interrupted" in rustyline).
    Interrupted,
    /// Ctrl-D on an empty line (maps to "Eof" in rustyline).
    Eof,
}

// ── History ───────────────────────────────────────────────────────────────────

/// Simple in-process history list.
/// The REPL keeps one `InputHistory` alive for the session.
/// For persistence across sessions we still use the file-based approach
/// (load at start, save at end) but the in-memory list drives navigation.
pub struct InputHistory {
    entries: Vec<String>,
    /// When navigating, which index are we at?  `None` = not navigating.
    cursor: Option<usize>,
    /// Stash the live line while the user is browsing history.
    stash: String,
}

impl Default for InputHistory {
    fn default() -> Self {
        Self::new()
    }
}

impl InputHistory {
    pub fn new() -> Self {
        Self {
            entries: Vec::new(),
            cursor: None,
            stash: String::new(),
        }
    }

    /// Load from a text file (one entry per line, oldest first).
    pub fn load_from_file(&mut self, path: &std::path::Path) {
        if let Ok(content) = std::fs::read_to_string(path) {
            for line in content.lines() {
                let s = line.trim().to_string();
                if !s.is_empty() {
                    self.entries.push(s);
                }
            }
        }
    }

    /// Save to a text file, keeping the last 1000 entries.
    pub fn save_to_file(&self, path: &std::path::Path) {
        if let Some(parent) = path.parent() {
            let _ = std::fs::create_dir_all(parent);
        }
        let start = self.entries.len().saturating_sub(1000);
        let content = self.entries[start..].join("\n");
        let _ = std::fs::write(path, content);
    }

    /// Push a new entry (skip duplicates of the most recent).
    pub fn push(&mut self, line: &str) {
        if line.is_empty() {
            return;
        }
        // Don't add exact duplicate of the last entry.
        if self.entries.last().map(|s| s.as_str()) != Some(line) {
            self.entries.push(line.to_string());
        }
        self.cursor = None;
        self.stash = String::new();
    }

    /// Move up (older) in history.  Returns the entry to display.
    pub fn up(&mut self, current_line: &str) -> Option<String> {
        if self.entries.is_empty() {
            return None;
        }
        match self.cursor {
            None => {
                // First press: stash current line, go to most recent entry.
                self.stash = current_line.to_string();
                self.cursor = Some(self.entries.len() - 1);
            }
            Some(0) => {
                // Already at oldest; stay there.
            }
            Some(i) => {
                self.cursor = Some(i - 1);
            }
        }
        self.cursor.map(|i| self.entries[i].clone())
    }

    /// Move down (newer) in history.  Returns the entry, or None if back at live line.
    pub fn down(&mut self) -> Option<String> {
        match self.cursor {
            None => None,
            Some(i) if i + 1 >= self.entries.len() => {
                // Back to live line.
                self.cursor = None;
                Some(self.stash.clone())
            }
            Some(i) => {
                self.cursor = Some(i + 1);
                Some(self.entries[i + 1].clone())
            }
        }
    }

    /// Reset navigation (called when Enter is pressed).
    pub fn reset_nav(&mut self) {
        self.cursor = None;
        self.stash = String::new();
    }
}

// ── Main readline function ────────────────────────────────────────────────────

/// Read one line from the terminal with live slash-command suggestions.
///
/// `prompt` is the coloured prompt string (already formatted with ANSI codes).
/// `history` is the mutable history list owned by the REPL.
///
/// The function:
///   1. Enables raw mode so we get keypresses immediately.
///   2. Prints the prompt.
///   3. Processes keys one by one, updating the buffer and suggestion list.
///   4. Returns when Enter / Ctrl-D / Ctrl-C is pressed.
///   5. Restores cooked mode before returning.
pub fn readline_with_suggestions(
    prompt: &str,
    history: &mut InputHistory,
    working_dir: Option<&std::path::Path>,
) -> io::Result<ReadResult> {
    // ── Enter raw mode ────────────────────────────────────────────────────────
    terminal::enable_raw_mode()?;
    let mut stdout = std::io::stdout();
    let mut file_cache: Option<Vec<String>> = None;
    // Print the prompt (it contains ANSI colour codes from `console::style`).
    // We use `print!` + `flush` rather than crossterm's Print so that the
    // `console` crate's escape sequences go through unmolested.
    print!("{}", prompt);
    stdout.flush()?;

    // ── State ─────────────────────────────────────────────────────────────────
    let mut buf: Vec<char> = Vec::new(); // the characters the user has typed
    let mut cursor_pos: usize = 0; // insertion point (0 = before first char)
    let mut suggestions: Vec<String> = Vec::new(); // current /command or file matches
    let mut selected: usize = 0; // which suggestion is highlighted
    let mut prev_sug_count: usize = 0; // how many suggestion lines were rendered last time

    let result = loop {
        // ── Wait for a keypress ───────────────────────────────────────────────
        let evt = event::read()?;

        // On Windows and some terminals, key-release events are also sent.
        // We only want key-press events.
        let key = match evt {
            Event::Key(k) if k.kind == KeyEventKind::Press => k,
            // Ignore resize, mouse, key-release, etc.
            _ => continue,
        };

        match (key.code, key.modifiers) {
            // ── Enter: submit ─────────────────────────────────────────────────
            (KeyCode::Enter, _) => {
                // If a suggestion is highlighted, complete it first.
                if !suggestions.is_empty() {
                    let chosen = suggestions[selected].clone();
                    // Replace buffer with completed command + trailing space.
                    buf = format!("{} ", chosen).chars().collect();
                    cursor_pos = buf.len();
                    prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;

                    // If the command takes no arguments, submit immediately.
                    // Commands that take args (e.g. /model, /undo N) get a
                    // trailing space so the user can type the argument.
                    let no_args = chosen != "/undo";
                    if no_args {
                        // Erase the suggestion lines BEFORE moving to the next line.
                        erase_suggestions(&mut stdout, prev_sug_count)?;
                        suggestions.clear();
                        stdout.execute(Print("\r\n"))?;
                        history.reset_nav();
                        let line: String = buf.iter().collect();
                        let line = line.trim().to_string();
                        break ReadResult::Line(line);
                    } else {
                        // Arg-taking command: erase suggestions, let user type the arg.
                        erase_suggestions(&mut stdout, prev_sug_count)?;
                        suggestions.clear();
                        prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &[], 0, 0)?;
                        continue;
                    }
                } // end if !suggestions.is_empty()


                // No active suggestion — normal Enter.
                erase_suggestions(&mut stdout, prev_sug_count)?;
                stdout.execute(Print("\r\n"))?;
                history.reset_nav();
                let line: String = buf.iter().collect();
                let line = line.trim().to_string();
                break ReadResult::Line(line);
            }

            // ── Ctrl-D: EOF (only on empty buffer) ───────────────────────────
            (KeyCode::Char('d'), KeyModifiers::CONTROL) => {
                if buf.is_empty() {
                    erase_suggestions(&mut stdout, prev_sug_count)?;
                    stdout.execute(Print("\r\n"))?;
                    break ReadResult::Eof;
                }
                // Non-empty: delete char under cursor (like normal Ctrl-D).
                if cursor_pos < buf.len() {
                    buf.remove(cursor_pos);
                    update_suggestions(&buf, &mut suggestions, &mut selected, working_dir, &mut file_cache);
                    prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
                }
            }

            // ── Ctrl-C: interrupted ───────────────────────────────────────────
            (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
                // Erase any visible suggestions before clearing and breaking.
                erase_suggestions(&mut stdout, prev_sug_count)?;
                suggestions.clear();
                stdout.execute(Print("\r\n"))?;
                history.reset_nav();
                buf.clear();
                // buf and cursor_pos are about to go out of scope (break).
                break ReadResult::Interrupted;
            }

            // ── Ctrl-U: clear line ────────────────────────────────────────────
            (KeyCode::Char('u'), KeyModifiers::CONTROL) => {
                buf.clear();
                cursor_pos = 0;
                update_suggestions(&buf, &mut suggestions, &mut selected, working_dir, &mut file_cache);
                prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
            }

            // ── Ctrl-W: delete word before cursor ─────────────────────────────
            (KeyCode::Char('w'), KeyModifiers::CONTROL) => {
                // Delete backwards to the previous word boundary.
                while cursor_pos > 0 && buf[cursor_pos - 1] == ' ' {
                    cursor_pos -= 1;
                    buf.remove(cursor_pos);
                }
                while cursor_pos > 0 && buf[cursor_pos - 1] != ' ' {
                    cursor_pos -= 1;
                    buf.remove(cursor_pos);
                }
                update_suggestions(&buf, &mut suggestions, &mut selected, working_dir, &mut file_cache);
                prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
            }

            // ── Ctrl-A / Home: go to start of line ───────────────────────────
            (KeyCode::Char('a'), KeyModifiers::CONTROL) | (KeyCode::Home, _) => {
                cursor_pos = 0;
                prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
            }

            // ── Ctrl-E / End: go to end of line ──────────────────────────────
            (KeyCode::Char('e'), KeyModifiers::CONTROL) | (KeyCode::End, _) => {
                cursor_pos = buf.len();
                prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
            }

            // ── Left arrow: move cursor left ──────────────────────────────────
            (KeyCode::Left, _) => {
                if cursor_pos > 0 {
                    cursor_pos -= 1;
                    prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
                }
            }

            // ── Right arrow: move cursor right ────────────────────────────────
            (KeyCode::Right, _) => {
                if cursor_pos < buf.len() {
                    cursor_pos += 1;
                    prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
                }
            }

            // ── Up arrow: navigate suggestions UP or history UP ───────────────
            (KeyCode::Up, _) => {
                if !suggestions.is_empty() {
                    // Cycle through suggestions upward.
                    selected = if selected == 0 {
                        suggestions.len() - 1
                    } else {
                        selected - 1
                    };
                    prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
                } else {
                    // History navigation.
                    let current: String = buf.iter().collect();
                    if let Some(entry) = history.up(&current) {
                        buf = entry.chars().collect();
                        cursor_pos = buf.len();
                        update_suggestions(&buf, &mut suggestions, &mut selected, working_dir, &mut file_cache);
                    prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
                    }
                }
            }

            // ── Down arrow: navigate suggestions DOWN or history DOWN ─────────
            (KeyCode::Down, _) => {
                if !suggestions.is_empty() {
                    // Cycle through suggestions downward.
                    selected = if selected + 1 >= suggestions.len() {
                        0
                    } else {
                        selected + 1
                    };
                    prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
                } else {
                    // History navigation.
                    if let Some(entry) = history.down() {
                        buf = entry.chars().collect();
                        cursor_pos = buf.len();
                        update_suggestions(&buf, &mut suggestions, &mut selected, working_dir, &mut file_cache);
                    prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
                    }
                }
            }

            // ── Tab: complete the selected suggestion ─────────────────────────
            (KeyCode::Tab, _) => {
                if !suggestions.is_empty() {
                    let chosen = suggestions[selected].clone();
                    buf = format!("{} ", chosen).chars().collect();
                    cursor_pos = buf.len();
                    suggestions.clear();
                    erase_suggestions(&mut stdout, prev_sug_count)?;
                    prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &[], 0, 0)?;
                }
            }

            // ── Escape: clear suggestions / clear line ────────────────────────
            (KeyCode::Esc, _) => {
                if !suggestions.is_empty() {
                    suggestions.clear();
                    erase_suggestions(&mut stdout, prev_sug_count)?;
                    prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &[], 0, 0)?;
                } else {
                    // Clear buffer.
                    buf.clear();
                    cursor_pos = 0;
                    prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &[], 0, 0)?;
                }
            }

            // ── Backspace: delete char before cursor ──────────────────────────
            (KeyCode::Backspace, _) => {
                if cursor_pos > 0 {
                    cursor_pos -= 1;
                    buf.remove(cursor_pos);
                    update_suggestions(&buf, &mut suggestions, &mut selected, working_dir, &mut file_cache);
                    prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
                }
            }

            // ── Delete: delete char under cursor ──────────────────────────────
            (KeyCode::Delete, _) => {
                if cursor_pos < buf.len() {
                    buf.remove(cursor_pos);
                    update_suggestions(&buf, &mut suggestions, &mut selected, working_dir, &mut file_cache);
                    prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
                }
            }

            // ── Regular character: insert at cursor ───────────────────────────
            (KeyCode::Char(ch), mods)
                if mods.is_empty() || mods == KeyModifiers::SHIFT =>
            {
                buf.insert(cursor_pos, ch);
                cursor_pos += 1;
                update_suggestions(&buf, &mut suggestions, &mut selected, working_dir, &mut file_cache);
                prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
            }

            // Ignore everything else (F-keys, Ctrl+other, Alt+…, etc.)
            _ => {}
        }
    }; // end loop

    // ── Restore cooked mode ───────────────────────────────────────────────────
    terminal::disable_raw_mode()?;
    Ok(result)
}

// ── Suggestion filtering ──────────────────────────────────────────────────────

/// Recompute which commands match the current buffer.
/// Called after every character insertion/deletion.
fn update_suggestions(buf: &[char], suggestions: &mut Vec<String>, selected: &mut usize, working_dir: Option<&std::path::Path>, file_cache: &mut Option<Vec<String>>) {
    let line: String = buf.iter().collect();
    if line.starts_with('/') {
        let new_sug: Vec<String> = COMMANDS
            .iter()
            .filter(|c| c.cmd.starts_with(line.as_str()))
            .map(|c| c.cmd.to_string())
            .collect();
        if !new_sug.is_empty() {
            if *selected >= new_sug.len() {
                *selected = 0;
            }
        } else {
            *selected = 0;
        }
        *suggestions = new_sug;
    } else if let Some(at_idx) = line.rfind('@') {
        let partial = &line[at_idx+1..];
        // If file_cache is empty, walk the directory and cache results
        if let (None, Some(wd)) = (file_cache.as_ref(), working_dir) {
            let mut files = Vec::new();
            fn walk(root: &std::path::Path, dir: &std::path::Path, depth: usize, files: &mut Vec<String>) {
                if depth > 4 || files.len() >= 100 { return; }
                if let Ok(entries) = std::fs::read_dir(dir) {
                    for entry in entries.flatten() {
                        let path = entry.path();
                        let fname = path.file_name().and_then(|f| f.to_str()).unwrap_or("");
                        if fname.starts_with('.') { continue; }
                        if fname == "target" || fname == "node_modules" || fname == ".git" { continue; }
                        if path.is_dir() {
                            walk(root, &path, depth+1, files);
                        } else if let Ok(rel) = path.strip_prefix(root) {
                            if let Some(rel_str) = rel.to_str() {
                                files.push(rel_str.replace("\\", "/"));
                            }
                        }
                        if files.len() >= 100 { break; }
                    }
                }
            }
            walk(wd, wd, 0, &mut files);
            *file_cache = Some(files);
        }
        // Filter from cache
        let filtered = file_cache.as_ref().map(|cache| {
            cache.iter()
                .filter(|f| f.to_lowercase().contains(&partial.to_lowercase()))
                .take(10)
                .cloned()
                .collect::<Vec<String>>()
        }).unwrap_or_default();
        if !filtered.is_empty() {
            if *selected >= filtered.len() {
                *selected = 0;
            }
        } else {
            *selected = 0;
        }
        *suggestions = filtered;
    } else {
        suggestions.clear();
        *selected = 0;
    }
}
// ── Terminal rendering ────────────────────────────────────────────────────────

/// Erase `count` suggestion lines below the input line, then move the cursor
/// back up to the input line.
///
/// We call this before every redraw so we never leave ghost lines behind.
fn erase_suggestions(stdout: &mut impl Write, count: usize) -> io::Result<()> {
    if count == 0 {
        return Ok(());
    }
    for _ in 0..count {
        // Move down one line, clear it.
        stdout
            .queue(cursor::MoveDown(1))?
            .queue(terminal::Clear(ClearType::CurrentLine))?;
    }
    // Move back up to the input line.
    stdout.queue(cursor::MoveUp(count as u16))?;
    stdout.flush()?;
    Ok(())
}

/// Redraw the input line + suggestion list from scratch.
///
/// Steps:
///   1. Move to column 0, clear the line.
///   2. Print prompt + buffer.
///   3. Erase old suggestion lines (if any were rendered before).
///   4. Print new suggestion lines below.
///   5. Move cursor back to the input line at the correct column.
fn redraw_line(
    stdout: &mut impl Write,
    prompt: &str,
    buf: &[char],
    cursor_pos: usize,
    suggestions: &[String],
    selected: usize,
    prev_sug_count: usize,
) -> io::Result<usize> {
    let line: String = buf.iter().collect();

    // ── 1. Redraw input line ──────────────────────────────────────────────────
    // Move to start of line and clear it.
    stdout
        .queue(cursor::MoveToColumn(0))?
        .queue(terminal::Clear(ClearType::CurrentLine))?
        // Print prompt (contains ANSI from `console` crate — use Print so they
        // pass through verbatim).
        .queue(Print(prompt))?
        .queue(Print(&line))?;

    // ── 1b. Erase any previously-rendered suggestion lines ─────────────────
    erase_suggestions(stdout, prev_sug_count)?;

    // ── 2. Print suggestion lines ─────────────────────────────────────────────
    // We need to know how many *previous* suggestion lines we rendered so we
    // can erase them.  We always re-render all of them, so just erase the
    // current count below the input line first.
    //
    // Approach: move down/up around the suggestion area.
    if !suggestions.is_empty() {
        // Compute the longest command name for alignment.
        let max_cmd_len = suggestions.iter().map(|s| s.len()).max().unwrap_or(0);

        for (i, cmd) in suggestions.iter().enumerate() {
            // Move to the next line, go to column 0, clear it.
            stdout
                .queue(Print("\r\n"))?
                .queue(terminal::Clear(ClearType::CurrentLine))?;

            // If this is a file suggestion (not a command), no description.
            let desc = if cmd.starts_with('/') {
                COMMANDS
                    .iter()
                    .find(|c| c.cmd == cmd)
                    .map(|c| c.desc)
                    .unwrap_or("")
            } else {
                ""
            };

            if i == selected {
                // Highlighted row: white text.
                stdout
                    .queue(SetForegroundColor(Color::White))?
                    .queue(Print(format!("  {:<width$}  {}", cmd, desc, width = max_cmd_len)))?
                    .queue(ResetColor)?;
            } else {
                // Dimmed row: dark grey.
                stdout
                    .queue(SetForegroundColor(Color::DarkGrey))?
                    .queue(Print(format!("  {:<width$}  {}", cmd, desc, width = max_cmd_len)))?
                    .queue(ResetColor)?;
            }
        }

        // Move back up to the input line.
        stdout.queue(cursor::MoveUp(suggestions.len() as u16))?;
    }

    // ── 3. Position the cursor correctly on the input line ───────────────────
    // `prompt` may contain ANSI escape sequences (from `console::style`).
    // We need the *visible* character width of the prompt, not its byte length.
    // Use `console::measure_text_width` for this.
    let prompt_visible_width = console::measure_text_width(prompt);
    // Cursor column = prompt width + cursor position in the buffer.
    let buf_display_width: usize = buf[..cursor_pos].iter().map(|c| c.width().unwrap_or(0)).sum();
    let col = (prompt_visible_width + buf_display_width) as u16;
    stdout.queue(cursor::MoveToColumn(col))?;

    stdout.flush()?;
    Ok(suggestions.len())
}