aisuggest — prompt-time NL→shell ghost text¶
Type a natural-language description at the zsh prompt → press the trigger → ghost text appears showing the suggested command → Tab or → to accept, keep typing to dismiss. Inspired by kylesnowschwartz/zsh-ai-cmd (MIT); reimplemented from scratch with our own naming so it routes through the existing _aiagent_invoke layer (claude → opencode → codex → cursor-agent autodetect, no API key needed).
This is the prompt-time counterpart to aicapture. Two distinct use cases:
aicapture (aifix / aiexplain) |
aisuggest |
|
|---|---|---|
| Trigger | After a command ran; type aifix |
While typing; press Alt+; |
| Input | Captured tmux output of a past block | Current BUFFER text (natural language) |
| Output | Multi-paragraph diagnosis (Markdown) | One shell command (ghost text → buffer) |
| UI | Streamed to stdout in the next prompt | ZLE postdisplay + region_highlight |
Quick start¶
# Type a description, press Alt+; for a suggestion
find files larger than 100 MB
# (Alt+;)
find files larger than 100 MB ⇥ find . -size +100M ← ghost text in fg=8
# (Tab)
find . -size +100M ← buffer replaced; press Enter
If the suggestion happens to be an extension of what you already typed (e.g. git log -- → git log --oneline -20), the ghost text shows only the suffix with no ⇥ indicator, so you can keep typing characters that match and the suggestion stays alive.
Configuration¶
| Env var | Default | Notes |
|---|---|---|
AISUGGEST_KEY |
^[; (Alt+;) |
zsh keybind notation. Override in ~/.zshrc.adhoc. Ctrl+Z is intentionally NOT used (preserves SIGTSTP); ^[/ (Alt+/) is a fine alternative on layouts where ; is awkward. |
AISUGGEST_AGENT |
empty (autodetect) | Pin to claude / opencode / codex / cursor-agent to skip autodetect. |
AISUGGEST_MODEL |
empty (per-agent default) | When empty, falls through to AICAP_<AGENT>_MODEL from 04_ai_capture.zsh. |
AISUGGEST_HIGHLIGHT |
fg=8 |
zsh region_highlight style for the ghost text. |
AISUGGEST_PROMPT_OVERRIDE |
empty | Replace the built-in system prompt entirely. Power-user knob. |
AISUGGEST_DEBUG |
0 |
1 enables debug logging. |
AISUGGEST_LOG |
/tmp/aisuggest.log |
Debug log path. |
Override in ~/.zshrc.adhoc (untracked) or ~/.config/zsh/secrets.zsh. Don't edit dot_config/zsh/tools/05_aisuggest.zsh directly.
ZLE widget anatomy (how the ghost text actually works)¶
This section captures the design we adopted from upstream (commit cda55e1) so future maintainers can reason about it without re-reading the upstream code.
State machine — Dormant ↔ Active¶
The widget has two states. Five globals track Active state:
| Global | Role |
|---|---|
_AISUGGEST_ACTIVE |
0/1 flag — main gate for accept/dismiss handlers |
_AISUGGEST_SUGGESTION |
The full suggested command text |
_AISUGGEST_BUFFER_AT_SUGGESTION |
Snapshot of $BUFFER when ghost text was rendered |
_AISUGGEST_LAST_HIGHLIGHT |
The exact region_highlight entry we appended (so we can remove only that one on deactivate, not clobber other plugins') |
_AISUGGEST_ORIG_TAB / _AISUGGEST_ORIG_RIGHT |
The Tab and right-arrow widgets bound before we activated, so we can restore them on deactivate |
Trigger flow (Alt+; pressed)¶
- Bail if
BUFFERis empty (no input → nothing to translate). - Background the agent invocation (
_aiagent_invoke) writing to a temp file. - Animate a spinner via
POSTDISPLAYwhile the agent thinks. Pollingread -t 0.1 -k 1lets any keypress cancel. - On response: sanitize (strip ANSI escapes + control chars), then call
_aisuggest_show_ghostand_aisuggest_activate.
Display: POSTDISPLAY + region_highlight (NOT BUFFER mutation)¶
We use zsh's POSTDISPLAY variable (text rendered after the cursor, never part of BUFFER) plus a single region_highlight entry styling its bytes. Two display modes:
- Suggestion is a prefix-extension of buffer:
POSTDISPLAY="${suggestion#$BUFFER}". Pure inline completion — the suggestion grows the buffer when accepted. - Suggestion is different:
POSTDISPLAY=" ⇥ ${suggestion}". Tab-hint indicator tells the user "Tab will replace, not append".
The region_highlight entry covers byte indices [$#BUFFER, $#BUFFER + $#POSTDISPLAY) and is stored verbatim in _AISUGGEST_LAST_HIGHLIGHT so we can remove exactly that entry — leaving any other plugin's highlights untouched — via:
Why POSTDISPLAY (and not append-to-BUFFER)¶
POSTDISPLAY is "text shown after the cursor that isn't part of the editable buffer". Modifying BUFFER would force the user to manually edit out the suggestion if they want to dismiss it — that's the wrong UX. POSTDISPLAY is invisible to BUFFER-reading code and clears trivially by setting POSTDISPLAY="".
Activate: dynamically rebind Tab and →¶
The clever bit. Tab is sacred — globally rebinding it to "accept suggestion" would break filename completion forever. So _aisuggest_activate temporarily steals Tab and right-arrow:
_AISUGGEST_ORIG_TAB=$(bindkey -M main '^I' | awk '{print $2}')
bindkey '^I' _aisuggest_accept_tab
bindkey '^[[C' _aisuggest_accept_right
_aisuggest_deactivate restores them. So Tab → accept while a suggestion is live, Tab → completion the rest of the time.
Dismiss: line-pre-redraw hook with smart prefix logic¶
A hook fires on every redraw. If active and $BUFFER != $_AISUGGEST_BUFFER_AT_SUGGESTION:
- Buffer is still a prefix of the suggestion → re-render ghost text and update the snapshot. (User typed forward characters that match — keep the suggestion alive.)
- Buffer diverged → deactivate, clear POSTDISPLAY + highlight, restore Tab/→.
We register the hook via add-zle-hook-widget line-pre-redraw when available (chains alongside zsh-syntax-highlighting's and zsh-autosuggestions' hooks); falls back to direct zle -N zle-line-pre-redraw otherwise. Chaining is important — overriding the widget kills syntax highlighting.
Line-finish: deactivate on Enter¶
zle-line-finish fires when the line is submitted (Enter). We deactivate to restore Tab/→ before the line runs — otherwise next-line state is stale.
Provider integration¶
_aisuggest_query is the single shim into our agent layer. It:
- Picks an agent:
${AISUGGEST_AGENT:-$(_aiagent_autodetect)}(autodetect from04_ai_capture.zsh:36-45— same order asaifix/aiexplain). - Builds the system prompt (built-in unless
AISUGGEST_PROMPT_OVERRIDEis set). - Calls a low-latency invocation that suppresses metadata, spinner, and prettify (
AICAP_SHOW_METADATA=0 AICAP_SPINNER=0 AICAP_PRETTIFY=0) via_aiagent_invoke. - Post-filters:
head -1(one line only) andsedto strip leading$/#prompt symbols and surrounding backticks the model might add despite the system prompt.
For Claude specifically, we'd ideally use the upstream-style flag profile (--no-session-persistence --effort low --no-chrome --tools "" --setting-sources "") to drop response time from ~5s to ~1.5s. Future enhancement; the MVP relies on _aiagent_invoke's default flags.
System prompt design¶
Inspired by upstream's prompt (rules-only, no chain-of-thought, with examples). Key rules:
- One command, no markdown, no prose.
- BSD vs GNU awareness (the model gets PWD + OS via context).
- Prefix standard tools with
commandto bypass aliases (sols -la→command ls -la). - Example pairs covering: file find, grep, git, lsof, du, date, tar, ffmpeg.
Override the whole thing via AISUGGEST_PROMPT_OVERRIDE.
Interaction with other plugins¶
zsh-autosuggestions (POSTDISPLAY clobber — root cause + fix)¶
Both plugins write POSTDISPLAY. The diagnostic log on 2026-04-27 showed our widget renders ghost text, then by the time the next pre-redraw fires, POSTDISPLAY is empty:
widget: rendered ghost, activated
pre_redraw: BUFFER=[show current disk space] snap=[show current disk space] POSTDISPLAY=[]
Root cause (in ~/.oh-my-zsh/custom/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh:220-221): autosuggestions binds every non-underscore-prefixed, non-autosuggest-* ZLE widget as a "modify" widget — the comment in source says "Assume any unspecified widget might modify the buffer". The wrapper function _zsh_autosuggest_modify (src/widgets.zsh:39) does this around every wrapped invocation:
local orig_postdisplay="$POSTDISPLAY" # save (empty before our widget)
POSTDISPLAY= # clear
_zsh_autosuggest_invoke_original_widget $@ # ← runs our _aisuggest_widget
# … then, if BUFFER didn't change …
POSTDISPLAY="${orig_postdisplay:…}" # restore the empty value, clobbering ours
Pressing Alt+; doesn't change BUFFER (we don't write to it), so the BUFFER-unchanged branch fires and POSTDISPLAY gets reset to the (empty) pre-widget value.
Fix. Tell zsh-autosuggestions to not wrap our widgets, by adding them to its ZSH_AUTOSUGGEST_IGNORE_WIDGETS array at file source time:
typeset -ga ZSH_AUTOSUGGEST_IGNORE_WIDGETS
ZSH_AUTOSUGGEST_IGNORE_WIDGETS+=(aisuggest _aisuggest_accept_tab _aisuggest_accept_right _aisuggest_pre_redraw)
This works because autosuggestions' _zsh_autosuggest_start runs as a precmd hook and re-binds widgets every prompt by default — it reads the ignore list fresh on each rebind, so appending in our file sourced after OMZ takes effect on the next prompt.
Defense-in-depth. _aisuggest_pre_redraw still re-asserts POSTDISPLAY if it goes empty while active, in case another POSTDISPLAY consumer slips in. Cheap insurance.
Why we don't call _zsh_autosuggest_disable instead: _zsh_autosuggest_disable itself calls _zsh_autosuggest_clear, which sets POSTDISPLAY="" — actively making the problem worse if invoked at the wrong moment. ZSH_AUTOSUGGEST_IGNORE_WIDGETS is the surgical fix.
zsh-vi-mode (bindkey wipe)¶
zsh-vi-mode calls a private function on init that drops every bindkey. It exposes a zvm_after_init hook so plugins can re-apply their bindings. Our trigger key is rebound there; see dot_zshrc.tmpl. The Tab/→ swap-bind happens during widget invocation (after init) so it's not affected.
zsh-syntax-highlighting (line-pre-redraw)¶
Repaints region_highlight on every redraw. Because we use add-zle-hook-widget (chains rather than overrides), our hook runs alongside theirs. Our highlight entry is appended after theirs each redraw — visible and correctly styled.
Debugging¶
Two surfaces for diagnosis when the widget misbehaves:
CLI (synchronous, no ZLE in the picture)¶
aisuggest "find files larger than 100 MB" # prints the suggested command
aisuggest --debug "find files larger than 100 MB" # also prints raw / sanitized / final to stderr
Use this to test whether _aisuggest_query itself is healthy — agent reachable, prompt followed, post-filter not over-stripping. If the CLI works but the widget doesn't, the bug is in the widget-specific path (subshell, spinner, POSTDISPLAY render).
Widget tracing¶
export AISUGGEST_DEBUG=1 # toggleable at runtime, no restart
: > /tmp/aisuggest.log # truncate
# trigger Alt+; on a description
cat /tmp/aisuggest.log
Each step writes a labelled line: widget: enter, widget: backgrounded pid=…, widget: spinner exited at i=N, reading tmpfile, widget: raw stdout (len=N)=[…], show_ghost: POSTDISPLAY=[…], activate: orig_tab=[…] autosuggest_paused=…, pre_redraw: BUFFER=[…] snap=[…] POSTDISPLAY=[…], deactivate: called from <function>.
Common failure modes → log signatures:
| Symptom | Log line that points to it | Likely fix |
|---|---|---|
| Spinner runs, then nothing visible | rendered ghost, activated followed by pre_redraw: ... POSTDISPLAY=[] |
POSTDISPLAY contention — verify _zsh_autosuggest_disable ran (autosuggest_paused=1 in activate log line); fallback re-assert should trigger |
| Spinner cancels almost immediately | widget: read cancelled at i=N (key='…') |
Stale input in the TTY buffer or a key event leaking through; switch cancel mechanism |
| Spinner runs, "no suggestion" message | widget: empty suggestion → 'no suggestion' branch, widget: raw stdout (len=0) |
Subshell-context agent failure — check widget: raw stderr=[…] for the underlying error |
| Widget activates then immediately deactivates | deactivate: called from _aisuggest_pre_redraw (POSTDISPLAY was [...]) |
Buffer mismatch; check pre_redraw BUFFER vs snap |
Disable debug logging: export AISUGGEST_DEBUG=0 (no restart needed; _aisuggest_log checks the flag at call time).
Troubleshooting¶
| Symptom | Diagnosis |
|---|---|
| Trigger does nothing | Check bindkey \| grep '\^\[;' — if missing, zvm_after_init didn't rebind. Check zle -l \| grep aisuggest — if missing, the file didn't source (look for guard-bail at top: missing claude/opencode/codex/cursor-agent CLI, or missing curl/jq). |
| Ghost text appears but Tab opens completion menu instead of accepting | _aisuggest_activate failed to swap-bind Tab. Check bindkey '^I' while active — should show _aisuggest_accept_tab. |
| Ghost text doesn't clear on edit | The line-pre-redraw hook isn't firing. Check zle -l \| grep -i pre-redraw — if add-zle-hook-widget wasn't available, fallback set zle-line-pre-redraw directly which can be clobbered by other plugins. |
| Suggestions take >5s | Default agent uses _aiagent_invoke's standard flags (claude with JSON output). Pin a faster agent: export AISUGGEST_AGENT=opencode. |
| Wrong agent picked | Set AISUGGEST_AGENT explicitly. _aiagent_autodetect's order is claude → opencode → codex → cursor-agent; whichever is first on PATH wins. |
Job control broken (Ctrl+Z doesn't suspend) |
Someone set AISUGGEST_KEY=^Z. Reset to default ^[;. |
Pressed Enter, got command not found: <my words> |
The ghost text is a hint, not the buffer contents. Tab or → replaces BUFFER with the suggestion; Enter runs BUFFER as-is. Press Tab first, then Enter. |
Enable debug logging:
export AISUGGEST_DEBUG=1
export AISUGGEST_LOG=~/.cache/aisuggest.log
exec zsh
# Trigger a few suggestions, then:
tail -f ~/.cache/aisuggest.log
Re-study upstream for new ideas¶
Upstream evolves; we don't auto-pull. Re-read with:
Files worth re-reading on a refresh pass:
zsh-ai-cmd.plugin.zsh— widget mechanics, state machine, sanitization.prompt.zsh— prompt design changes; consider porting good rule additions.providers/claude-code.zsh— claude CLI flag profile for low latency.CHANGELOG.md— what changed sincecda55e1(the commit our design was learned from).
If upstream adds a feature we want (e.g., async-via-zle -F for non-blocking spinner), we port the idea into our 05_aisuggest.zsh, not the code.
Roadmap — ideas worth porting¶
Concrete enhancements identified during the upstream study at commit cda55e1. Listed roughly in payoff-vs-effort order; pick one when you next touch this file.
1. Low-latency claude flag profile (high payoff, low risk)¶
Today our _aisuggest_query shells out via _aiagent_invoke which calls claude -p --model "$AICAP_CLAUDE_MODEL" (with JSON output for metadata). For inline suggestions we don't need the metadata, and Claude Code's CLI startup dominates the 1–3 s response time. Upstream's providers/claude-code.zsh uses a stripped-down profile that they measure as ~1.5× faster:
claude -p --no-session-persistence --effort low \
--disable-slash-commands --strict-mcp-config \
--setting-sources "" --no-chrome --tools "" \
--system-prompt "$prompt" "$user_input"
Two ways to take this:
- A. Add a parallel low-latency path in
_aisuggest_query: whenagent == claude, bypass_aiagent_invokeand shell out directly with the upstream flag profile +--system-prompt. Keeps_aiagent_invokeunchanged foraifix/aiexplain. - B. Add a new
_aiagent_invoke_fastin04_ai_capture.zshthat takes a system prompt separately and uses the stripped-down flags for all agents (opencode--no-system-context, etc.). Cleaner abstraction; more work; touchesaifix/aiexplain.
(A) is the safer first move.
2. --system-prompt separation (small but cleaner)¶
Currently we concatenate system + context + user input into one prompt string and pass it as the user message. Claude follows it, but --system-prompt (upstream uses this) lets the model treat rules as immutable system context. Tighter rule-following, occasionally fewer "explanation slipped through" cases. Pairs naturally with idea (1).
3. Async via zle -F (medium effort, big UX win)¶
Today _aisuggest_widget blocks the prompt for the duration of the agent call. The spinner-on-POSTDISPLAY animation hides this visually, but you can't keep typing during the wait — any keypress cancels the request. zsh's zle -F <fd> <handler> lets you register a callback when an FD becomes readable, so you can fire the agent in the background, return immediately to the prompt, and have the suggestion land asynchronously when the FD has data. Upstream doesn't do this either, but it's the natural next UX leap. Sketch: mkfifo or mktemp → fork agent → zle -F $fd _aisuggest_on_response → user types freely → callback fires when response arrives → render ghost text if buffer hasn't diverged.
Constraint: must handle "user submitted line before response arrived" gracefully — _aisuggest_line_finish should kill the FD watcher.
4. Direct API providers (anthropic / openai / gemini) (only if needed)¶
We currently only support the four CLIs (claude, opencode, codex, cursor-agent). Users without any of those installed get nothing. Upstream supports direct API calls via curl + jq for anthropic / openai / gemini / deepseek / ollama / copilot. If we ever want a no-CLI fallback, port the providers/anthropic.zsh pattern (single curl -s POST, jq -r '.content[0].text' extract). Pair with upstream's ZSH_AI_CMD_API_KEY_COMMAND pattern so users can pull the key from op, pass, GNOME Keyring, AWS Secrets Manager, etc. — flexible without baking provider knowledge into us.
Flag for pickup: this is a user-driven enhancement — only do it when someone asks for it, since it adds curl/jq dependencies on the hot path and a maintenance surface.
5. Sanitize regression test (cheap insurance)¶
Upstream ships test-sanitize.sh — feeds crafted ANSI / control-character payloads through _zsh_ai_cmd_sanitize and asserts no leakage. Our _aisuggest_sanitize is a verbatim port; one bad regex change could re-introduce escape-injection. Add a tiny tests/aisuggest_sanitize.sh shellscript that sources 05_aisuggest.zsh and asserts on a handful of payloads (CSI sequence, raw ESC, NULL byte, BEL, DEL). 30 lines, runs in <1 s.
6. Per-provider model env vars (consistency with upstream)¶
Today AISUGGEST_MODEL is a single env var that overrides whichever agent is picked (we override AICAP_<AGENT>_MODEL in the shim). Upstream uses provider-specific names: ZSH_AI_CMD_ANTHROPIC_MODEL, ZSH_AI_CMD_OPENAI_MODEL, etc. — lets a user pin different models per provider without re-exporting on agent change. If we ever support multiple agents in one session, switch to AISUGGEST_<AGENT>_MODEL (and keep AISUGGEST_MODEL as a fallback for back-compat).
7. CHANGELOG-driven re-sync cadence¶
Upstream's CHANGELOG.md is concise. A quarterly git -C /tmp/zsh-ai-cmd-study log --oneline cda55e1..HEAD + skim is enough to spot porting candidates. Set a /schedule reminder when you want this on a cadence; otherwise just run it next time you're in this file.
Attribution¶
UX design and ZLE-widget mechanics learned from kylesnowschwartz/zsh-ai-cmd (MIT, by Kyle Snow Schwartz). All code in dot_config/zsh/tools/05_aisuggest.zsh is our own implementation, written to integrate with this repo's existing _aiagent_invoke layer. We're grateful for the upstream design — go star their repo if you appreciate the original.