Skip to main content

a_agent/tui/
input.rs

1use std::borrow::Cow;
2use std::io;
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::{Arc, Mutex};
6
7use dialoguer::Select;
8use rustyline::completion::{Completer, Pair};
9use rustyline::error::ReadlineError;
10use rustyline::highlight::Highlighter;
11use rustyline::hint::{Hint, Hinter};
12use rustyline::history::DefaultHistory;
13use rustyline::validate::Validator;
14use rustyline::{
15    Cmd, ConditionalEventHandler, Context, Editor, Event, EventContext, EventHandler, Helper,
16    KeyCode, KeyEvent, Modifiers, Movement, RepeatCount,
17};
18use unicode_width::UnicodeWidthStr;
19
20use super::InlineRenderer;
21
22const SLASH_COMMANDS: &[SlashCommand] = &[
23    SlashCommand::new("/model", "/model [profile]", "Switch model profile"),
24    SlashCommand::new("/effort", "/effort [level]", "Set reasoning effort"),
25    SlashCommand::new("/thinking", "/thinking", "Toggle reasoning visibility"),
26    SlashCommand::new("/status", "/status", "Show session and model status"),
27    SlashCommand::new("/clear", "/clear", "Clear the current conversation"),
28    SlashCommand::new("/compact", "/compact", "Summarize the current conversation"),
29    SlashCommand::new(
30        "/resume",
31        "/resume [session-id]",
32        "Resume a session in this cwd",
33    ),
34    SlashCommand::new("/help", "/help", "Show available commands"),
35];
36
37struct SlashCommand {
38    name: &'static str,
39    usage: &'static str,
40    description: &'static str,
41}
42
43impl SlashCommand {
44    const fn new(name: &'static str, usage: &'static str, description: &'static str) -> Self {
45        Self {
46            name,
47            usage,
48            description,
49        }
50    }
51}
52
53#[derive(Default)]
54struct PaletteState(Mutex<PaletteSelection>);
55
56#[derive(Default)]
57struct PaletteSelection {
58    prefix: String,
59    from: Option<String>,
60    applied: Option<String>,
61    re_anchor: bool,
62    index: usize,
63}
64
65impl PaletteState {
66    /// Returns the token the palette filters on. Navigation writes the
67    /// highlighted entry into the input, so the buffer alone cannot be the
68    /// filter: once it holds a full entry the list would collapse to that one
69    /// row. What the user typed is kept while the buffer holds either side of a
70    /// completion this palette requested, and is refreshed as soon as the user
71    /// edits the token themselves. Accepting with Tab re-anchors the filter to
72    /// the accepted text, which is what lets `@src/` list the directory it just
73    /// completed to.
74    fn filter_prefix(&self, token: &str) -> String {
75        let Ok(mut state) = self.0.lock() else {
76            return token.to_owned();
77        };
78        let ours = state.applied.as_deref() == Some(token) || state.from.as_deref() == Some(token);
79        if state.applied.is_some() && ours {
80            if state.re_anchor && state.applied.as_deref() == Some(token) {
81                state.prefix = token.into();
82                state.re_anchor = false;
83                state.from = None;
84                state.applied = None;
85                state.index = 0;
86            }
87            return state.prefix.clone();
88        }
89        if state.prefix != token {
90            state.prefix = token.into();
91            state.index = 0;
92        }
93        state.from = None;
94        state.applied = None;
95        state.re_anchor = false;
96        state.prefix.clone()
97    }
98
99    fn selected(&self, count: usize) -> usize {
100        let Ok(mut state) = self.0.lock() else {
101            return 0;
102        };
103        state.index = state.index.min(count.saturating_sub(1));
104        state.index
105    }
106
107    fn move_selection(&self, count: usize, direction: isize) -> usize {
108        if count == 0 {
109            return 0;
110        }
111        let Ok(mut state) = self.0.lock() else {
112            return 0;
113        };
114        state.index = (state.index as isize + direction).rem_euclid(count as isize) as usize;
115        state.index
116    }
117
118    fn request_completion(&self, from: &str, to: &str, re_anchor: bool) {
119        if let Ok(mut state) = self.0.lock() {
120            state.from = Some(from.to_owned());
121            state.applied = Some(to.to_owned());
122            state.re_anchor = re_anchor;
123        }
124    }
125
126    /// The text a handler asked to complete to. Read only, so the completer
127    /// cannot disturb the selection it is being asked to render.
128    fn pending_completion(&self) -> Option<String> {
129        self.0.lock().ok()?.applied.clone()
130    }
131
132    /// The currently highlighted candidate for `token`, resolved against the
133    /// filter the user actually typed.
134    fn highlighted(&self, token: &PaletteToken<'_>, cwd: &Path) -> Option<Candidate> {
135        let filter = self.filter_prefix(token.text);
136        let filtered = PaletteToken {
137            start: token.start,
138            text: &filter,
139            kind: token.kind,
140        };
141        let mut rows = candidates(&filtered, cwd);
142        let selected = self.selected(rows.len());
143        (selected < rows.len()).then(|| rows.swap_remove(selected))
144    }
145
146    /// Moves the selection and returns the newly highlighted candidate.
147    fn navigate(
148        &self,
149        token: &PaletteToken<'_>,
150        cwd: &Path,
151        direction: isize,
152    ) -> Option<Candidate> {
153        let filter = self.filter_prefix(token.text);
154        let filtered = PaletteToken {
155            start: token.start,
156            text: &filter,
157            kind: token.kind,
158        };
159        let mut rows = candidates(&filtered, cwd);
160        let selected = self.move_selection(rows.len(), direction);
161        (selected < rows.len()).then(|| rows.swap_remove(selected))
162    }
163
164    fn clear(&self) {
165        if let Ok(mut state) = self.0.lock() {
166            state.prefix.clear();
167            state.from = None;
168            state.applied = None;
169            state.re_anchor = false;
170            state.index = 0;
171        }
172    }
173}
174
175struct AgentHint(String);
176
177impl Hint for AgentHint {
178    fn display(&self) -> &str {
179        &self.0
180    }
181
182    fn completion(&self) -> Option<&str> {
183        None
184    }
185}
186
187/// Directories that are never worth completing into.
188const SKIPPED_DIRECTORIES: &[&str] = &[".git", "node_modules", "target"];
189const PATH_ROWS: usize = 12;
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192enum PaletteKind {
193    Slash,
194    Path,
195}
196
197struct PaletteToken<'a> {
198    start: usize,
199    text: &'a str,
200    kind: PaletteKind,
201}
202
203/// Start of the last whitespace-separated token, treating `\ ` as part of the
204/// token so a mention of a path containing spaces stays one token.
205fn token_start(line: &str) -> usize {
206    let mut start = 0;
207    let mut escaped = false;
208    for (index, character) in line.char_indices() {
209        if character.is_whitespace() && !escaped {
210            start = index + character.len_utf8();
211        }
212        escaped = character == '\\' && !escaped;
213    }
214    start
215}
216
217/// Splits a line the same way, then keeps the `@` mentions with their escapes
218/// removed. Shared with the palette so completion and resolution agree on where
219/// a mention ends.
220pub fn mention_paths(line: &str) -> Vec<String> {
221    let mut mentions = Vec::new();
222    let mut token = String::new();
223    let mut escaped = false;
224    let mut push = |token: &mut String| {
225        if let Some(mention) = token.strip_prefix('@') {
226            let mention = mention.trim_end_matches([',', '.', ';', ':', ')']);
227            if !mention.is_empty() {
228                mentions.push(mention.to_owned());
229            }
230        }
231        token.clear();
232    };
233    for character in line.chars() {
234        if escaped {
235            token.push(character);
236            escaped = false;
237            continue;
238        }
239        match character {
240            '\\' => escaped = true,
241            character if character.is_whitespace() => push(&mut token),
242            character => token.push(character),
243        }
244    }
245    push(&mut token);
246    mentions
247}
248
249fn unescape_mention(text: &str) -> String {
250    let mut out = String::with_capacity(text.len());
251    let mut escaped = false;
252    for character in text.chars() {
253        if escaped {
254            out.push(character);
255            escaped = false;
256        } else if character == '\\' {
257            escaped = true;
258        } else {
259            out.push(character);
260        }
261    }
262    out
263}
264
265fn escape_mention(path: &str) -> String {
266    path.chars()
267        .flat_map(|character| {
268            let escape = character.is_whitespace() || character == '\\';
269            escape.then_some('\\').into_iter().chain([character])
270        })
271        .collect()
272}
273
274/// The token under the cursor that the palette can complete: a slash command at
275/// the start of the line, or an `@path` anywhere in it.
276fn palette_token(line: &str, position: usize) -> Option<PaletteToken<'_>> {
277    if position != line.len() {
278        return None;
279    }
280    let start = token_start(line);
281    let text = &line[start..];
282    let kind = match text.chars().next()? {
283        '/' if start == 0 => PaletteKind::Slash,
284        '@' => PaletteKind::Path,
285        _ => return None,
286    };
287    Some(PaletteToken { start, text, kind })
288}
289
290/// A palette row: the text a completion writes, plus how it is displayed.
291struct Candidate {
292    completion: String,
293    label: String,
294    detail: String,
295}
296
297fn candidates(token: &PaletteToken<'_>, cwd: &Path) -> Vec<Candidate> {
298    match token.kind {
299        PaletteKind::Slash => SLASH_COMMANDS
300            .iter()
301            .filter(|command| command.name.starts_with(token.text))
302            .map(|command| Candidate {
303                completion: command.name.into(),
304                label: command.usage.into(),
305                detail: command.description.into(),
306            })
307            .collect(),
308        PaletteKind::Path => path_candidates(&unescape_mention(&token.text[1..]), cwd),
309    }
310}
311
312fn path_candidates(typed: &str, cwd: &Path) -> Vec<Candidate> {
313    let (parent, name) = typed
314        .rsplit_once('/')
315        .map_or(("", typed), |(parent, name)| (parent, name));
316    let directory = if parent.is_empty() {
317        cwd.to_path_buf()
318    } else {
319        cwd.join(parent)
320    };
321    let Ok(entries) = std::fs::read_dir(&directory) else {
322        return Vec::new();
323    };
324    let mut rows = entries
325        .filter_map(Result::ok)
326        .filter_map(|entry| {
327            let file_name = entry.file_name().into_string().ok()?;
328            if !file_name.starts_with(name) {
329                return None;
330            }
331            if file_name.starts_with('.') && !name.starts_with('.') {
332                return None;
333            }
334            let is_directory = entry.file_type().ok()?.is_dir();
335            if is_directory && SKIPPED_DIRECTORIES.contains(&file_name.as_str()) {
336                return None;
337            }
338            let relative = if parent.is_empty() {
339                file_name.clone()
340            } else {
341                format!("{parent}/{file_name}")
342            };
343            let suffix = if is_directory { "/" } else { "" };
344            Some((
345                is_directory,
346                file_name,
347                Candidate {
348                    // Whitespace is escaped so the mention survives the split
349                    // that resolves it into a target.
350                    completion: format!("@{}{suffix}", escape_mention(&relative)),
351                    label: format!("{relative}{suffix}"),
352                    detail: if is_directory { "directory" } else { "file" }.into(),
353                },
354            ))
355        })
356        .collect::<Vec<_>>();
357    rows.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
358    rows.into_iter()
359        .take(PATH_ROWS)
360        .map(|(_, _, candidate)| candidate)
361        .collect()
362}
363
364fn candidate_row(candidate: &Candidate, selected: bool, color: bool) -> String {
365    let marker = if selected { '›' } else { ' ' };
366    let row = format!("{marker} {:<22} {}", candidate.label, candidate.detail);
367    if !color {
368        return row;
369    }
370    if selected {
371        format!("\x1b[1;36m{row}\x1b[0m")
372    } else {
373        format!("\x1b[90m{row}\x1b[0m")
374    }
375}
376
377#[derive(Debug, Clone, PartialEq, Eq)]
378pub enum InputAction {
379    Submit(String, InputMode),
380    Rewind,
381    ToggleReasoning,
382    Interrupt,
383    Eof,
384}
385
386#[derive(Debug, Clone, Copy, PartialEq, Eq)]
387pub enum InputMode {
388    Once,
389    Multi,
390}
391
392#[derive(Clone)]
393struct AgentHelper {
394    multi: Arc<AtomicBool>,
395    palette: Arc<PaletteState>,
396    cwd: PathBuf,
397}
398
399impl Completer for AgentHelper {
400    type Candidate = Pair;
401
402    fn complete(
403        &self,
404        line: &str,
405        position: usize,
406        _context: &Context<'_>,
407    ) -> rustyline::Result<(usize, Vec<Self::Candidate>)> {
408        let Some(token) = palette_token(line, position) else {
409            return Ok((0, Vec::new()));
410        };
411        let candidates = self
412            .palette
413            .pending_completion()
414            .map(|target| Pair {
415                display: target.clone(),
416                replacement: target,
417            })
418            .into_iter()
419            .collect();
420        Ok((token.start, candidates))
421    }
422}
423
424impl Hinter for AgentHelper {
425    type Hint = AgentHint;
426
427    fn hint(&self, line: &str, position: usize, _context: &Context<'_>) -> Option<AgentHint> {
428        if position != line.len() {
429            self.palette.clear();
430            return None;
431        }
432        if let Some(token) = palette_token(line, position) {
433            let filter = self.palette.filter_prefix(token.text);
434            let filtered = PaletteToken {
435                start: token.start,
436                text: &filter,
437                kind: token.kind,
438            };
439            let rows = candidates(&filtered, &self.cwd);
440            let selected = self.palette.selected(rows.len());
441            let color = std::env::var_os("NO_COLOR").is_none();
442            let rendered = rows
443                .iter()
444                .enumerate()
445                .map(|(index, candidate)| candidate_row(candidate, index == selected, color))
446                .collect::<Vec<_>>();
447            return Some(AgentHint(if rendered.is_empty() {
448                match token.kind {
449                    PaletteKind::Slash => "\n  No matching commands".into(),
450                    PaletteKind::Path => "\n  No matching paths".into(),
451                }
452            } else {
453                format!("\n{}", rendered.join("\n"))
454            }));
455        }
456        self.palette.clear();
457        let label = if self.multi.load(Ordering::SeqCst) {
458            "multi · tab"
459        } else {
460            "once · tab"
461        };
462        let terminal_width = crossterm::terminal::size()
463            .map(|(width, _)| usize::from(width))
464            .unwrap_or(80);
465        let used = 3 + UnicodeWidthStr::width(line) + UnicodeWidthStr::width(label);
466        (terminal_width > used + 1)
467            .then(|| AgentHint(format!("{}{label}", " ".repeat(terminal_width - used - 1))))
468    }
469}
470
471impl Highlighter for AgentHelper {
472    fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
473        if hint.starts_with('\n') {
474            return Cow::Borrowed(hint);
475        }
476        if self.multi.load(Ordering::SeqCst) {
477            Cow::Owned(format!("\x1b[1;35m{hint}\x1b[0m"))
478        } else {
479            Cow::Owned(format!("\x1b[90m{hint}\x1b[0m"))
480        }
481    }
482}
483
484impl Validator for AgentHelper {}
485impl Helper for AgentHelper {}
486
487/// How many times a selector redraws before giving up. A resize storm from a
488/// dragged window edge produces a burst of signals, not an endless stream.
489const RESIZE_RETRIES: usize = 32;
490
491/// Whether a read failed because a signal arrived rather than because the user
492/// pressed Ctrl+C, which `console` also reports as `Interrupted` but without an
493/// errno.
494fn is_signal_interruption(error: &io::Error) -> bool {
495    error.raw_os_error() == Some(libc::EINTR)
496}
497
498struct RewindHandler(Arc<AtomicBool>);
499
500impl ConditionalEventHandler for RewindHandler {
501    fn handle(
502        &self,
503        _event: &Event,
504        _repeat: RepeatCount,
505        _positive: bool,
506        _context: &EventContext,
507    ) -> Option<Cmd> {
508        self.0.store(true, Ordering::SeqCst);
509        Some(Cmd::Interrupt)
510    }
511}
512
513struct ReasoningHandler(Arc<Mutex<Option<(String, String)>>>);
514
515impl ConditionalEventHandler for ReasoningHandler {
516    fn handle(
517        &self,
518        _event: &Event,
519        _repeat: RepeatCount,
520        _positive: bool,
521        context: &EventContext,
522    ) -> Option<Cmd> {
523        let position = context.pos();
524        let line = context.line();
525        if let Ok(mut pending) = self.0.lock() {
526            *pending = Some((line[..position].to_owned(), line[position..].to_owned()));
527        }
528        Some(Cmd::Interrupt)
529    }
530}
531
532/// Ctrl+C throws away what is typed instead of quitting, matching every other
533/// shell prompt. Quitting is still one more Ctrl+C away on an empty line, and the
534/// discarded text stays in the kill ring, so a mistaken press is recoverable with
535/// Ctrl+Y.
536struct AbandonLine;
537
538impl ConditionalEventHandler for AbandonLine {
539    fn handle(
540        &self,
541        _event: &Event,
542        _repeat: RepeatCount,
543        _positive: bool,
544        context: &EventContext,
545    ) -> Option<Cmd> {
546        if context.line().is_empty() {
547            // Nothing to discard, so let the default interrupt exit.
548            return None;
549        }
550        Some(Cmd::Kill(Movement::WholeBuffer))
551    }
552}
553
554struct TabHandler {
555    multi: Arc<AtomicBool>,
556    palette: Arc<PaletteState>,
557    cwd: PathBuf,
558}
559
560impl ConditionalEventHandler for TabHandler {
561    fn handle(
562        &self,
563        _event: &Event,
564        _repeat: RepeatCount,
565        _positive: bool,
566        context: &EventContext,
567    ) -> Option<Cmd> {
568        if let Some(token) = palette_token(context.line(), context.pos()) {
569            if let Some(candidate) = self.palette.highlighted(&token, &self.cwd) {
570                // Accepting re-anchors the filter, so completing to a directory
571                // lists that directory on the next keystroke.
572                self.palette
573                    .request_completion(token.text, &candidate.completion, true);
574            }
575            return Some(Cmd::Complete);
576        }
577        self.multi.fetch_xor(true, Ordering::SeqCst);
578        Some(Cmd::Repaint)
579    }
580}
581
582/// Rustyline maps one key press to one command, so Enter cannot both rewrite the
583/// buffer and submit it. When the input still holds the prefix the user typed,
584/// Enter completes it to the highlighted entry instead of submitting, so the
585/// transcript never records a line that differs from what ran.
586struct PaletteSubmit {
587    palette: Arc<PaletteState>,
588    cwd: PathBuf,
589}
590
591impl ConditionalEventHandler for PaletteSubmit {
592    fn handle(
593        &self,
594        _event: &Event,
595        _repeat: RepeatCount,
596        _positive: bool,
597        context: &EventContext,
598    ) -> Option<Cmd> {
599        let token = palette_token(context.line(), context.pos())?;
600        let candidate = self.palette.highlighted(&token, &self.cwd)?;
601        if candidate.completion == token.text {
602            return None;
603        }
604        self.palette
605            .request_completion(token.text, &candidate.completion, true);
606        Some(Cmd::Complete)
607    }
608}
609
610struct PaletteNavigation {
611    palette: Arc<PaletteState>,
612    direction: isize,
613    cwd: PathBuf,
614}
615
616impl ConditionalEventHandler for PaletteNavigation {
617    fn handle(
618        &self,
619        _event: &Event,
620        _repeat: RepeatCount,
621        _positive: bool,
622        context: &EventContext,
623    ) -> Option<Cmd> {
624        let token = palette_token(context.line(), context.pos())?;
625        let candidate = self.palette.navigate(&token, &self.cwd, self.direction)?;
626        // Arrow navigation keeps the typed filter so siblings stay reachable.
627        self.palette
628            .request_completion(token.text, &candidate.completion, false);
629        Some(Cmd::Complete)
630    }
631}
632
633pub struct InputEditor {
634    editor: Editor<AgentHelper, DefaultHistory>,
635    rewind_requested: Arc<AtomicBool>,
636    reasoning_requested: Arc<Mutex<Option<(String, String)>>>,
637    pending_initial: Option<(String, String)>,
638    reasoning_key: char,
639    multi: Arc<AtomicBool>,
640}
641
642impl InputEditor {
643    pub fn with_reasoning_toggle(value: &str, cwd: impl Into<PathBuf>) -> io::Result<Self> {
644        let cwd = cwd.into();
645        let reasoning_key = value
646            .strip_prefix("ctrl-")
647            .and_then(|value| {
648                let mut characters = value.chars();
649                let key = characters.next()?;
650                characters.next().is_none().then_some(key)
651            })
652            .ok_or_else(|| {
653                io::Error::new(
654                    io::ErrorKind::InvalidInput,
655                    "reasoning toggle must use ctrl-<character>",
656                )
657            })?;
658        let rewind_requested = Arc::new(AtomicBool::new(false));
659        let reasoning_requested = Arc::new(Mutex::new(None));
660        let multi = Arc::new(AtomicBool::new(true));
661        let palette = Arc::new(PaletteState::default());
662        let editor_config = rustyline::Config::builder()
663            .keyseq_timeout(Some(500))
664            // Circular completion runs its own key loop, which swallows the next
665            // arrow press and restores the pre-completion buffer. List applies a
666            // single candidate and returns immediately.
667            .completion_type(rustyline::CompletionType::List)
668            .build();
669        let mut editor = Editor::<AgentHelper, DefaultHistory>::with_config(editor_config)
670            .map_err(io::Error::other)?;
671        editor.set_helper(Some(AgentHelper {
672            multi: multi.clone(),
673            palette: palette.clone(),
674            cwd: cwd.clone(),
675        }));
676        editor.bind_sequence(
677            Event::KeySeq(vec![KeyEvent::from('\x1b'), KeyEvent::from('\x1b')]),
678            EventHandler::Conditional(Box::new(RewindHandler(rewind_requested.clone()))),
679        );
680        editor.bind_sequence(
681            KeyEvent(KeyCode::Esc, Modifiers::ALT),
682            EventHandler::Conditional(Box::new(RewindHandler(rewind_requested.clone()))),
683        );
684        editor.bind_sequence(
685            KeyEvent(KeyCode::Esc, Modifiers::NONE),
686            EventHandler::Conditional(Box::new(RewindHandler(rewind_requested.clone()))),
687        );
688        editor.bind_sequence(
689            KeyEvent::ctrl('c'),
690            EventHandler::Conditional(Box::new(AbandonLine)),
691        );
692        editor.bind_sequence(
693            KeyEvent::ctrl(reasoning_key),
694            EventHandler::Conditional(Box::new(ReasoningHandler(reasoning_requested.clone()))),
695        );
696        editor.bind_sequence(
697            KeyEvent(KeyCode::Tab, Modifiers::NONE),
698            EventHandler::Conditional(Box::new(TabHandler {
699                multi: multi.clone(),
700                palette: palette.clone(),
701                cwd: cwd.clone(),
702            })),
703        );
704        editor.bind_sequence(
705            KeyEvent(KeyCode::Enter, Modifiers::NONE),
706            EventHandler::Conditional(Box::new(PaletteSubmit {
707                palette: palette.clone(),
708                cwd: cwd.clone(),
709            })),
710        );
711        editor.bind_sequence(
712            KeyEvent(KeyCode::Up, Modifiers::NONE),
713            EventHandler::Conditional(Box::new(PaletteNavigation {
714                palette: palette.clone(),
715                direction: -1,
716                cwd: cwd.clone(),
717            })),
718        );
719        editor.bind_sequence(
720            KeyEvent(KeyCode::Down, Modifiers::NONE),
721            EventHandler::Conditional(Box::new(PaletteNavigation {
722                palette: palette.clone(),
723                direction: 1,
724                cwd,
725            })),
726        );
727        Ok(Self {
728            editor,
729            rewind_requested,
730            reasoning_requested,
731            pending_initial: None,
732            reasoning_key,
733            multi,
734        })
735    }
736
737    pub fn reasoning_key(&self) -> char {
738        self.reasoning_key
739    }
740
741    pub fn is_reasoning_toggle(
742        &self,
743        code: crossterm::event::KeyCode,
744        modifiers: crossterm::event::KeyModifiers,
745    ) -> bool {
746        matches!(code, crossterm::event::KeyCode::Char(character) if character == self.reasoning_key)
747            && modifiers.contains(crossterm::event::KeyModifiers::CONTROL)
748    }
749
750    pub fn read_action(&mut self) -> io::Result<InputAction> {
751        let prompt = ("a> ", "\x1b[1;36ma> \x1b[0m");
752        let result = if let Some((left, right)) = self.pending_initial.take() {
753            self.editor.readline_with_initial(&prompt, (&left, &right))
754        } else {
755            self.editor.readline(&prompt)
756        };
757        match result {
758            Ok(line) => {
759                if !line.trim().is_empty() {
760                    self.editor
761                        .add_history_entry(line.as_str())
762                        .map_err(io::Error::other)?;
763                }
764                let mode = if self.multi.load(Ordering::SeqCst) {
765                    InputMode::Multi
766                } else {
767                    InputMode::Once
768                };
769                Ok(InputAction::Submit(line, mode))
770            }
771            Err(ReadlineError::Interrupted)
772                if self.rewind_requested.swap(false, Ordering::SeqCst) =>
773            {
774                Ok(InputAction::Rewind)
775            }
776            Err(ReadlineError::Interrupted) if self.take_reasoning_request() => {
777                Ok(InputAction::ToggleReasoning)
778            }
779            Err(ReadlineError::Interrupted) => Ok(InputAction::Interrupt),
780            Err(ReadlineError::Eof) => Ok(InputAction::Eof),
781            Err(error) => Err(io::Error::other(error)),
782        }
783    }
784
785    pub fn add_history_entries(&mut self, entries: &[String]) -> io::Result<()> {
786        for entry in entries {
787            self.editor
788                .add_history_entry(entry.as_str())
789                .map_err(io::Error::other)?;
790        }
791        Ok(())
792    }
793
794    pub fn select_option(
795        &mut self,
796        prompt: &str,
797        choices: &[String],
798        default: usize,
799    ) -> io::Result<Option<usize>> {
800        if choices.is_empty() {
801            return Ok(None);
802        }
803        let default = default.min(choices.len() - 1);
804        // A selector reads keys through select(2), which returns EINTR whenever a
805        // signal arrives — and a window resize sends SIGWINCH, for which a handler
806        // is installed for the rest of the process once a turn has run. Losing the
807        // menu because the window changed size would be absurd, so the menu is
808        // redrawn and the read retried. The old frame is erased first so the
809        // resize does not leave a second menu behind.
810        for _ in 0..RESIZE_RETRIES {
811            match Select::new()
812                .with_prompt(prompt)
813                .items(choices)
814                .default(default)
815                .interact_opt()
816            {
817                Ok(choice) => return Ok(choice),
818                Err(dialoguer::Error::IO(error)) if is_signal_interruption(&error) => {
819                    let term = dialoguer::console::Term::stderr();
820                    let drawn = choices.len() + 1;
821                    let height = usize::from(term.size().0).saturating_sub(1);
822                    term.clear_last_lines(drawn.min(height.max(1)))?;
823                }
824                Err(error) => return Err(io::Error::other(error)),
825            }
826        }
827        Err(io::Error::other(
828            "the terminal kept interrupting the selection",
829        ))
830    }
831
832    fn take_reasoning_request(&mut self) -> bool {
833        let Ok(mut requested) = self.reasoning_requested.lock() else {
834            return false;
835        };
836        let Some(initial) = requested.take() else {
837            return false;
838        };
839        self.pending_initial = Some(initial);
840        true
841    }
842
843    pub fn select_checkpoint(
844        &mut self,
845        checkpoints: &[(String, String)],
846        renderer: &InlineRenderer,
847    ) -> io::Result<Option<String>> {
848        if checkpoints.is_empty() {
849            renderer.render_status("no user messages to rewind to")?;
850            return Ok(None);
851        }
852        let labels = checkpoints
853            .iter()
854            .map(|(_, label)| label.clone())
855            .collect::<Vec<_>>();
856        let Some(index) = self.select_option("Rewind to", &labels, 0)? else {
857            return Ok(None);
858        };
859        Ok(Some(checkpoints[index].0.clone()))
860    }
861}
862
863#[cfg(test)]
864mod tests {
865    use super::*;
866
867    #[test]
868    fn slash_tokens_only_start_a_line_while_mentions_can_appear_anywhere() {
869        let slash = palette_token("/mo", 3).expect("slash token");
870        assert_eq!(slash.kind, PaletteKind::Slash);
871        assert_eq!(slash.text, "/mo");
872        assert_eq!(slash.start, 0);
873
874        let mention = palette_token("fix @src/pa", 11).expect("path token");
875        assert_eq!(mention.kind, PaletteKind::Path);
876        assert_eq!(mention.text, "@src/pa");
877        assert_eq!(mention.start, 4);
878
879        // A slash that is not the first token is a path separator, not a command.
880        assert!(palette_token("look at /etc", 12).is_none());
881        assert!(palette_token("plain words", 11).is_none());
882        // Completion only applies at the end of the line.
883        assert!(palette_token("@src", 2).is_none());
884    }
885
886    #[test]
887    fn path_candidates_put_directories_first_and_hide_noise() {
888        let temp = tempfile::tempdir().unwrap();
889        let root = temp.path();
890        std::fs::create_dir_all(root.join("src")).unwrap();
891        std::fs::create_dir_all(root.join("target")).unwrap();
892        std::fs::create_dir_all(root.join(".git")).unwrap();
893        std::fs::write(root.join("srcfile.rs"), "").unwrap();
894        std::fs::write(root.join(".srchidden"), "").unwrap();
895        std::fs::write(root.join("other.rs"), "").unwrap();
896
897        let rows = path_candidates("s", root);
898        let completions = rows
899            .iter()
900            .map(|candidate| candidate.completion.as_str())
901            .collect::<Vec<_>>();
902        assert_eq!(completions, vec!["@src/", "@srcfile.rs"], "{completions:?}");
903
904        // Ignored directories stay out even when they match the prefix.
905        assert!(path_candidates("t", root).is_empty());
906        assert!(
907            path_candidates("", root)
908                .iter()
909                .all(|c| c.completion != "@.git/")
910        );
911        // Hidden entries appear once a dot is typed.
912        let hidden = path_candidates(".src", root);
913        assert_eq!(hidden.len(), 1, "{:?}", hidden[0].completion);
914        assert_eq!(hidden[0].completion, "@.srchidden");
915    }
916
917    #[test]
918    fn mentions_of_paths_with_spaces_survive_completion_and_resolution() {
919        let temp = tempfile::tempdir().unwrap();
920        let root = temp.path();
921        std::fs::write(root.join("name with space.txt"), "").unwrap();
922
923        let candidate = path_candidates("name", root).remove(0);
924        assert_eq!(candidate.completion, r"@name\ with\ space.txt");
925        // The escaped mention stays one token, both for the palette and for the
926        // resolver, so completion cannot produce a mention that silently fails.
927        let line = format!("review {}", candidate.completion);
928        let token = palette_token(&line, line.len()).expect("token");
929        assert_eq!(token.text, r"@name\ with\ space.txt");
930        assert_eq!(mention_paths(&line), vec!["name with space.txt".to_owned()]);
931        assert_eq!(
932            path_candidates(&unescape_mention(&token.text[1..]), root).len(),
933            1
934        );
935    }
936
937    #[test]
938    fn mentions_are_split_off_trailing_punctuation_and_other_words() {
939        assert_eq!(
940            mention_paths("compare @src/a.rs and @src/b.rs, please"),
941            vec!["src/a.rs".to_owned(), "src/b.rs".to_owned()]
942        );
943        assert!(mention_paths("no mentions here").is_empty());
944        assert!(mention_paths("@").is_empty());
945    }
946
947    #[test]
948    fn descending_into_a_directory_lists_its_contents() {
949        let temp = tempfile::tempdir().unwrap();
950        let root = temp.path();
951        std::fs::create_dir_all(root.join("src")).unwrap();
952        std::fs::write(root.join("src/parser.rs"), "").unwrap();
953        std::fs::write(root.join("src/main.rs"), "").unwrap();
954
955        let rows = path_candidates("src/", root);
956        let completions = rows
957            .iter()
958            .map(|candidate| candidate.completion.as_str())
959            .collect::<Vec<_>>();
960        assert_eq!(completions, vec!["@src/main.rs", "@src/parser.rs"]);
961    }
962}