Skip to main content

tau_cli_term/
completion.rs

1//! Command-mode and argument completion content + menu rendering.
2//!
3//! State and lifecycle live in [`tau_cli_term_raw`]; this module
4//! supplies the *content* (which candidates exist for a given buffer)
5//! and the *presentation* (how the menu block is laid out and styled).
6//!
7//! Public types:
8//! - [`CommandCompletion`] — command completion entry
9//! - [`CompletionItem`] / [`CompletionData`] — dynamic argument completions
10//! - [`build_candidates`] — turns the current buffer into a `Vec<Candidate>`
11//! - [`render_menu_block`] — turns a [`CompletionView`] into a [`StyledBlock`]
12
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use std::sync::{Arc, Mutex};
16use std::{collections as path_std_collections, fmt};
17
18use tau_cli_term_raw::{
19    Candidate, CompletionAcceptance, CompletionView, Span, StyledBlock, StyledText,
20};
21use tau_term_screen::{display_width, truncate_to_width};
22use tau_themes::Theme;
23
24use crate::resolve;
25
26mod git_files;
27
28/// One command-mode token, including its leading `:` (for example, `":model"`).
29#[derive(Clone, Debug, PartialEq, Eq, Hash)]
30pub struct CommandName(String);
31
32impl CommandName {
33    /// Creates a command name and asserts it follows the command-token grammar.
34    ///
35    /// # Panics
36    ///
37    /// Panics unless `name` is one colon followed by an ASCII alphanumeric
38    /// character and then only ASCII alphanumeric, `_`, or `-` characters.
39    pub fn new(name: impl Into<String>) -> Self {
40        let s = name.into();
41        assert!(
42            is_valid_command_name(&s),
43            "CommandName must be ':' followed by one command token"
44        );
45        Self(s)
46    }
47
48    /// Returns the command name as a string slice.
49    pub fn as_str(&self) -> &str {
50        &self.0
51    }
52}
53
54impl fmt::Display for CommandName {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        f.write_str(&self.0)
57    }
58}
59
60/// One command-mode completion entry with its display description.
61#[derive(Clone, Debug)]
62pub struct CommandCompletion {
63    /// Command token typed by the user, including the leading `:`.
64    pub name: CommandName,
65    /// Human-readable description shown in the completion menu.
66    pub description: String,
67}
68
69impl CommandCompletion {
70    /// Creates a command-mode entry with a display description for completion
71    /// menus.
72    ///
73    /// # Panics
74    ///
75    /// Panics when `name` does not satisfy [`CommandName::new`].
76    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
77        Self {
78            name: CommandName::new(name),
79            description: description.into(),
80        }
81    }
82}
83
84pub(crate) fn is_valid_command_name(name: &str) -> bool {
85    let Some(token) = name.strip_prefix(':') else {
86        return false;
87    };
88    let mut chars = token.chars();
89    chars.next().is_some_and(|ch| ch.is_ascii_alphanumeric())
90        && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-'))
91}
92
93/// A single argument completion candidate.
94#[derive(Clone, Debug)]
95pub struct CompletionItem {
96    /// Text inserted into the prompt when this completion is accepted.
97    pub value: String,
98    /// Human-readable description shown beside the value in the menu.
99    pub description: String,
100}
101
102impl CompletionItem {
103    /// Creates an argument completion with a menu description.
104    pub fn new(value: impl Into<String>, description: impl Into<String>) -> Self {
105        Self {
106            value: value.into(),
107            description: description.into(),
108        }
109    }
110
111    /// Creates an argument completion with no menu description.
112    pub fn plain(value: impl Into<String>) -> Self {
113        Self {
114            value: value.into(),
115            description: String::new(),
116        }
117    }
118}
119
120/// Closure that produces argument completions for a command,
121/// given the already-typed args (the last element is the partial arg
122/// being completed; may be empty for "just typed a space").
123///
124/// The closure is responsible for filtering and ranking — callers do
125/// no further processing. For the common flat-list case use
126/// [`CompletionData::set_arg_completions`], which builds an appropriate
127/// closure internally.
128pub type ArgCompleter = Arc<dyn Fn(&[&str]) -> Vec<CompletionItem> + Send + Sync>;
129
130/// Mutable completion state shared with background renderer updates.
131#[derive(Default)]
132struct CompletionInner {
133    /// Description replacements for intrinsic command-root candidates.
134    static_command_descriptions: HashMap<CommandName, String>,
135    arg_completers: HashMap<CommandName, ArgCompleter>,
136    dynamic_arg_completers: HashMap<CommandName, ArgCompleter>,
137    dynamic_commands: Vec<CommandCompletion>,
138    agent_mention_completer: Option<ArgCompleter>,
139    session_completer: Option<ArgCompleter>,
140}
141
142/// Thread-safe storage for dynamic command and argument completions.
143///
144/// Clone this handle and pass it to background threads that need to
145/// update available completions (e.g. when the harness sends a model
146/// list or an extension publishes an action schema).
147#[derive(Clone, Default)]
148pub struct CompletionData {
149    inner: Arc<Mutex<CompletionInner>>,
150}
151
152impl CompletionData {
153    /// Creates empty, shareable dynamic completion storage.
154    pub fn new() -> Self {
155        Self::default()
156    }
157
158    /// Replaces extension-provided root commands shown alongside the
159    /// static command registry.
160    pub fn set_dynamic_commands(&self, commands: Vec<CommandCompletion>) {
161        self.set_dynamic_commands_and_arg_completers(commands, Vec::new());
162    }
163
164    /// Replaces extension-provided root commands and their nested
165    /// argument/subcommand completers as one atomic snapshot.
166    pub fn set_dynamic_commands_and_arg_completers(
167        &self,
168        commands: Vec<CommandCompletion>,
169        arg_completers: Vec<(CommandName, ArgCompleter)>,
170    ) {
171        let mut inner = self.inner.lock().expect("completion data lock");
172        inner.dynamic_commands = commands;
173        inner.dynamic_arg_completers = arg_completers.into_iter().collect();
174    }
175
176    /// Replaces descriptions for existing intrinsic command-root candidates.
177    ///
178    /// This changes display text only: it cannot add a command or alter its
179    /// insertion text.
180    pub fn set_static_command_descriptions(
181        &self,
182        descriptions: impl IntoIterator<Item = (CommandName, String)>,
183    ) {
184        self.inner
185            .lock()
186            .expect("completion data lock")
187            .static_command_descriptions = descriptions.into_iter().collect();
188    }
189
190    /// Sets a flat, single-arg completion list for a command.
191    /// Items are ranked prefix-match-first, substring-match-second
192    /// (case-insensitive). For commands that take more than one arg
193    /// or need to react to prior args, use
194    /// [`CompletionData::set_arg_completer`].
195    pub fn set_arg_completions(&self, command: CommandName, items: Vec<CompletionItem>) {
196        // Precompute lowercased haystacks once at insertion time so
197        // the per-keystroke match loop doesn't reallocate.
198        let indexed: Arc<Vec<(CompletionItem, String)>> = Arc::new(
199            items
200                .into_iter()
201                .map(|item| {
202                    let lower = item.value.to_lowercase();
203                    (item, lower)
204                })
205                .collect(),
206        );
207        let completer: ArgCompleter = Arc::new(move |args: &[&str]| {
208            // Single-arg completion only — multi-arg buffers fall
209            // through to no candidates.
210            if args.len() != 1 {
211                return Vec::new();
212            }
213            let needle = args[0].to_lowercase();
214            let mut prefix_matches = Vec::new();
215            let mut substr_matches = Vec::new();
216            for (item, value_lower) in indexed.iter() {
217                if needle.is_empty() || value_lower.starts_with(&needle) {
218                    prefix_matches.push(item.clone());
219                } else if value_lower.contains(&needle) {
220                    substr_matches.push(item.clone());
221                }
222            }
223            prefix_matches.extend(substr_matches);
224            prefix_matches
225        });
226        self.inner
227            .lock()
228            .expect("completion data lock")
229            .arg_completers
230            .insert(command, completer);
231    }
232
233    /// Registers a custom argument completer for a command.
234    /// The closure receives the args typed so far (with the partial
235    /// last element being completed) and returns ranked candidates.
236    pub fn set_arg_completer(&self, command: CommandName, completer: ArgCompleter) {
237        self.inner
238            .lock()
239            .expect("completion data lock")
240            .arg_completers
241            .insert(command, completer);
242    }
243
244    fn get_arg_completer(&self, command: &CommandName) -> Option<ArgCompleter> {
245        let inner = self.inner.lock().expect("completion data lock");
246        inner
247            .arg_completers
248            .get(command)
249            .or_else(|| inner.dynamic_arg_completers.get(command))
250            .cloned()
251    }
252
253    /// Registers prompt-text completion for active agent mentions typed as
254    /// `@<partial-agent-id>`.
255    pub fn set_agent_mention_completer(&self, completer: ArgCompleter) {
256        self.inner
257            .lock()
258            .expect("completion data lock")
259            .agent_mention_completer = Some(completer);
260    }
261
262    /// Registers prompt-text completion for running sessions typed as
263    /// `&<partial-session-id>`.
264    pub fn set_session_completer(&self, completer: ArgCompleter) {
265        self.inner
266            .lock()
267            .expect("completion data lock")
268            .session_completer = Some(completer);
269    }
270
271    fn get_agent_mention_completer(&self) -> Option<ArgCompleter> {
272        self.inner
273            .lock()
274            .expect("completion data lock")
275            .agent_mention_completer
276            .clone()
277    }
278
279    fn get_session_completer(&self) -> Option<ArgCompleter> {
280        self.inner
281            .lock()
282            .expect("completion data lock")
283            .session_completer
284            .clone()
285    }
286
287    fn root_commands(&self) -> (HashMap<CommandName, String>, Vec<CommandCompletion>) {
288        let inner = self.inner.lock().expect("completion data lock");
289        (
290            inner.static_command_descriptions.clone(),
291            inner.dynamic_commands.clone(),
292        )
293    }
294
295    fn dynamic_commands(&self) -> Vec<CommandCompletion> {
296        self.inner
297            .lock()
298            .expect("completion data lock")
299            .dynamic_commands
300            .clone()
301    }
302}
303
304/// Named completion behavior selected by prompt completion config.
305#[derive(Clone, Debug, PartialEq, Eq)]
306pub enum CompletionRuleKind {
307    /// Complete active agent mentions from harness-provided agent data.
308    Agents,
309    /// Complete running session identifiers from application-provided data.
310    Sessions,
311    /// Complete filesystem paths by reading the matching directory.
312    Path,
313    /// Complete filesystem paths, preferring fuzzy git-tracked file matches for
314    /// `./<partial>` inside a repository.
315    PathFuzzy,
316    /// Complete action/command names.
317    Actions,
318    /// Run an external command when the trigger token is typed exactly.
319    Command(Vec<String>),
320}
321
322/// A single prompt completion rule keyed by the word prefix that activates it.
323#[derive(Clone, Debug, PartialEq, Eq)]
324pub struct CompletionRule {
325    /// Word prefix that activates this completion rule.
326    pub prefix: String,
327    /// Completion behavior selected for the prefix.
328    pub kind: CompletionRuleKind,
329}
330
331/// Private nonempty argv retained for one configured completion command.
332#[derive(Clone, Debug, PartialEq, Eq)]
333pub(super) struct CompletionCommand {
334    /// Executable name passed as argv element zero.
335    program: String,
336    /// Remaining argv elements passed to the executable unchanged and in order.
337    args: Vec<String>,
338}
339
340impl CompletionCommand {
341    /// Converts a public command argv into its private nonempty runtime form.
342    fn from_argv(argv: Vec<String>) -> Option<Self> {
343        let mut argv = argv.into_iter();
344        Some(Self {
345            program: argv.next()?,
346            args: argv.collect(),
347        })
348    }
349
350    /// Returns the executable name selected by the public command argv.
351    pub(super) fn program(&self) -> &str {
352        &self.program
353    }
354
355    /// Returns the executable arguments after the program name.
356    pub(super) fn args(&self) -> &[String] {
357        &self.args
358    }
359}
360
361/// Exact command-completion match selected from the private runtime rules.
362pub(super) enum CommandCompletionMatch<'a> {
363    /// A public command argv with a program, ready for execution.
364    Command(&'a CompletionCommand),
365    /// A publicly constructed empty command argv, retained for the established
366    /// runtime fallback diagnostic.
367    EmptyCommand,
368}
369
370/// One completion rule retained by the private command-completion runtime.
371#[derive(Clone, Debug)]
372struct RuntimeCompletionRule {
373    /// Word prefix that activates this rule.
374    prefix: String,
375    /// Runtime behavior selected from the public rule kind.
376    kind: RuntimeCompletionRuleKind,
377}
378
379impl From<&CompletionRule> for RuntimeCompletionRule {
380    fn from(rule: &CompletionRule) -> Self {
381        Self {
382            prefix: rule.prefix.clone(),
383            kind: (&rule.kind).into(),
384        }
385    }
386}
387
388/// Runtime completion behavior with command argv made nonempty before storage.
389#[derive(Clone, Debug)]
390enum RuntimeCompletionRuleKind {
391    /// Complete active agent mentions from harness-provided agent data.
392    Agents,
393    /// Complete running session identifiers from application-provided data.
394    Sessions,
395    /// Complete filesystem paths by reading the matching directory.
396    Path,
397    /// Complete filesystem paths, preferring fuzzy git-tracked file matches.
398    PathFuzzy,
399    /// Complete action/command names.
400    Actions,
401    /// Run a configured command, or retain the public empty-argv fallback.
402    Command(Option<CompletionCommand>),
403}
404
405impl From<&CompletionRuleKind> for RuntimeCompletionRuleKind {
406    fn from(kind: &CompletionRuleKind) -> Self {
407        match kind {
408            CompletionRuleKind::Agents => Self::Agents,
409            CompletionRuleKind::Sessions => Self::Sessions,
410            CompletionRuleKind::Path => Self::Path,
411            CompletionRuleKind::PathFuzzy => Self::PathFuzzy,
412            CompletionRuleKind::Actions => Self::Actions,
413            CompletionRuleKind::Command(argv) => {
414                Self::Command(CompletionCommand::from_argv(argv.clone()))
415            }
416        }
417    }
418}
419
420impl CompletionRule {
421    /// Parses a `cli.yaml` completion entry such as `complete_path` or
422    /// `complete_with_command fzf --filter foo`.
423    pub fn parse(prefix: impl Into<String>, spec: &str) -> Option<Self> {
424        let prefix = prefix.into();
425        let mut parts = spec.split_whitespace();
426        let name = parts.next()?;
427        let kind = match name {
428            "complete_agents" => CompletionRuleKind::Agents,
429            "complete_sessions" => CompletionRuleKind::Sessions,
430            "complete_path" => CompletionRuleKind::Path,
431            "complete_path_fuzzy" => CompletionRuleKind::PathFuzzy,
432            "complete_actions" => CompletionRuleKind::Actions,
433            "complete_with_command" => {
434                let args = parts.map(ToOwned::to_owned).collect::<Vec<_>>();
435                if args.is_empty() {
436                    return None;
437                }
438                CompletionRuleKind::Command(args)
439            }
440            _ => return None,
441        };
442        Some(Self { prefix, kind })
443    }
444}
445
446/// Prompt completion rules. If multiple rules match, the longest prefix wins.
447#[derive(Clone, Debug)]
448pub struct CompletionRules {
449    /// Public/config rules retained for the public accessor and completion
450    /// menu.
451    rules: Vec<CompletionRule>,
452    /// Private command runtime derived at the public-rule construction
453    /// boundary.
454    command_rules: CompletionCommandRules,
455}
456
457impl CompletionRules {
458    /// Creates rules sorted so the longest matching prefix wins.
459    pub fn new(mut rules: Vec<CompletionRule>) -> Self {
460        rules.sort_by(|a, b| {
461            b.prefix
462                .len()
463                .cmp(&a.prefix.len())
464                .then(a.prefix.cmp(&b.prefix))
465        });
466        let command_rules = CompletionCommandRules::from_public_rules(&rules);
467        Self {
468            rules,
469            command_rules,
470        }
471    }
472
473    /// Built-in prompt completion defaults used when no config is supplied.
474    pub fn built_in() -> Self {
475        Self::new(vec![
476            CompletionRule::parse("@", "complete_agents").expect("valid built-in completion"),
477            CompletionRule::parse("&", "complete_sessions").expect("valid built-in completion"),
478            CompletionRule::parse("./", "complete_path").expect("valid built-in completion"),
479            CompletionRule::parse("../", "complete_path").expect("valid built-in completion"),
480            CompletionRule::parse("/", "complete_path").expect("valid built-in completion"),
481            CompletionRule::parse("~", "complete_path").expect("valid built-in completion"),
482            CompletionRule::parse("~/", "complete_path").expect("valid built-in completion"),
483        ])
484    }
485
486    fn matching_rule(&self, token_prefix: &str) -> Option<&CompletionRule> {
487        self.rules
488            .iter()
489            .find(|rule| token_prefix.starts_with(&rule.prefix))
490    }
491
492    /// Returns the argv and replacement surroundings for an exact command
493    /// trigger token at the cursor.
494    pub fn command_for_exact_token<'a>(
495        &'a self,
496        buffer: &'a str,
497        cursor: usize,
498    ) -> Option<(&'a [String], &'a str, &'a str)> {
499        let token = exact_command_token(buffer, cursor)?;
500        let rule = self.rules.iter().find(|rule| rule.prefix == token.prefix)?;
501        match &rule.kind {
502            CompletionRuleKind::Command(command) => Some((command, token.before, token.after)),
503            _ => None,
504        }
505    }
506
507    /// Returns the private command runtime derived with these public rules.
508    pub(super) fn command_rules(&self) -> &CompletionCommandRules {
509        &self.command_rules
510    }
511}
512
513/// Private command rules converted from the public completion-rule boundary.
514#[derive(Clone, Debug)]
515pub(super) struct CompletionCommandRules {
516    /// All rules in public rule order so duplicate-prefix behavior remains
517    /// exact.
518    rules: Vec<RuntimeCompletionRule>,
519}
520
521impl CompletionCommandRules {
522    /// Converts public completion rules at their construction boundary.
523    fn from_public_rules(rules: &[CompletionRule]) -> Self {
524        Self {
525            rules: rules.iter().map(RuntimeCompletionRule::from).collect(),
526        }
527    }
528
529    /// Returns the private command and replacement surroundings for one exact
530    /// command trigger token.
531    pub(super) fn command_for_exact_token<'a>(
532        &'a self,
533        buffer: &'a str,
534        cursor: usize,
535    ) -> Option<(CommandCompletionMatch<'a>, &'a str, &'a str)> {
536        let token = exact_command_token(buffer, cursor)?;
537        let rule = self.rules.iter().find(|rule| rule.prefix == token.prefix)?;
538        match &rule.kind {
539            RuntimeCompletionRuleKind::Command(Some(command)) => Some((
540                CommandCompletionMatch::Command(command),
541                token.before,
542                token.after,
543            )),
544            RuntimeCompletionRuleKind::Command(None) => Some((
545                CommandCompletionMatch::EmptyCommand,
546                token.before,
547                token.after,
548            )),
549            _ => None,
550        }
551    }
552}
553
554/// Finds a configured command trigger only when it occupies the complete token
555/// at the cursor outside intrinsic command mode.
556fn exact_command_token(buffer: &str, cursor: usize) -> Option<PathToken<'_>> {
557    if first_non_whitespace_starts_command(buffer) {
558        return None;
559    }
560    let token = word_token(buffer, cursor)?;
561    if buffer
562        .get(cursor..)?
563        .chars()
564        .next()
565        .is_some_and(|ch| !ch.is_whitespace())
566    {
567        return None;
568    }
569    Some(token)
570}
571
572impl Default for CompletionRules {
573    fn default() -> Self {
574        Self::built_in()
575    }
576}
577
578/// Builds the candidate list for the given buffer/cursor.
579pub fn build_candidates(
580    commands: &[CommandCompletion],
581    data: &CompletionData,
582    buffer: &str,
583    cursor: usize,
584) -> Vec<Candidate> {
585    build_candidates_with_rules(commands, data, &CompletionRules::default(), buffer, cursor)
586}
587
588/// Builds candidates using explicit prompt completion rules.
589pub fn build_candidates_with_rules(
590    commands: &[CommandCompletion],
591    data: &CompletionData,
592    rules: &CompletionRules,
593    buffer: &str,
594    cursor: usize,
595) -> Vec<Candidate> {
596    build_candidates_with_home_and_rules(
597        commands,
598        data,
599        rules,
600        buffer,
601        cursor,
602        home_dir().as_deref(),
603    )
604}
605
606#[cfg(test)]
607pub(crate) fn build_candidates_with_home(
608    commands: &[CommandCompletion],
609    data: &CompletionData,
610    buffer: &str,
611    cursor: usize,
612    home_dir: Option<&Path>,
613) -> Vec<Candidate> {
614    build_candidates_with_home_and_rules(
615        commands,
616        data,
617        &CompletionRules::default(),
618        buffer,
619        cursor,
620        home_dir,
621    )
622}
623
624pub(crate) fn build_candidates_with_home_and_rules(
625    commands: &[CommandCompletion],
626    data: &CompletionData,
627    rules: &CompletionRules,
628    buffer: &str,
629    cursor: usize,
630    home_dir: Option<&Path>,
631) -> Vec<Candidate> {
632    build_candidates_with_home_and_rules_at_cwd(
633        commands, data, rules, buffer, cursor, home_dir, None,
634    )
635}
636
637/// Builds candidates against an injected current directory for hermetic tests.
638#[cfg(test)]
639pub(crate) fn build_candidates_with_home_and_cwd(
640    commands: &[CommandCompletion],
641    data: &CompletionData,
642    buffer: &str,
643    cursor: usize,
644    home_dir: Option<&Path>,
645    cwd: &Path,
646) -> Vec<Candidate> {
647    build_candidates_with_home_and_rules_at_cwd(
648        commands,
649        data,
650        &CompletionRules::default(),
651        buffer,
652        cursor,
653        home_dir,
654        Some(cwd),
655    )
656}
657
658fn build_candidates_with_home_and_rules_at_cwd(
659    commands: &[CommandCompletion],
660    data: &CompletionData,
661    rules: &CompletionRules,
662    buffer: &str,
663    cursor: usize,
664    home_dir: Option<&Path>,
665    working_dir: Option<&Path>,
666) -> Vec<Candidate> {
667    if first_non_whitespace_starts_command(buffer) {
668        let leading_len = buffer.len() - buffer.trim_start().len();
669        let view = &buffer[leading_len..];
670        if cursor < leading_len {
671            return Vec::new();
672        }
673        let view_cursor = clamp_to_char_boundary(view, cursor.saturating_sub(leading_len));
674        if view_cursor == 0 {
675            return Vec::new();
676        }
677        let command_token_end = first_whitespace(view)
678            .map(|(index, _)| index)
679            .unwrap_or(view.len());
680        if view_cursor <= command_token_end {
681            let prefix = &view[..view_cursor];
682            let suffix = &view[command_token_end..];
683            let (static_descriptions, dynamic_commands) = data.root_commands();
684            let candidates =
685                build_cmd_candidates(commands, &dynamic_commands, &static_descriptions, prefix);
686            return replace_token_candidates(&buffer[..leading_len], suffix, candidates);
687        }
688
689        if let Some((space_pos, space_ch)) = first_whitespace(view) {
690            let cmd = &view[..space_pos];
691            if cmd.is_empty() {
692                return Vec::new();
693            }
694            let rest_start = space_pos + space_ch.len_utf8();
695            let rest = &view[rest_start..];
696            let rest_cursor = view_cursor.saturating_sub(rest_start).min(rest.len());
697            let candidates = build_arg_candidates(data, cmd, rest, rest_cursor);
698            return prepend_to_replacements(&buffer[..leading_len], candidates);
699        }
700    }
701
702    let Some(token) = word_token(buffer, cursor) else {
703        return Vec::new();
704    };
705    let Some(rule) = rules.matching_rule(token.prefix) else {
706        return Vec::new();
707    };
708
709    match &rule.kind {
710        CompletionRuleKind::Agents => build_agent_mention_candidates(data, &token, &rule.prefix),
711        CompletionRuleKind::Sessions => build_session_candidates(data, &token, &rule.prefix),
712        CompletionRuleKind::Path => {
713            build_filesystem_candidates_with_home(&token, home_dir, false, working_dir)
714        }
715        CompletionRuleKind::PathFuzzy => {
716            build_filesystem_candidates_with_home(&token, home_dir, true, working_dir)
717        }
718        CompletionRuleKind::Actions => {
719            build_action_token_candidates(commands, &data.dynamic_commands(), &token, &rule.prefix)
720        }
721        CompletionRuleKind::Command(_) => Vec::new(),
722    }
723}
724
725fn build_cmd_candidates(
726    static_commands: &[CommandCompletion],
727    dynamic_commands: &[CommandCompletion],
728    static_descriptions: &HashMap<CommandName, String>,
729    prefix: &str,
730) -> Vec<Candidate> {
731    let mut seen = path_std_collections::HashSet::new();
732    static_commands
733        .iter()
734        .chain(dynamic_commands)
735        .filter(|cmd| seen.insert(cmd.name.to_string()))
736        .filter(|cmd| cmd.name.as_str().starts_with(prefix))
737        .map(|cmd| Candidate {
738            label: cmd.name.to_string(),
739            description: static_descriptions
740                .get(&cmd.name)
741                .cloned()
742                .unwrap_or_else(|| cmd.description.clone()),
743            replacement: cmd.name.to_string(),
744            cursor: cmd.name.as_str().len(),
745            acceptance: None,
746        })
747        .collect()
748}
749fn prepend_to_replacements(prefix: &str, candidates: Vec<Candidate>) -> Vec<Candidate> {
750    candidates
751        .into_iter()
752        .map(|candidate| Candidate {
753            replacement: format!("{prefix}{}", candidate.replacement),
754            cursor: prefix.len() + candidate.cursor,
755            ..candidate
756        })
757        .collect()
758}
759
760fn replace_token_candidates(
761    before: &str,
762    after: &str,
763    candidates: Vec<Candidate>,
764) -> Vec<Candidate> {
765    candidates
766        .into_iter()
767        .map(|candidate| {
768            let accepted = candidate.replacement.clone();
769            replace_candidate(candidate, before, &accepted, after)
770        })
771        .collect()
772}
773
774fn replace_candidate(candidate: Candidate, before: &str, accepted: &str, after: &str) -> Candidate {
775    Candidate {
776        replacement: format!("{before}{accepted}{after}"),
777        cursor: before.len() + accepted.len(),
778        ..candidate
779    }
780}
781
782fn build_action_token_candidates(
783    static_commands: &[CommandCompletion],
784    dynamic_commands: &[CommandCompletion],
785    token: &PathToken<'_>,
786    trigger_prefix: &str,
787) -> Vec<Candidate> {
788    let partial = token
789        .prefix
790        .strip_prefix(trigger_prefix)
791        .unwrap_or(token.prefix);
792    let lookup_prefix = if trigger_prefix == ":" {
793        token.prefix.to_owned()
794    } else {
795        format!(":{partial}")
796    };
797    build_cmd_candidates(
798        static_commands,
799        dynamic_commands,
800        &HashMap::new(),
801        &lookup_prefix,
802    )
803    .into_iter()
804    .map(|candidate| {
805        let replacement = if trigger_prefix == ":" {
806            candidate.replacement.clone()
807        } else {
808            format!(
809                "{trigger_prefix}{}",
810                candidate.replacement.trim_start_matches(':')
811            )
812        };
813        replace_candidate(candidate, token.before, &replacement, token.after)
814    })
815    .collect()
816}
817struct PathToken<'a> {
818    prefix: &'a str,
819    /// Bytes in the current token after the cursor.
820    suffix: &'a str,
821    before: &'a str,
822    after: &'a str,
823}
824
825fn first_non_whitespace_starts_command(buffer: &str) -> bool {
826    let trimmed = buffer.trim_start();
827    trimmed.starts_with(':') && !trimmed.starts_with("::")
828}
829
830fn word_token(buffer: &str, cursor: usize) -> Option<PathToken<'_>> {
831    let before_cursor = buffer.get(..cursor)?;
832    let after_cursor = buffer.get(cursor..)?;
833    let token_start = before_cursor
834        .char_indices()
835        .rev()
836        .find_map(|(idx, ch)| ch.is_whitespace().then_some(idx + ch.len_utf8()))
837        .unwrap_or(0);
838    let token_end = after_cursor
839        .char_indices()
840        .find_map(|(idx, ch)| ch.is_whitespace().then_some(cursor + idx))
841        .unwrap_or(buffer.len());
842    Some(PathToken {
843        prefix: &buffer[token_start..cursor],
844        suffix: &buffer[cursor..token_end],
845        before: &buffer[..token_start],
846        after: &buffer[token_end..],
847    })
848}
849
850fn build_session_candidates(
851    data: &CompletionData,
852    token: &PathToken<'_>,
853    trigger_prefix: &str,
854) -> Vec<Candidate> {
855    if token.prefix.contains('/') || token.suffix.contains('/') {
856        return Vec::new();
857    }
858    let Some(completer) = data.get_session_completer() else {
859        return Vec::new();
860    };
861    let partial = token
862        .prefix
863        .strip_prefix(trigger_prefix)
864        .unwrap_or(token.prefix);
865    completer(&[partial])
866        .into_iter()
867        .map(|item| {
868            let accepted = format!("{trigger_prefix}{}", item.value);
869            replace_candidate(
870                Candidate {
871                    label: item.value,
872                    description: item.description,
873                    replacement: String::new(),
874                    cursor: 0,
875                    acceptance: None,
876                },
877                token.before,
878                &accepted,
879                token.after,
880            )
881        })
882        .collect()
883}
884fn build_agent_mention_candidates(
885    data: &CompletionData,
886    token: &PathToken<'_>,
887    trigger_prefix: &str,
888) -> Vec<Candidate> {
889    let Some(completer) = data.get_agent_mention_completer() else {
890        return Vec::new();
891    };
892    let partial = token
893        .prefix
894        .strip_prefix(trigger_prefix)
895        .unwrap_or(token.prefix);
896    completer(&[partial])
897        .into_iter()
898        .map(|item| {
899            let accepted = format!("{trigger_prefix}{}", item.value);
900            replace_candidate(
901                Candidate {
902                    label: item.value,
903                    description: item.description,
904                    replacement: String::new(),
905                    cursor: 0,
906                    acceptance: None,
907                },
908                token.before,
909                &accepted,
910                token.after,
911            )
912        })
913        .collect()
914}
915
916fn cwd() -> PathBuf {
917    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
918}
919
920fn home_dir() -> Option<PathBuf> {
921    let home = std::env::var_os("HOME")?;
922    if home.as_os_str().is_empty() {
923        None
924    } else {
925        Some(PathBuf::from(home))
926    }
927}
928fn home_expanded_path(prefix: &str, home_dir: Option<&Path>) -> Option<PathBuf> {
929    if prefix == "~" {
930        Some(home_dir?.to_path_buf())
931    } else if let Some(rest) = prefix.strip_prefix("~/") {
932        Some(home_dir?.join(rest))
933    } else {
934        Some(PathBuf::from(prefix))
935    }
936}
937
938fn build_filesystem_candidates_with_home(
939    path_token: &PathToken<'_>,
940    home_dir: Option<&Path>,
941    fuzzy_git_files: bool,
942    working_dir: Option<&Path>,
943) -> Vec<Candidate> {
944    let prefix = path_token.prefix;
945    let Some(lookup_path) = home_expanded_path(prefix, home_dir) else {
946        return Vec::new();
947    };
948    let display_path = Path::new(prefix);
949    let (lookup_dir, display_dir, partial) = if prefix == "~" {
950        (lookup_path, PathBuf::from("~"), "")
951    } else if prefix.ends_with('/') {
952        (lookup_path, display_path.to_path_buf(), "")
953    } else {
954        let Some(lookup_parent) = lookup_path.parent() else {
955            return Vec::new();
956        };
957        let Some(display_parent) = display_path.parent() else {
958            return Vec::new();
959        };
960        let partial = display_path
961            .file_name()
962            .and_then(|s| s.to_str())
963            .unwrap_or("");
964        let lookup_dir = if lookup_parent.as_os_str().is_empty() {
965            PathBuf::from(".")
966        } else {
967            lookup_parent.to_path_buf()
968        };
969        let display_dir = if display_parent.as_os_str().is_empty() {
970            PathBuf::from(".")
971        } else {
972            display_parent.to_path_buf()
973        };
974        (lookup_dir, display_dir, partial)
975    };
976
977    if fuzzy_git_files && prefix.starts_with("./") && !partial.is_empty() {
978        let cwd = working_dir.map(Path::to_path_buf).unwrap_or_else(cwd);
979        if let Some((repo_root, files)) = git_files::git_repo_files(&cwd) {
980            let matches = git_files::fuzzy_match_git_files(partial, &files);
981            if !matches.is_empty() {
982                return matches
983                    .into_iter()
984                    .map(|path| {
985                        let display = git_files::dotslash_display_path(path, &repo_root, &cwd);
986                        replace_candidate(
987                            Candidate {
988                                label: display.clone(),
989                                description: "git file".to_owned(),
990                                replacement: String::new(),
991                                cursor: 0,
992                                acceptance: None,
993                            },
994                            path_token.before,
995                            &display,
996                            path_token.after,
997                        )
998                    })
999                    .collect();
1000            }
1001        }
1002    }
1003
1004    let lookup_dir = working_dir
1005        .filter(|_| lookup_dir.is_relative())
1006        .map_or(lookup_dir.clone(), |cwd| cwd.join(lookup_dir));
1007    let Ok(entries) = std::fs::read_dir(lookup_dir) else {
1008        return Vec::new();
1009    };
1010
1011    let mut candidates = Vec::new();
1012    for entry in entries.flatten() {
1013        let name = entry.file_name();
1014        let Some(name) = name.to_str() else {
1015            continue;
1016        };
1017        if !name.starts_with(partial) {
1018            continue;
1019        }
1020        if !partial.starts_with('.') && name.starts_with('.') {
1021            continue;
1022        }
1023
1024        let is_dir = entry.file_type().map(|ty| ty.is_dir()).unwrap_or(false);
1025        let mut replacement = display_dir.join(name).to_string_lossy().into_owned();
1026        if is_dir && !replacement.ends_with('/') {
1027            replacement.push('/');
1028        }
1029        let acceptance = home_completion_acceptance(
1030            path_token,
1031            home_dir,
1032            &replacement,
1033            path_token.before,
1034            path_token.after,
1035        );
1036        candidates.push(replace_candidate(
1037            Candidate {
1038                label: replacement.clone(),
1039                description: if is_dir { "directory" } else { "file" }.to_owned(),
1040                replacement: String::new(),
1041                cursor: 0,
1042                acceptance,
1043            },
1044            path_token.before,
1045            &replacement,
1046            path_token.after,
1047        ));
1048    }
1049
1050    candidates.sort_by(|a, b| a.label.cmp(&b.label));
1051    candidates
1052}
1053
1054fn clamp_to_char_boundary(text: &str, cursor: usize) -> usize {
1055    let mut cursor = cursor.min(text.len());
1056    while 0 < cursor && !text.is_char_boundary(cursor) {
1057        cursor -= 1;
1058    }
1059    cursor
1060}
1061
1062fn first_whitespace(text: &str) -> Option<(usize, char)> {
1063    text.char_indices().find(|(_, ch)| ch.is_whitespace())
1064}
1065
1066fn build_arg_candidates(
1067    data: &CompletionData,
1068    cmd: &str,
1069    rest: &str,
1070    rest_cursor: usize,
1071) -> Vec<Candidate> {
1072    let cmd_name = CommandName::new(cmd);
1073    let Some(completer) = data.get_arg_completer(&cmd_name) else {
1074        return Vec::new();
1075    };
1076
1077    let rest_cursor = clamp_to_char_boundary(rest, rest_cursor);
1078    let token_start = rest[..rest_cursor]
1079        .char_indices()
1080        .rev()
1081        .find_map(|(pos, ch)| ch.is_whitespace().then_some(pos + ch.len_utf8()))
1082        .unwrap_or(0);
1083    let token_end = rest[rest_cursor..]
1084        .find(char::is_whitespace)
1085        .map(|pos| rest_cursor + pos)
1086        .unwrap_or(rest.len());
1087
1088    let mut args: Vec<&str> = rest[..token_start].split_whitespace().collect();
1089    args.push(&rest[token_start..rest_cursor]);
1090
1091    let replacement_prefix = format!("{cmd} {}", &rest[..token_start]);
1092    let replacement_suffix = &rest[token_end..];
1093
1094    completer(&args)
1095        .into_iter()
1096        .map(|item| {
1097            let accepted = item.value.clone();
1098            replace_candidate(
1099                Candidate {
1100                    label: item.value,
1101                    description: item.description,
1102                    replacement: String::new(),
1103                    cursor: 0,
1104                    acceptance: None,
1105                },
1106                &replacement_prefix,
1107                &accepted,
1108                replacement_suffix,
1109            )
1110        })
1111        .collect()
1112}
1113
1114/// Builds the absolute prompt replacement used only after accepting a `~/`
1115/// filesystem candidate.
1116fn home_completion_acceptance(
1117    path_token: &PathToken<'_>,
1118    home_dir: Option<&Path>,
1119    display_replacement: &str,
1120    before: &str,
1121    after: &str,
1122) -> Option<CompletionAcceptance> {
1123    path_token.prefix.strip_prefix("~/")?;
1124    let home_dir = home_dir?;
1125    let completed = display_replacement.strip_prefix("~/")?;
1126    let mut absolute = home_dir.join(completed).to_string_lossy().into_owned();
1127    if display_replacement.ends_with('/') && !absolute.ends_with('/') {
1128        absolute.push('/');
1129    }
1130    Some(CompletionAcceptance {
1131        cursor: before.len() + absolute.len(),
1132        replacement: format!("{before}{absolute}{after}"),
1133    })
1134}
1135
1136const COMPLETION_MENU_MAX_HEIGHT_PERCENT: usize = 30;
1137
1138/// Renders the completion menu as a [`StyledBlock`]: each candidate
1139/// on its own line, with the selected entry highlighted.
1140pub fn render_menu_block(
1141    view: &CompletionView,
1142    theme: &Theme,
1143    terminal_width: usize,
1144    terminal_height: usize,
1145) -> StyledBlock {
1146    render_menu_block_with_max_rows(
1147        view,
1148        theme,
1149        terminal_width,
1150        completion_menu_max_rows(terminal_height),
1151    )
1152}
1153
1154fn completion_menu_max_rows(terminal_height: usize) -> usize {
1155    (terminal_height * COMPLETION_MENU_MAX_HEIGHT_PERCENT / 100).max(1)
1156}
1157
1158fn visible_candidate_range(view: &CompletionView, max_rows: usize) -> std::ops::Range<usize> {
1159    let total = view.candidates.len();
1160    let max_rows = max_rows.max(1).min(total.max(1));
1161    if total <= max_rows {
1162        return 0..total;
1163    }
1164
1165    let selected = view.selected.unwrap_or(0).min(total - 1);
1166    let half = max_rows / 2;
1167    let start = selected.saturating_sub(half).min(total - max_rows);
1168    start..start + max_rows
1169}
1170
1171struct MenuLineParts {
1172    label: String,
1173    padding: usize,
1174    description: String,
1175}
1176
1177fn menu_line_parts(
1178    candidate: &Candidate,
1179    max_label_width: usize,
1180    terminal_width: usize,
1181) -> MenuLineParts {
1182    let inner_width = if terminal_width < 4 {
1183        terminal_width.max(1)
1184    } else {
1185        terminal_width.max(1).saturating_sub(4)
1186    };
1187    let label_budget = max_label_width.min(inner_width);
1188    let label = truncate_to_width(&candidate.label, label_budget);
1189    let label_width = display_width(label.as_str());
1190    let remaining = inner_width.saturating_sub(label_width);
1191
1192    let mut padding = 0;
1193    let mut description = String::new();
1194    if !candidate.description.is_empty() && 0 < remaining {
1195        padding = (max_label_width.saturating_sub(label_width) + 2).min(remaining);
1196        let desc_budget = remaining.saturating_sub(padding);
1197        if 0 < desc_budget {
1198            description = truncate_to_width(&candidate.description, desc_budget);
1199        }
1200    }
1201
1202    MenuLineParts {
1203        label,
1204        padding,
1205        description,
1206    }
1207}
1208
1209fn render_menu_block_with_max_rows(
1210    view: &CompletionView,
1211    theme: &Theme,
1212    terminal_width: usize,
1213    max_rows: usize,
1214) -> StyledBlock {
1215    let selected_style = resolve::resolve(theme, tau_themes::names::COMPLETION_SELECTED);
1216    let label_style = resolve::resolve(theme, tau_themes::names::COMPLETION_LABEL);
1217    let desc_style = resolve::resolve(theme, tau_themes::names::COMPLETION_DESC);
1218
1219    let visible = visible_candidate_range(view, max_rows);
1220    let max_label_width = view.candidates[visible.clone()]
1221        .iter()
1222        .map(|c| display_width(c.label.as_str()))
1223        .max()
1224        .unwrap_or(0);
1225
1226    let mut spans: Vec<Span> = Vec::new();
1227    for (row, i) in visible.enumerate() {
1228        let candidate = &view.candidates[i];
1229        if 0 < row {
1230            spans.push(Span::plain("\n"));
1231        }
1232
1233        let is_selected = view.selected == Some(i);
1234        let parts = menu_line_parts(candidate, max_label_width, terminal_width);
1235
1236        let line_text = if terminal_width < 4 {
1237            truncate_to_width(&parts.label, terminal_width)
1238        } else if parts.description.is_empty() {
1239            format!("  {}  ", parts.label)
1240        } else {
1241            format!(
1242                "  {}{:padding$}{}  ",
1243                parts.label,
1244                "",
1245                parts.description,
1246                padding = parts.padding,
1247            )
1248        };
1249
1250        if terminal_width < 4 {
1251            spans.push(Span::plain(line_text));
1252        } else if is_selected {
1253            spans.push(Span::new(line_text, selected_style));
1254        } else {
1255            spans.push(Span::plain("  "));
1256            spans.push(Span::new(parts.label, label_style));
1257            if !parts.description.is_empty() {
1258                spans.push(Span::plain(format!(
1259                    "{:padding$}",
1260                    "",
1261                    padding = parts.padding
1262                )));
1263                spans.push(Span::new(parts.description, desc_style));
1264            }
1265            spans.push(Span::plain("  "));
1266        }
1267    }
1268
1269    StyledBlock::new(StyledText::from(spans))
1270}
1271
1272#[cfg(test)]
1273#[path = "completion_rule_tests.rs"]
1274mod completion_rule_tests;
1275#[cfg(test)]
1276mod render_tests;