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 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 fn pending_completion(&self) -> Option<String> {
129 self.0.lock().ok()?.applied.clone()
130 }
131
132 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 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
187const 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
203fn 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
217pub 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
274fn 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
290struct 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 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
487const RESIZE_RETRIES: usize = 32;
490
491fn 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
532struct 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 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 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
582struct 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 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 .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 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 assert!(palette_token("look at /etc", 12).is_none());
881 assert!(palette_token("plain words", 11).is_none());
882 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 assert!(path_candidates("t", root).is_empty());
906 assert!(
907 path_candidates("", root)
908 .iter()
909 .all(|c| c.completion != "@.git/")
910 );
911 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 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}