Shell Completion¶
sase completion supports native shell completion for zsh, bash, and fish from
the live sase argparse tree, so pressing <TAB> anywhere in the command line offers
the right commands, options, static choices, and live values — bead ids with titles,
project display names, macro names, and more — with no perceptible latency.
Installed completion uses a small shell-native loader plus a SASE-owned runtime grammar
cache. A fresh shell loads the portable loader, the loader asks the active sase
executable for a current cached grammar, and cache hits avoid rebuilding the full
parser. Static completion after that runs in the shell. Only values — beads, projects,
repos, and the like — are fetched live, through a narrow cached fast path. Manual
sase completion zsh|bash|fish exports remain snapshots.
Quick Start¶
Pick your shell and write the script somewhere your shell already scans, or let
sase completion install find that place for you:
sase completion install # detect the shell, write the loader, verify, stamp
sase completion install zsh # or name one explicitly
sase completion install -d # dry run: print the plan, touch nothing
sase completion refresh -d # preview refreshes for existing stamped installs
Open a new shell afterward — completion scripts are read once, at shell startup.
Manual install, if you'd rather manage the file yourself:
sase completion zsh -o ~/.zfunc/_sase
sase completion bash -o ~/.local/share/bash-completion/completions/sase
sase completion fish -o ~/.config/fish/completions/sase.fish
SASE-managed machines can write the same loaders through chezmoi instead:
sase completion deploy-chezmoi -d # preview source files
sase completion deploy-chezmoi # write source, commit, push, and apply
deploy-chezmoi renders the bash, fish, and zsh loaders into
~/.local/share/chezmoi/home (override with -s/--source) and removes the old
generated stamp source files under dot_sase/completion/stamp/. Runtime stamp metadata
now belongs to each host's SASE state. -c/--no-commit writes the source files only,
-n/--no-push commits without pulling, pushing, or applying, and -a/--no-apply
commits and pushes but skips chezmoi apply.
The generated grammar also understands root -p/--print-command as a no-value global
option. That makes short zsh aliases work cleanly with completion:
alias sbd='sase -p bead'
With that alias loaded in a fresh shell, sbd sh<TAB> completes the static sase bead
tree and dynamic bead-id slots still call the narrow candidate fast path. The print
header is emitted only when a command actually runs, never during tab completion. It
goes to stderr and omits the print switch, so stdout remains clean for JSON, generated
scripts, and redirected command output.
Never eval "$(sase completion zsh)" in an rc file. That pays a full sase startup
(300–640 ms) on every new shell; write the script to a file instead.
For zsh, the directory must be on fpath before compinit runs, or the completion
silently never loads even though the file is sitting right there. Registration as
_sase is necessary, but it is not the whole story: if another directory earlier on
fpath also contains _sase, zsh can register the command and later autoload the older
file. Frameworks such as oh-my-zsh may prepend their own completion directories after an
earlier user prepend, so managed startup files should reassert the managed directory's
priority after the framework loads. sase completion install prefers a directory your
shell already scans — and, when you run a framework like oh-my-zsh, its completions
drop-in directory, which the framework guarantees is on fpath before compinit. If it
has to fall back to a conventional directory (~/.zfunc), it prints the exact line to
add:
fpath=(~/.zfunc $fpath) # must appear BEFORE compinit
sase completion install never edits ~/.zshrc, ~/.bashrc, or any other rc file —
only the completion script itself.
What install Does¶
- Detects the shell from
$SHELLand the parent process, unless one is given explicitly. - Chooses a target directory, in order:
-t/--target, thenSASE_COMPLETION_DIR, then a shell framework'scompletionsdrop-in directory, then the first writable directory the shell actually scans, then a conventional fallback (~/.zfuncfor zsh,~/.local/share/bash-completion/completionsfor bash,~/.config/fish/completionsfor fish). Two rules shape that middle ground: - A framework's drop-in directory wins, and is created if the framework ships the
fpathentry without the directory — on oh-my-zsh that is~/.oh-my-zsh/custom/completions. It is the only scanned entry whose ordering is guaranteed, because the framework puts it onfpathitself, before it runscompinit. A plain user directory like~/.zfuncis frequently appended aftercompinit, where a script in it never loads — and thefpathprobe cannot tell the two apart, since it readsfpathonce rc processing has already finished. - Framework plugin directories and caches are never used, even though they are
scanned and writable. An enabled plugin's own directory is on
fpathonly while that plugin is enabled, so a script dropped there hijacks an unrelated project's tree and disappears the day the plugin is turned off.
Among the remaining candidates, home directories win over system-wide ones.
- Warms and validates the runtime grammar cache for the selected shell. A cache hit verifies the cached file without importing the full parser; a miss generates the grammar once from the running CLI.
- Writes the loader atomically, and removes the script a previous install stamped somewhere else, so changing targets never leaves a second copy behind.
zcompiles the zsh loader. The larger generated zsh grammar is compiled in the runtime cache when it is refreshed.- Verifies registration for zsh by probing
${_comps[sase]}in a real, non-interactive shell. A file that exists but was written to a directorycompinitnever scanned is a silent no-op —installcatches that and tells you exactly what to fix. - Stamps
~/.sase/completion/stamp/<shell>.jsonwith the sase version, the grammar structural digest, the target path, the loader representation, the loader digest, and an ownership marker (localorchezmoi), sosase completion listandsase doctorcan tell a real install from a stray file. - Reports every step's outcome and prints the recommended
zstylesnippet below.
sase completion list (also the bare sase completion default; -j for JSON) shows
every shell's generator availability, install status, owner, target path, .zwc
freshness, and stamp version in one table. sase doctor includes the same checks as a
non-blocking advisory group: completion.install runs by default, and
completion.registration — which spawns a real shell — runs only under -D/--deep.
File presence alone is never treated as evidence of a working install.
The Recommended zstyle Snippet¶
zsh's compsys is opt-in for grouping, descriptions, and menu selection.
sase completion install prints this snippet at the end of a successful install; add it
to your .zshrc after compinit:
# Recommended compsys styles for sase (grouped, described, menu-selected):
zstyle ':completion:*' menu select
zstyle ':completion:*' group-name ''
zstyle ':completion:*:descriptions' format '%F{yellow}-- %d --%f'
zstyle ':completion:*' verbose yes
zstyle ':completion:*' list-grouped true
zstyle ':completion:*' use-cache on
use-cache on matters beyond cosmetics: it is what lets the dynamic-value layer below
actually cache results in-shell instead of re-forking sase on every keystroke.
Value Kinds And Caching¶
Options and positionals whose value can't be enumerated statically (bead ids, project
names, repo names, plan references, and more) complete through
sase completion candidates <KIND> [PREFIX] — a pre-argparse fast path in entry.py
that never imports the full CLI, sase's TUI, or Rust extension surface it doesn't need,
and answers in well under its latency budget for a warm process. KIND completes to the
kinds this build can actually answer, so sase completion candidates <TAB> is the
authoritative list; today that is agent, artifact, artifact_ref,
artifact_relation, bead, directive, flag, memory, model, monitor, patch,
pending_plan, plan, plugin, proc, project, project_tag, provider, repo,
skill, snippet, tag, workspace, and macro. Path and directory slots are
deliberately not kinds — the shell completes those natively.
Three flags matter when calling it by hand: -l/--limit N caps the printed candidates
(default 200), -p/--project NAME scopes project-relative kinds to one project, and
-S/--selector SELECTOR scopes plan_decision candidates to the one pending proposal
that name, path, or notification ID (prefix) selects. Without -S, decision ids merge
across every visible pending proposal; the generated shell helpers pass the PLAN
already on the command line as -S when completing sase plan approve|reject -D.
Repo candidates come from SASE's read-only repo inventory, so they include primary,
linked, sidecar, and external repository display names without cloning or resolving
anything. Snippet candidates for sase snippet show and sase snippet delete come from
the shared Rust editor snippet catalog and include generated aliases.
Memory selectors are a worked example of how a kind is shaped for a shell rather than
for a report: sase memory read <TAB> and sase memory show <TAB> offer flat note
names, bare web names, and web:slug strand references (glossary:agent-hood) read
directly from the project's sase/memory/ tree, because sase memory read resolves a
web:keyword reference case- and separator-insensitively and a slug is the one form
that never needs quoting on a command line.
Pending plans work the same way: sase plan approve <TAB> and sase plan reject <TAB>
offer only the names of plans actually awaiting approval (newest first, as
tier · title · @agent · age), not the whole archived plan: catalog the plan kind
covers. Freshness is a feature here — a plan you just approved must disappear, and one
that just arrived must appear — so pending_plan candidates expire after 5 seconds in
every shell (and in the on-disk cache) instead of the default 60.
That fast path is still a subprocess, so every generated script caches its output rather
than calling it on every keystroke — necessary once something like
zsh-autosuggestions' completion strategy is in the mix, which re-triggers completion
on every character typed:
- zsh caches through compsys's own
_retrieve_cache/_store_cachemachinery (subject to youruse-cache onsetting above), keyed per value kind. - bash caches into a
declare -gAassociative array scoped to the shell session. - fish forks a subshell for every
(...)command substitution, so shell-side caching doesn't carry between keystrokes; it relies on the fast path's own on-disk cache instead (seeSASE_COMPLETION_NO_CACHEbelow).
All three pass the full candidate set to the shell's own filtering rather than the typed prefix, so one cached fetch serves the whole word, not just one keystroke.
sase run's PROMPT argument is a special case: rather than a single value kind, it
completes native file paths (for editor-drafted prompt files) and stored macro names
together. Inside quoted or spaced prompt text, # completes macro names, % completes
prompt directive names, @ completes canonical artifact references such as
file:explicit:..., and + completes project tags such as
+sase; the inserted value keeps the marker and only replaces the active embedded
fragment. Project-tag candidates (sase completion candidates project_tag) are the
enabled, launchable projects whose PROJECT_NAME fits the tag syntax, sorted
case-insensitively; sibling records and the system-managed home project are left out,
and, unlike the TUI and LSP + picker, Patches are not offered. They skip provider
detection to stay on the fast path, so a tag for a project without a detected VCS
provider is still offered and fails at launch with the usual error.
Environment Variables¶
| Variable | Effect |
|---|---|
SASE_COMPLETION_CACHE_TTL |
Seconds an in-shell (zsh/bash) cached kind stays fresh. Default: 60. |
SASE_COMPLETION_NO_CACHE |
Set to 1 to bypass the on-disk candidates cache entirely (fish, or debugging any shell). |
SASE_COMPLETION_DIR |
Force sase completion install's target directory, overriding auto-detection. |
Refresh Existing Installs¶
Refresh stamped installs without changing where they live:
sase completion refresh # every stamped supported shell
sase completion refresh zsh # one shell only
sase completion refresh bash -d # show whether and why it would change
sase completion refresh -j # machine-readable per-shell outcomes
The command validates or regenerates each runtime grammar cache, rewrites its stamped
target to the loader representation, zcompiles zsh, and records a fresh stamp while
preserving the existing owner (local or chezmoi). With no shell argument it
refreshes every stamped zsh, bash, and fish install. Naming a shell with no stamp
is a successful no-op (no stamped <shell> completion install), as is running without a
shell when there are no stamps. Dry-run reports already current or would refresh
with the detected drift reasons and touches no scripts, stamps, cache files, or zsh
bytecode.
Refresh deliberately skips zsh's registration probe: that probe protects a first
install, but can false-fail a refresh targeting a disposable or currently unregistered
directory. Use sase doctor -D -C completion.registration when you want to test the
active shell registration explicitly.
Legacy raw snapshots whose owner is chezmoi are refreshed in place to loaders, but the
managed source still needs a one-time sase completion deploy-chezmoi migration so a
later chezmoi apply cannot restore the frozen grammar. The explicit refresh command
exits nonzero when any selected outcome cannot be refreshed. To convert a chezmoi-owned
target to a local install, run sase completion install <shell> --force; without
--force, install refuses the ownership change.
After every successful live sase update run, including an already-up-to-date no-op,
SASE runs this same refresh automatically for all stamped installs. Dry-runs and --to
mode switches do not refresh them. Refresh failures are displayed but do not fail the
update itself.
Troubleshooting¶
Start with sase doctor — it runs the same install and registration checks described
above:
sase doctor -v # human report, including completion checks
sase doctor -C completion.install
sase doctor -D -C completion.registration # probes ${_comps[sase]} for real
completion.registration is a deep check: it runs only under -D/--deep, and
selecting it without that flag is rejected with
completion.registration selects deep checks only; rerun with -D/--deep to include them.
A plain sase doctor therefore reports install status but does not probe registration.
Common issues:
- Nothing completes in a new shell. Check
sase completion listfor the install status and target path. For zsh specifically, confirm the target directory appears infpathbeforecompinit—sase doctor -D -C completion.registrationcatches this even when the script file is present. - Registration says
_sase, but completion still acts stale. The zsh registration table stores the function name, not the file that lazy autoload will choose. Runsase doctor -D -C completion.registrationto detect an older_saseearlier onfpath; this commonly happens when oh-my-zsh prepends a custom completion directory after an earlier~/.zfuncprepend. - Aliases like
alias sbd='sase -p bead'stop completing static commands or options. A failure after-por--print-commandusually means zsh loaded an older grammar that does not know the root print-command option. Refresh the managed source, ensure the managed_sasewins onfpath, and open a fresh shell so the old autoloaded function is not reused. - An empty bead-id slot completes, but a typed prefix does not. Empty-slot success
only proves candidates are being fetched. Typed-prefix matching also depends on the
zsh completion environment around
_describe; use a fresh shell with the repaired managed script before diagnosing candidate data. - Completion is slow. A cold first
<TAB>for a zsh script that was written but neverzcompiled costs 79–84 ms; re-runsase completion installto compile it. A slow dynamic value (a kinded slot) points at the candidates fast path itself —sase completion candidates <kind>directly to isolate it from shell overhead. - A completion looks stale. In-shell caches (zsh, bash) expire after
SASE_COMPLETION_CACHE_TTLseconds (default 60); a fresh shell also reloads the installed loader and asks the activesaseexecutable for current grammar. If the target is still a raw export or a pre-loader chezmoi source, runsase completion refreshfor the applied target andsase completion deploy-chezmoifor managed sources, then open a fresh shell.
Measured Latency¶
Approximate figures, measured when this repo's live command tree had 331 parsers, 809 options, and 140 positionals (the tree has grown since, so treat them as a baseline rather than current numbers):
| Stage | zsh | bash | fish |
|---|---|---|---|
| Parse/load the generated script (per new shell) | 0.4–12 ms (.zwc) / 79–84 ms (uncompiled) |
~4–5 ms (uncompiled; no zcompile equivalent) | 27.59 ms |
Warm <TAB> (cached value kind) |
0.4–12 ms | ~9–10 ms | 179.81 ms |
Cold <TAB> (first fetch of a value kind) |
one sase completion candidates subprocess, ~65–90 ms |
~65–90 ms | 185.53 ms |
zsh and bash numbers above are directly measured (zsh via the real-shell smoke tests
under tests/completion/; bash via sourcing the generated script and timing _sase
before and after its in-shell cache is populated). Fish was measured on athena with
Debian fish, version 4.0.2 from package fish=4.0.2-1. The method generated the live
fish script from this checkout, placed a temporary sase shim first on PATH, and used
fish's time builtin for 30 samples of source sase.fish, cold
complete -C 'sase run %mo' with a fresh SASE_HOME, and warm
complete -C 'sase run %mo' after priming the on-disk candidate cache. Values in the
table are medians; fish has no persistent in-shell cache, so warm still pays one sase
candidate subprocess.
Plugin Commands¶
Top-level commands mounted by plugins through the sase_commands entry-point group (see
plugins.md) are merged into the same runtime spec the generators above consume, so
sase <name> <TAB>, sase completion spec, and the TUI : command line all complete
plugin subtrees. The checked-in structural snapshot stays builtin-only; only the live
runtime spec carries plugin children.
Freshness follows the same cache identity as everything else: the shell grammar cache
and the TUI spec cache key on the plugin command set (names, owners, versions,
locations, and the disable-switch state) plus the *.py sources of editable providers.
A plugin install, uninstall, update, or editable source edit gives new shells and new
TUI sessions a fresh grammar automatically. An already-running TUI rechecks the key
off-thread each time the : command line opens, and reloads the grammar only when the
key changed. Already-open shells keep their loaded functions until exec $SHELL;
exported files from sase completion zsh > file stay unmanaged snapshots.
A plugin subtree that fails to load (or whose parser fails to build) is skipped and
recorded once in the grammar manifest as an omission, so a broken plugin is not
re-imported on every new shell. sase doctor reports omissions as WARN in the
completion.plugins check with a sase plugin update <name> next step.
Plugin authors opt argument slots into path completion with a public string attribute on
the argparse action: action.sase_completion = "path" (or "dir"). Otherwise only
argparse choices produce candidates — none of sase's builtin name, override, or hint
heuristics apply inside a plugin subtree, and no default_child is inferred.
Deferred¶
Recorded here rather than shipped in this epic — cheap to add later once the spec model exists, and not blocking the core experience:
- A
carapace-specemitter from the sameCompletionSpecmodel, for nushell, elvish, and PowerShell. SASE targets POSIX hosts today.