Class: Rubino::UI::CompletionSource
- Inherits:
-
Object
- Object
- Rubino::UI::CompletionSource
- Defined in:
- lib/rubino/ui/completion_source.rb
Overview
Shared completion DISCOVERY + token HIGHLIGHT for the interactive prompt. The bottom composer’s /command + @file completion menu and token highlight consult this single implementation (git→rg→glob walk, @file candidate shaping, caps/TTL cache, cyan leading-token highlight) instead of each path duplicating it.
* +candidates_for(token)+ — slash commands or @file paths for a token.
* +highlight_line(line)+ — cyan the leading /command / @mention token.
Discovery is fastest-first (git tracked+untracked honoring .gitignore →ripgrep –files → a capped Dir.glob walk) and memoized for a few seconds so a burst of @ keystrokes never reshells. Every tier is guarded so a failure degrades to the next tier (and finally to []), never crashing the prompt.
Constant Summary collapse
- TRIGGER_TOKEN =
Tokens that trigger highlighting at the start of the line. A leading ‘!` (the bang shell escape) glows like `/` so the user can SEE the line will run as a shell command, not a message — highlight only, it never opens the completion menu.
%r{\A([/@]\S+|!\S*)}- MAX_CANDIDATES =
Cap on candidates — keeps the menu skimmable and bounds work on huge repos. Cline et al. ship similar caps.
30- FILE_CACHE_TTL =
How long a computed file list stays warm before the next ‘@` reshells.
5.0- GLOB_IGNORE_DIRS =
Hardcoded ignore set for the last-resort Dir.glob walk (git/rg already honor .gitignore; this is only the fallback’s safety net).
%w[.git node_modules vendor tmp log .bundle].freeze
- GLOB_MAX_FILES =
Hard ceiling on the Dir.glob fallback so a giant tree can’t hang the prompt while we walk it.
5000- NONE_ENTRY =
The ‘✗ none` clear entry shown at the TOP of an argument list whose command supports clearing its active selection (e.g. `/skills`). Picking it submits the bare sentinel so the command handler clears the slot.
"✗ none"- NONE =
The sentinel a ‘✗ none` selection resolves to once spliced + submitted —the command handler treats this argument as “clear the active selection”.
"none"
Class Method Summary collapse
-
.directory_candidates(partial) ⇒ Object
Directory candidates for a PATH-shaped argument (‘/add-dir `, #185) — the directory-flavored sibling of the `@file` picker.
Instance Method Summary collapse
-
#arg_candidates_for(command, partial, args = []) ⇒ Object
Candidates for the ARGUMENT of a command, e.g.
-
#candidates_for(token) ⇒ Object
Candidates for a completion token.
-
#description_for(candidate) ⇒ Object
The one-line description for a dropdown candidate (#39): the same strings /help shows for a ‘/command`, a usage hint for a subcommand.
-
#highlight_line(line) ⇒ Object
Subtly colorize a leading /command or @mention token (cyan).
-
#initialize(commands: [], files: nil, arg_sources: {}, descriptions: {}) ⇒ CompletionSource
constructor
A new instance of CompletionSource.
-
#positional_candidates(list, down) ⇒ Object
Prefix-filtered candidates from a positional (one-arg) source.
Constructor Details
#initialize(commands: [], files: nil, arg_sources: {}, descriptions: {}) ⇒ CompletionSource
Returns a new instance of CompletionSource.
77 78 79 80 81 82 83 |
# File 'lib/rubino/ui/completion_source.rb', line 77 def initialize(commands: [], files: nil, arg_sources: {}, descriptions: {}) @commands = Array(commands).uniq @files_root_proc = files @arg_sources = arg_sources || {} @descriptions = descriptions || {} @pastel = Pastel.new end |
Class Method Details
.directory_candidates(partial) ⇒ Object
Directory candidates for a PATH-shaped argument (‘/add-dir `, #185) —the directory-flavored sibling of the `@file` picker. Globs the filesystem from the typed partial (relative to cwd, absolute, or `~`-prefixed — an added root usually lives OUTSIDE the workspace, so the workspace file list is the wrong source here), keeps only directories, and folds `~` back so the spliced candidate preserves the user’s spelling. Best-effort: any failure (e.g. ‘~nouser`) returns [].
156 157 158 159 160 161 162 163 164 165 166 |
# File 'lib/rubino/ui/completion_source.rb', line 156 def self.directory_candidates(partial) text = partial.to_s pattern = text.start_with?("~") ? File.(text) : text Dir.glob("#{pattern}*") .select { |p| File.directory?(p) } .sort .map { |p| text.start_with?("~") ? p.sub(File.("~"), "~") : p } .first(MAX_CANDIDATES) rescue StandardError [] end |
Instance Method Details
#arg_candidates_for(command, partial, args = []) ⇒ Object
Candidates for the ARGUMENT of a command, e.g. the skill names when the buffer is ‘/skills <partial>`. command is the bare command name (no leading slash); partial is the text typed so far for the argument (may be empty); args the COMPLETE arguments typed before it. Returns
-
when the command has no registered argument source.
Candidates are filtered by case-insensitive prefix and capped at MAX_CANDIDATES — the SAME cap the ‘/command` and `@file` lists honor. A no-arg source (single-argument command) completes only the first argument and leads with the `✗ none` clear entry; a one-arg source is called with args and owns the per-position grammar (#39) — see #initialize.
112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 |
# File 'lib/rubino/ui/completion_source.rb', line 112 def arg_candidates_for(command, partial, args = []) source = @arg_sources[command.to_s] return [] unless source down = partial.to_s.downcase list = if source.arity.zero? return [] unless args.empty? # single-argument command: first arg only # The `✗ none` clear entry matches an empty partial or a # "n"/"no…"/"none" prefix, so typing toward "none" keeps it in view. none = down.empty? || NONE.start_with?(down) ? [NONE_ENTRY] : [] none + Array(source.call).select { |n| n.to_s.downcase.start_with?(down) } elsif source.arity == 2 # PARTIAL-AWARE source: it derives candidates FROM the typed text # (e.g. a filesystem glob) and owns the matching — see #initialize. Array(source.call(args, partial.to_s)) else positional_candidates(source.call(args), down) end list.first(MAX_CANDIDATES) end |
#candidates_for(token) ⇒ Object
Candidates for a completion token. A ‘/`-prefixed token completes from the command list; an `@`-prefixed token completes from workspace files; anything else has no candidates. Case-insensitive prefix matching.
88 89 90 91 92 93 94 95 96 97 98 |
# File 'lib/rubino/ui/completion_source.rb', line 88 def candidates_for(token) case token when %r{\A/} down = token.downcase @commands.select { |c| c.downcase.start_with?(down) } when /\A@/ file_candidates(token) else [] end end |
#description_for(candidate) ⇒ Object
The one-line description for a dropdown candidate (#39): the same strings /help shows for a ‘/command`, a usage hint for a subcommand. nil when the candidate has none (files, skill names) — the menu row renders bare, exactly as before.
176 177 178 |
# File 'lib/rubino/ui/completion_source.rb', line 176 def description_for(candidate) @descriptions[candidate.to_s] end |
#highlight_line(line) ⇒ Object
Subtly colorize a leading /command or @mention token (cyan). Plain text and non-strings are returned unchanged. Matches LineInput#highlight_line. A “[Pasted text #N +M lines]” paste placeholder (UI::PasteStore) glows the same way wherever it sits in the line, so the user can SEE it is a token that expands at send, not literal text.
185 186 187 188 189 190 |
# File 'lib/rubino/ui/completion_source.rb', line 185 def highlight_line(line) return line unless line.is_a?(String) line.sub(TRIGGER_TOKEN) { @pastel.cyan(Regexp.last_match(1)) } .gsub(PasteStore::TOKEN_RE) { |token| @pastel.cyan(token) } end |
#positional_candidates(list, down) ⇒ Object
Prefix-filtered candidates from a positional (one-arg) source. A literal NONE_ENTRY in the source’s list (the /skills first position, #188) keeps the clear entry’s special matching — shown on an empty partial or while typing toward “none” — instead of being dropped by the literal ‘✗ ` prefix filter.
140 141 142 143 144 145 146 147 |
# File 'lib/rubino/ui/completion_source.rb', line 140 def positional_candidates(list, down) list = Array(list) has_none = list.delete(NONE_ENTRY) matched = list.select { |n| n.to_s.downcase.start_with?(down) } return matched unless has_none && (down.empty? || NONE.start_with?(down)) [NONE_ENTRY] + matched end |