This commit is contained in:
Frad LEE 2026-02-05 14:45:47 +08:00 committed by GitHub
commit 78fb80ebfa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1606 additions and 5 deletions

View file

@ -49,11 +49,12 @@ For more info, read the Character Highlighting section of the zsh manual: `man z
### Suggestion Strategy ### Suggestion Strategy
`ZSH_AUTOSUGGEST_STRATEGY` is an array that specifies how suggestions should be generated. The strategies in the array are tried successively until a suggestion is found. There are currently three built-in strategies to choose from: `ZSH_AUTOSUGGEST_STRATEGY` is an array that specifies how suggestions should be generated. The strategies in the array are tried successively until a suggestion is found. There are currently four built-in strategies to choose from:
- `history`: Chooses the most recent match from history. - `history`: Chooses the most recent match from history.
- `completion`: Chooses a suggestion based on what tab-completion would suggest. (requires `zpty` module, which is included with zsh since 4.0.1) - `completion`: Chooses a suggestion based on what tab-completion would suggest. (requires `zpty` module, which is included with zsh since 4.0.1)
- `match_prev_cmd`: Like `history`, but chooses the most recent match whose preceding history item matches the most recently executed command ([more info](src/strategies/match_prev_cmd.zsh)). Note that this strategy won't work as expected with ZSH options that don't preserve the history order such as `HIST_IGNORE_ALL_DUPS` or `HIST_EXPIRE_DUPS_FIRST`. - `match_prev_cmd`: Like `history`, but chooses the most recent match whose preceding history item matches the most recently executed command ([more info](src/strategies/match_prev_cmd.zsh)). Note that this strategy won't work as expected with ZSH options that don't preserve the history order such as `HIST_IGNORE_ALL_DUPS` or `HIST_EXPIRE_DUPS_FIRST`.
- `ai`: Queries an OpenAI-compatible LLM API to generate command completions based on your partial input, working directory, and recent shell history. (requires `curl` and `jq`, opt-in via `ZSH_AUTOSUGGEST_AI_API_KEY`)
For example, setting `ZSH_AUTOSUGGEST_STRATEGY=(history completion)` will first try to find a suggestion from your history, but, if it can't find a match, will find a suggestion from the completion engine. For example, setting `ZSH_AUTOSUGGEST_STRATEGY=(history completion)` will first try to find a suggestion from your history, but, if it can't find a match, will find a suggestion from the completion engine.
@ -100,6 +101,90 @@ Set `ZSH_AUTOSUGGEST_COMPLETION_IGNORE` to a [glob pattern](http://zsh.sourcefor
**Note:** This only affects the `completion` suggestion strategy. **Note:** This only affects the `completion` suggestion strategy.
### AI Strategy Configuration
The `ai` strategy uses an OpenAI-compatible LLM API to generate intelligent command completions based on your shell context.
#### Prerequisites
- `curl` command-line tool
- `jq` JSON processor
- API key for an OpenAI-compatible service
#### Setup
To enable the AI strategy, you must set the `ZSH_AUTOSUGGEST_AI_API_KEY` environment variable and add `ai` to your strategy list:
```sh
export ZSH_AUTOSUGGEST_AI_API_KEY="your-api-key-here"
export ZSH_AUTOSUGGEST_STRATEGY=(ai history)
```
**Security Note:** Never commit your API key to version control. Consider storing it in a separate file (e.g., `~/.zsh_secrets`) that is sourced in your `.zshrc` and excluded from version control.
#### Configuration Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `ZSH_AUTOSUGGEST_AI_API_KEY` | (unset) | **Required.** API key for the LLM service. Strategy is disabled if unset. |
| `ZSH_AUTOSUGGEST_AI_ENDPOINT` | `https://api.openai.com/v1` | API base URL |
| `ZSH_AUTOSUGGEST_AI_MODEL` | `gpt-3.5-turbo` | Model name to use |
| `ZSH_AUTOSUGGEST_AI_TIMEOUT` | `5` | Request timeout in seconds |
| `ZSH_AUTOSUGGEST_AI_MIN_INPUT` | `1` | Minimum input length before querying (`0` enables empty-buffer suggestions) |
| `ZSH_AUTOSUGGEST_AI_HISTORY_LINES` | `5` | Number of recent history lines to send as context |
| `ZSH_AUTOSUGGEST_AI_PREFER_PWD_HISTORY` | `no` | Prioritize history from current directory |
| `ZSH_AUTOSUGGEST_AI_DEBUG` | (unset) | Prints AI debug logs to stderr when enabled |
#### Empty Buffer Suggestions
By default, suggestions only appear when you start typing. You can enable suggestions on an empty command line by setting `ZSH_AUTOSUGGEST_AI_MIN_INPUT=0`:
```sh
export ZSH_AUTOSUGGEST_AI_MIN_INPUT=0
export ZSH_AUTOSUGGEST_AI_API_KEY="your-api-key-here"
export ZSH_AUTOSUGGEST_STRATEGY=(ai history)
```
**Notes:**
- Requires zsh 5.3+ for prompt-time suggestions
- This feature is primarily designed for the AI strategy, which can predict the next likely command based on your current directory, git status, and recent history
- Traditional strategies (history, completion) don't benefit from empty buffer suggestions
- **Cost consideration:** With AI strategy, this will make an API request on every new prompt, which may increase API costs
#### AI Debug Logs
If AI suggestions are not appearing, enable debug logs:
```sh
export ZSH_AUTOSUGGEST_AI_DEBUG=1
```
Debug logs are printed to stderr with the prefix `[zsh-autosuggestions ai]`.
#### Examples
**OpenAI (default):**
```sh
export ZSH_AUTOSUGGEST_AI_API_KEY="sk-..."
export ZSH_AUTOSUGGEST_STRATEGY=(ai history)
```
**Ollama (local LLM):**
```sh
export ZSH_AUTOSUGGEST_AI_API_KEY="not-needed" # Required but unused by Ollama
export ZSH_AUTOSUGGEST_AI_ENDPOINT="http://localhost:11434/v1"
export ZSH_AUTOSUGGEST_AI_MODEL="codellama"
export ZSH_AUTOSUGGEST_STRATEGY=(ai history)
```
**Custom OpenAI-compatible endpoint:**
```sh
export ZSH_AUTOSUGGEST_AI_API_KEY="your-key"
export ZSH_AUTOSUGGEST_AI_ENDPOINT="https://your-endpoint.com/v1"
export ZSH_AUTOSUGGEST_AI_MODEL="your-model"
export ZSH_AUTOSUGGEST_STRATEGY=(ai history)
```
### Key Bindings ### Key Bindings

View file

@ -0,0 +1,25 @@
describe 'the `ai` strategy debug logging' do
let(:options) { ["ZSH_AUTOSUGGEST_STRATEGY=(ai)"] }
context 'when debug is enabled' do
let(:options) do
[
"ZSH_AUTOSUGGEST_STRATEGY=(ai)",
"ZSH_AUTOSUGGEST_AI_DEBUG=1"
]
end
it 'logs why AI suggestion is skipped when API key is missing' do
session.send_string('brew')
wait_for { session.content }.to match(/\[zsh-autosuggestions ai\] API key not set/)
end
end
context 'when debug is disabled by default' do
it 'does not print AI debug logs' do
session.send_string('brew')
sleep 0.2
expect(session.content).not_to match(/\[zsh-autosuggestions ai\]/)
end
end
end

407
spec/strategies/ai_spec.rb Normal file
View file

@ -0,0 +1,407 @@
describe 'the `ai` suggestion strategy' do
let(:options) { ["ZSH_AUTOSUGGEST_STRATEGY=(ai history)"] }
let(:before_sourcing) do
-> {
session.run_command('curl() {
if [[ "$*" == *"max-time"* ]]; then
cat <<EOF
{"choices":[{"message":{"content":"git status --short"}}]}
200
EOF
fi
}')
}
end
context 'when API key is not set' do
it 'returns no suggestion and falls through to next strategy' do
with_history('git status --short') do
session.send_string('git st')
wait_for { session.content }.to eq('git status --short')
end
end
end
context 'when API key is set' do
let(:options) { ["ZSH_AUTOSUGGEST_AI_API_KEY=test-key", "ZSH_AUTOSUGGEST_STRATEGY=(ai history)"] }
context 'and curl/jq are available' do
it 'suggests completion from AI' do
session.send_string('git st')
wait_for { session.content }.to eq('git status --short')
end
end
context 'when input is below minimum threshold' do
let(:options) { ["ZSH_AUTOSUGGEST_AI_API_KEY=test-key", "ZSH_AUTOSUGGEST_AI_MIN_INPUT=5", "ZSH_AUTOSUGGEST_STRATEGY=(ai history)"] }
it 'returns no suggestion for short input' do
with_history('git status') do
session.send_string('git')
wait_for { session.content }.to eq('git status')
end
end
end
end
context 'when curl fails' do
let(:options) { ["ZSH_AUTOSUGGEST_AI_API_KEY=test-key", "ZSH_AUTOSUGGEST_STRATEGY=(ai history)"] }
let(:before_sourcing) do
-> {
session.run_command('curl() { return 1 }')
}
end
it 'falls through to next strategy' do
with_history('git status') do
session.send_string('git st')
wait_for { session.content }.to eq('git status')
end
end
end
context 'when API returns HTTP error' do
let(:options) { ["ZSH_AUTOSUGGEST_AI_API_KEY=test-key", "ZSH_AUTOSUGGEST_STRATEGY=(ai history)"] }
let(:before_sourcing) do
-> {
session.run_command('curl() {
if [[ "$*" == *"max-time"* ]]; then
cat <<EOF
{"error":"Unauthorized"}
401
EOF
fi
}')
}
end
it 'falls through to next strategy' do
with_history('git status') do
session.send_string('git st')
wait_for { session.content }.to eq('git status')
end
end
end
context 'when API returns malformed JSON' do
let(:options) { ["ZSH_AUTOSUGGEST_AI_API_KEY=test-key", "ZSH_AUTOSUGGEST_STRATEGY=(ai history)"] }
let(:before_sourcing) do
-> {
session.run_command('curl() {
if [[ "$*" == *"max-time"* ]]; then
cat <<EOF
not valid json
200
EOF
fi
}')
}
end
it 'falls through to next strategy' do
with_history('git status') do
session.send_string('git st')
wait_for { session.content }.to eq('git status')
end
end
end
context 'response normalization' do
let(:options) { ["ZSH_AUTOSUGGEST_AI_API_KEY=test-key", "ZSH_AUTOSUGGEST_STRATEGY=(ai)"] }
context 'when response has markdown code fences' do
let(:before_sourcing) do
-> {
session.run_command('curl() {
if [[ "$*" == *"max-time"* ]]; then
cat <<EOF
{"choices":[{"message":{"content":"```\\ngit status\\n```"}}]}
200
EOF
fi
}')
}
end
it 'strips the code fences' do
session.send_string('git st')
wait_for { session.content }.to eq('git status')
end
end
context 'when response includes the input prefix' do
let(:before_sourcing) do
-> {
session.run_command('curl() {
if [[ "$*" == *"max-time"* ]]; then
cat <<EOF
{"choices":[{"message":{"content":"git status --short"}}]}
200
EOF
fi
}')
}
end
it 'uses the complete command' do
session.send_string('git st')
wait_for { session.content }.to eq('git status --short')
end
end
end
context 'fallback strategy' do
let(:options) { ["ZSH_AUTOSUGGEST_AI_API_KEY=test-key", "ZSH_AUTOSUGGEST_STRATEGY=(ai history)"] }
let(:before_sourcing) do
-> {
session.run_command('curl() { return 1 }')
}
end
it 'uses history when AI fails' do
with_history('git status --long') do
session.send_string('git st')
wait_for { session.content }.to eq('git status --long')
end
end
end
end
context 'prompt artifact stripping' do
let(:options) { ["ZSH_AUTOSUGGEST_AI_API_KEY=test-key", "ZSH_AUTOSUGGEST_STRATEGY=(ai)"] }
let(:before_sourcing) do
-> {
session.run_command('curl() {
if [[ "$*" == *"max-time"* ]]; then
cat <<EOFCURL
{"choices":[{"message":{"content":"$ git status"}}]}
200
EOFCURL
fi
}')
}
end
it 'strips $ prompt artifact' do
session.send_string('git st')
wait_for { session.content }.to eq('git status')
end
end
context 'empty buffer suggestions' do
let(:options) { ["ZSH_AUTOSUGGEST_AI_API_KEY=test-key", "ZSH_AUTOSUGGEST_AI_MIN_INPUT=0", "ZSH_AUTOSUGGEST_STRATEGY=(ai)"] }
let(:before_sourcing) do
-> {
session.run_command('curl() {
if [[ "$*" == *"max-time"* ]]; then
cat <<EOFCURL
{"choices":[{"message":{"content":"git status"}}]}
200
EOFCURL
fi
}')
}
end
it 'suggests command on empty buffer when enabled' do
session.send_keys('C-c')
wait_for { session.content(esc_seqs: true) }.to match(/git status/)
end
end
context 'empty buffer with default min input' do
let(:options) { ["ZSH_AUTOSUGGEST_AI_API_KEY=test-key", "ZSH_AUTOSUGGEST_AI_MIN_INPUT=1", "ZSH_AUTOSUGGEST_STRATEGY=(ai history)"] }
let(:before_sourcing) do
-> {
session.run_command('curl() {
if [[ "$*" == *"max-time"* ]]; then
cat <<EOFCURL
{"choices":[{"message":{"content":"git status"}}]}
200
EOFCURL
fi
}')
}
end
it 'does not suggest on empty buffer when min input is 1' do
with_history('git status') do
sleep 0.5
expect(session.content).to_not match(/git status/)
session.send_string('git')
wait_for { session.content }.to eq('git status')
end
end
end
context 'endpoint construction' do
let(:options) { ["ZSH_AUTOSUGGEST_AI_API_KEY=test-key", "ZSH_AUTOSUGGEST_STRATEGY=(ai)"] }
let(:before_sourcing) do
-> {
session.run_command('curl() {
local url=""
for arg in "$@"; do
if [[ "$arg" == http* ]]; then
url="$arg"
break
fi
done
if [[ "$url" == */chat/completions ]]; then
cat <<EOFCURL
{"choices":[{"message":{"content":"correct endpoint"}}]}
200
EOFCURL
else
cat <<EOFCURL
{"error":"wrong endpoint"}
400
EOFCURL
fi
}')
}
end
it 'appends /chat/completions to base URL' do
session.send_string('test')
wait_for { session.content }.to eq('correct endpoint')
end
context 'with custom base URL' do
let(:options) { ["ZSH_AUTOSUGGEST_AI_API_KEY=test-key", "ZSH_AUTOSUGGEST_AI_ENDPOINT=http://custom.api/v1", "ZSH_AUTOSUGGEST_STRATEGY=(ai)"] }
it 'constructs endpoint correctly' do
session.send_string('test')
wait_for { session.content }.to eq('correct endpoint')
end
end
end
context 'environmental context gathering' do
let(:options) { ["ZSH_AUTOSUGGEST_AI_API_KEY=test-key", "ZSH_AUTOSUGGEST_STRATEGY=(ai)"] }
let(:before_sourcing) do
-> {
session.run_command('curl() {
local data=""
for arg in "$@"; do
if [[ "$arg" == -d ]]; then
shift
data="$1"
break
fi
shift
done
if [[ "$data" == *"Directory contents"* ]]; then
cat <<EOFCURL
{"choices":[{"message":{"content":"has directory context"}}]}
200
EOFCURL
else
cat <<EOFCURL
{"choices":[{"message":{"content":"no context"}}]}
200
EOFCURL
fi
}')
session.run_command('ls() { echo "file1.txt\nfile2.txt"; }')
}
end
it 'includes directory contents in context' do
session.send_string('test')
wait_for { session.content }.to eq('has directory context')
end
end
context 'dual prompt modes' do
let(:options) { ["ZSH_AUTOSUGGEST_AI_API_KEY=test-key", "ZSH_AUTOSUGGEST_AI_MIN_INPUT=0", "ZSH_AUTOSUGGEST_STRATEGY=(ai)"] }
context 'empty buffer mode' do
let(:before_sourcing) do
-> {
session.run_command('curl() {
local data=""
for arg in "$@"; do
if [[ "$arg" == -d ]]; then
shift
data="$1"
break
fi
shift
done
if [[ "$data" == *"prediction engine"* ]]; then
cat <<EOFCURL
{"choices":[{"message":{"content":"prediction mode active"}}]}
200
EOFCURL
elif [[ "$data" == *"auto-completion engine"* ]]; then
cat <<EOFCURL
{"choices":[{"message":{"content":"completion mode active"}}]}
200
EOFCURL
fi
}')
}
end
it 'uses prediction prompt for empty buffer' do
session.send_keys('C-c')
wait_for { session.content(esc_seqs: true) }.to match(/prediction mode/)
end
it 'uses completion prompt for partial input' do
session.send_string('git')
wait_for { session.content }.to match(/completion mode/)
end
end
end
context 'temperature configuration' do
let(:options) { ["ZSH_AUTOSUGGEST_AI_API_KEY=test-key", "ZSH_AUTOSUGGEST_AI_MIN_INPUT=0", "ZSH_AUTOSUGGEST_STRATEGY=(ai)"] }
let(:before_sourcing) do
-> {
session.run_command('curl() {
local data=""
for arg in "$@"; do
if [[ "$arg" == -d ]]; then
shift
data="$1"
break
fi
shift
done
if [[ "$data" == *"\"temperature\": 0.5"* ]]; then
cat <<EOFCURL
{"choices":[{"message":{"content":"temp 0.5"}}]}
200
EOFCURL
elif [[ "$data" == *"\"temperature\": 0.3"* ]]; then
cat <<EOFCURL
{"choices":[{"message":{"content":"temp 0.3"}}]}
200
EOFCURL
fi
}')
}
end
it 'uses temperature 0.5 for empty buffer' do
session.send_keys('C-c')
wait_for { session.content(esc_seqs: true) }.to match(/temp 0.5/)
end
it 'uses temperature 0.3 for partial input' do
session.send_string('test')
wait_for { session.content }.to eq('temp 0.3')
end
end

View file

@ -93,3 +93,32 @@ typeset -g ZSH_AUTOSUGGEST_ORIGINAL_WIDGET_PREFIX=autosuggest-orig-
# Pty name for capturing completions for completion suggestion strategy # Pty name for capturing completions for completion suggestion strategy
(( ! ${+ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME} )) && (( ! ${+ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME} )) &&
typeset -g ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME=zsh_autosuggest_completion_pty typeset -g ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME=zsh_autosuggest_completion_pty
# AI strategy configuration
# API base URL for AI suggestions (OpenAI-compatible)
(( ! ${+ZSH_AUTOSUGGEST_AI_ENDPOINT} )) &&
typeset -g ZSH_AUTOSUGGEST_AI_ENDPOINT='https://api.openai.com/v1'
# AI model to use for suggestions
(( ! ${+ZSH_AUTOSUGGEST_AI_MODEL} )) &&
typeset -g ZSH_AUTOSUGGEST_AI_MODEL='gpt-3.5-turbo'
# Timeout in seconds for AI API requests
(( ! ${+ZSH_AUTOSUGGEST_AI_TIMEOUT} )) &&
typeset -g ZSH_AUTOSUGGEST_AI_TIMEOUT=5
# Minimum input length before querying AI
# Set to 0 to allow empty-buffer AI suggestions
(( ! ${+ZSH_AUTOSUGGEST_AI_MIN_INPUT} )) &&
typeset -g ZSH_AUTOSUGGEST_AI_MIN_INPUT=1
# Number of recent history lines to include as context
(( ! ${+ZSH_AUTOSUGGEST_AI_HISTORY_LINES} )) &&
typeset -g ZSH_AUTOSUGGEST_AI_HISTORY_LINES=5
# Prefer history entries from current directory
(( ! ${+ZSH_AUTOSUGGEST_AI_PREFER_PWD_HISTORY} )) &&
typeset -g ZSH_AUTOSUGGEST_AI_PREFER_PWD_HISTORY=no
# Enable AI debug logs to stderr (opt-in).
# Set to any value except 0/false/no/off to enable.

View file

@ -31,3 +31,18 @@ fi
# Start the autosuggestion widgets on the next precmd # Start the autosuggestion widgets on the next precmd
add-zsh-hook precmd _zsh_autosuggest_start add-zsh-hook precmd _zsh_autosuggest_start
_zsh_autosuggest_line_init() {
emulate -L zsh
local min_input="${ZSH_AUTOSUGGEST_AI_MIN_INPUT:-1}"
if (( min_input == 0 )) && \
(( ! ${+_ZSH_AUTOSUGGEST_DISABLED} )); then
_zsh_autosuggest_fetch
fi
}
# Use add-zle-hook-widget (zsh 5.3+) to avoid conflicts with other plugins
if (( ${+functions[add-zle-hook-widget]} )) || \
autoload -Uz add-zle-hook-widget 2>/dev/null; then
add-zle-hook-widget zle-line-init _zsh_autosuggest_line_init
fi

291
src/strategies/ai.zsh Normal file
View file

@ -0,0 +1,291 @@
#--------------------------------------------------------------------#
# AI Suggestion Strategy #
#--------------------------------------------------------------------#
# Queries an OpenAI-compatible LLM API to generate command
# completions based on partial input, working directory, and
# recent shell history.
#
_zsh_autosuggest_strategy_ai_json_escape() {
# Reset options to defaults and enable LOCAL_OPTIONS
emulate -L zsh
local input="$1"
local output=""
local char
for ((i=1; i<=${#input}; i++)); do
char="${input:$((i-1)):1}"
case "$char" in
'\\') output+='\\\\' ;;
'"') output+='\"' ;;
$'\n') output+='\n' ;;
$'\t') output+='\t' ;;
$'\r') output+='\r' ;;
[[:cntrl:]]) ;; # Skip other control chars
*) output+="$char" ;;
esac
done
printf '%s' "$output"
}
_zsh_autosuggest_strategy_ai_gather_context() {
# Reset options to defaults and enable LOCAL_OPTIONS
emulate -L zsh
local max_lines="${ZSH_AUTOSUGGEST_AI_HISTORY_LINES:-5}"
local prefer_pwd="${ZSH_AUTOSUGGEST_AI_PREFER_PWD_HISTORY:-no}"
local pwd_basename="${PWD:t}"
local -a context_lines
local -a pwd_lines
local -a other_lines
local line
# Iterate from most recent history
for line in "${(@On)history}"; do
# Truncate long lines
if [[ ${#line} -gt 200 ]]; then
line="${line:0:200}..."
fi
# Categorize by PWD relevance - match full path, basename, or PWD-relevant commands
if [[ "$prefer_pwd" == "yes" ]] && [[ "$line" == *"$PWD"* || "$line" == *"$pwd_basename"* || "$line" == cd* || "$line" == ls* || "$line" == "git "* || "$line" == *"./"* || "$line" == *"../"* ]]; then
pwd_lines+=("$line")
else
other_lines+=("$line")
fi
done
# Cap PWD lines at 2/3 of max to maintain diversity
local pwd_max=$(( (max_lines * 2) / 3 ))
local pwd_count=${#pwd_lines}
[[ $pwd_count -gt $pwd_max ]] && pwd_count=$pwd_max
# Prioritize PWD-relevant lines, then fill with others
context_lines=("${(@)pwd_lines[1,$pwd_count]}" "${other_lines[@]}")
context_lines=("${(@)context_lines[1,$max_lines]}")
# Return via reply array
reply=("${context_lines[@]}")
}
_zsh_autosuggest_strategy_ai_gather_env_context() {
# Reset options to defaults and enable LOCAL_OPTIONS
emulate -L zsh
local -A env_info
# Directory listing (up to 20 entries)
local dir_contents
dir_contents=$(command ls -1 2>/dev/null | head -20 | tr '\n' ', ' | sed 's/, $//')
[[ -n "$dir_contents" ]] && env_info[dir_contents]="$dir_contents"
# Git branch (try two methods)
local git_branch
git_branch=$(command git branch --show-current 2>/dev/null)
[[ -z "$git_branch" ]] && git_branch=$(command git rev-parse --abbrev-ref HEAD 2>/dev/null)
[[ -n "$git_branch" ]] && env_info[git_branch]="$git_branch"
# Git status (up to 10 lines)
local git_status
git_status=$(command git status --porcelain 2>/dev/null | head -10 | tr '\n' '; ' | sed 's/; $//')
[[ -n "$git_status" ]] && env_info[git_status]="$git_status"
# Return via reply associative array
typeset -gA reply
reply=("${(@kv)env_info}")
}
_zsh_autosuggest_strategy_ai_normalize() {
# Reset options to defaults and enable LOCAL_OPTIONS
emulate -L zsh
local response="$1"
local buffer="$2"
local result=""
# Strip \r
response="${response//$'\r'/}"
# Strip leading prompt artifacts ($ or >)
response="${response##\$ }"
response="${response##> }"
# Strip markdown code fences
response="${response##\`\`\`*$'\n'}"
response="${response%%$'\n'\`\`\`}"
# Strip surrounding quotes
if [[ "$response" == \"*\" || "$response" == \'*\' ]]; then
response="${response:1:-1}"
fi
# Trim whitespace
response="${response##[[:space:]]##}"
response="${response%%[[:space:]]##}"
# Take first line only
result="${response%%$'\n'*}"
# If response starts with buffer, extract suffix
if [[ "$result" == "$buffer"* ]]; then
local suffix="${result#$buffer}"
result="$buffer$suffix"
# If response looks like a pure suffix, prepend buffer
elif [[ -n "$buffer" ]] && [[ "$result" != "$buffer"* ]] && [[ "${buffer}${result}" == [[:print:]]* ]]; then
result="$buffer$result"
fi
printf '%s' "$result"
}
_zsh_autosuggest_strategy_ai_debug_log() {
# Reset options to defaults and enable LOCAL_OPTIONS
emulate -L zsh
local debug="${ZSH_AUTOSUGGEST_AI_DEBUG:-0}"
case "${debug:l}" in
0|false|no|off) return ;;
esac
print -ru2 -- "[zsh-autosuggestions ai] $1"
}
_zsh_autosuggest_strategy_ai() {
# Reset options to defaults and enable LOCAL_OPTIONS
emulate -L zsh
typeset -g suggestion
local buffer="$1"
# Early return if API key not set (opt-in gate)
if [[ -z "$ZSH_AUTOSUGGEST_AI_API_KEY" ]]; then
_zsh_autosuggest_strategy_ai_debug_log "API key not set; skipping AI request."
return
fi
# Early return if curl or jq not available
if [[ -z "${commands[curl]}" ]] || [[ -z "${commands[jq]}" ]]; then
_zsh_autosuggest_strategy_ai_debug_log "Missing dependency: curl and jq are required."
return
fi
# Early return if input too short
local min_input="${ZSH_AUTOSUGGEST_AI_MIN_INPUT:-1}"
if [[ ${#buffer} -lt $min_input ]]; then
_zsh_autosuggest_strategy_ai_debug_log "Input shorter than ZSH_AUTOSUGGEST_AI_MIN_INPUT=$min_input."
return
fi
# Gather history context
local -a context
_zsh_autosuggest_strategy_ai_gather_context
context=("${reply[@]}")
# Gather environment context
local -A env_context
_zsh_autosuggest_strategy_ai_gather_env_context
env_context=("${(@kv)reply}")
# Build context string
local context_str=""
for line in "${context[@]}"; do
if [[ -n "$context_str" ]]; then
context_str+=", "
fi
context_str+="\"$(_zsh_autosuggest_strategy_ai_json_escape "$line")\""
done
# Determine prompt mode (empty vs non-empty buffer)
local system_prompt user_message temperature
if [[ -z "$buffer" ]]; then
# Empty buffer: predict next command
system_prompt="You are a shell command prediction engine. Based on the working directory, directory contents, git status, and recent history, suggest the single most likely next command the user wants to run. Reply ONLY with the complete command. No explanations, no markdown, no quotes."
temperature="0.5"
user_message="Working directory: $PWD"
[[ -n "${env_context[dir_contents]}" ]] && user_message+="\nDirectory contents: ${env_context[dir_contents]}"
[[ -n "${env_context[git_branch]}" ]] && user_message+="\nGit branch: ${env_context[git_branch]}"
[[ -n "${env_context[git_status]}" ]] && user_message+="\nGit changes: ${env_context[git_status]}"
user_message+="\nRecent history: [$context_str]"
else
# Non-empty buffer: complete partial command
system_prompt="You are a shell command auto-completion engine. Given the user's partial command, working directory, and recent history, predict the complete command. Reply ONLY with the complete command. No explanations, no markdown, no quotes."
temperature="0.3"
user_message="Working directory: $PWD"
[[ -n "${env_context[dir_contents]}" ]] && user_message+="\nDirectory contents: ${env_context[dir_contents]}"
[[ -n "${env_context[git_branch]}" ]] && user_message+="\nGit branch: ${env_context[git_branch]}"
[[ -n "${env_context[git_status]}" ]] && user_message+="\nGit changes: ${env_context[git_status]}"
user_message+="\nRecent history: [$context_str]"
user_message+="\nPartial command: $buffer"
fi
# Build JSON request body
local json_body
json_body=$(printf '{
"model": "%s",
"messages": [
{"role": "system", "content": "%s"},
{"role": "user", "content": "%s"}
],
"temperature": %s,
"max_tokens": 100
}' \
"${ZSH_AUTOSUGGEST_AI_MODEL:-gpt-3.5-turbo}" \
"$(_zsh_autosuggest_strategy_ai_json_escape "$system_prompt")" \
"$(_zsh_autosuggest_strategy_ai_json_escape "$user_message")" \
"$temperature")
# Make API request
local base_url="${ZSH_AUTOSUGGEST_AI_ENDPOINT:-https://api.openai.com/v1}"
local endpoint="${base_url}/chat/completions"
local timeout="${ZSH_AUTOSUGGEST_AI_TIMEOUT:-5}"
local response
_zsh_autosuggest_strategy_ai_debug_log "Requesting $endpoint (model=${ZSH_AUTOSUGGEST_AI_MODEL:-gpt-3.5-turbo}, input_len=${#buffer})."
response=$(curl --silent --max-time "$timeout" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ZSH_AUTOSUGGEST_AI_API_KEY" \
-d "$json_body" \
-w '\n%{http_code}' \
"$endpoint" 2>/dev/null)
# Check curl exit status
local curl_status=$?
if [[ $curl_status -ne 0 ]]; then
_zsh_autosuggest_strategy_ai_debug_log "curl failed with exit code $curl_status."
return
fi
# Split response body from HTTP status
local http_code="${response##*$'\n'}"
local body="${response%$'\n'*}"
# Early return on non-2xx status
if [[ "$http_code" != 2* ]]; then
_zsh_autosuggest_strategy_ai_debug_log "HTTP $http_code from AI endpoint."
return
fi
# Extract content from JSON response
local content
content=$(printf '%s' "$body" | jq -r '.choices[0].message.content // empty' 2>/dev/null)
# Early return if extraction failed
if [[ -z "$content" ]]; then
_zsh_autosuggest_strategy_ai_debug_log "No suggestion content in API response."
return
fi
# Normalize response
local normalized
normalized="$(_zsh_autosuggest_strategy_ai_normalize "$content" "$buffer")"
# Set suggestion
if [[ -n "$normalized" ]]; then
suggestion="$normalized"
_zsh_autosuggest_strategy_ai_debug_log "AI suggestion accepted: '$normalized'."
fi
}

View file

@ -12,8 +12,9 @@ _zsh_autosuggest_disable() {
# Enable suggestions # Enable suggestions
_zsh_autosuggest_enable() { _zsh_autosuggest_enable() {
unset _ZSH_AUTOSUGGEST_DISABLED unset _ZSH_AUTOSUGGEST_DISABLED
local min_input="${ZSH_AUTOSUGGEST_AI_MIN_INPUT:-1}"
if (( $#BUFFER )); then if (( $#BUFFER )) || (( min_input == 0 )); then
_zsh_autosuggest_fetch _zsh_autosuggest_fetch
fi fi
} }
@ -73,10 +74,13 @@ _zsh_autosuggest_modify() {
fi fi
# Get a new suggestion if the buffer is not empty after modification # Get a new suggestion if the buffer is not empty after modification
local min_input="${ZSH_AUTOSUGGEST_AI_MIN_INPUT:-1}"
if (( $#BUFFER > 0 )); then if (( $#BUFFER > 0 )); then
if [[ -z "$ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE" ]] || (( $#BUFFER <= $ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE )); then if [[ -z "$ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE" ]] || (( $#BUFFER <= $ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE )); then
_zsh_autosuggest_fetch _zsh_autosuggest_fetch
fi fi
elif (( min_input == 0 )); then
_zsh_autosuggest_fetch
fi fi
return $retval return $retval
@ -98,8 +102,9 @@ _zsh_autosuggest_suggest() {
emulate -L zsh emulate -L zsh
local suggestion="$1" local suggestion="$1"
local min_input="${ZSH_AUTOSUGGEST_AI_MIN_INPUT:-1}"
if [[ -n "$suggestion" ]] && (( $#BUFFER )); then if [[ -n "$suggestion" ]] && { (( $#BUFFER )) || (( min_input == 0 )); }; then
POSTDISPLAY="${suggestion#$BUFFER}" POSTDISPLAY="${suggestion#$BUFFER}"
else else
POSTDISPLAY= POSTDISPLAY=

258
test_ai_integration.zsh Executable file
View file

@ -0,0 +1,258 @@
#!/usr/bin/env zsh
#
# AI Strategy Integration Test
# Tests that AI functionality is working correctly without requiring real API calls
#
set -e
# Colors for output
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Test counter
TESTS_RUN=0
TESTS_PASSED=0
TESTS_FAILED=0
# Helper functions
print_test() {
echo "${YELLOW}[TEST]${NC} $1"
}
print_pass() {
echo "${GREEN}[PASS]${NC} $1"
((TESTS_PASSED++))
}
print_fail() {
echo "${RED}[FAIL]${NC} $1"
((TESTS_FAILED++))
}
# Source the plugin
source ./zsh-autosuggestions.zsh
# Mock curl for testing
curl() {
local url=""
local data=""
local expect_url="https://api.openai.com/v1/chat/completions"
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
-d)
data="$2"
shift 2
;;
http*)
url="$1"
shift
;;
*)
shift
;;
esac
done
# Verify endpoint construction
if [[ "$url" != "$expect_url" ]]; then
echo '{"error":"wrong endpoint"}'
echo '400'
return 1
fi
# Return mock response based on data
echo '{"choices":[{"message":{"content":"git status"}}]}'
echo '200'
}
# Mock jq
jq() {
if [[ "$1" == "-r" ]]; then
# Simple extraction of content field
grep -o '"content":"[^"]*"' | cut -d'"' -f4
fi
}
# Mock ls for environment context
ls() {
if [[ "$1" == "-1" ]]; then
echo "file1.txt"
echo "file2.txt"
echo "README.md"
fi
}
# Mock git for environment context
git() {
if [[ "$1" == "branch" ]]; then
echo "master"
elif [[ "$1" == "status" ]]; then
echo "M file1.txt"
echo "?? file2.txt"
fi
}
echo "=========================================="
echo "AI Strategy Integration Tests"
echo "=========================================="
echo ""
# Test 1: Endpoint construction
print_test "Endpoint construction with base URL"
((TESTS_RUN++))
export ZSH_AUTOSUGGEST_AI_API_KEY="test-key"
export ZSH_AUTOSUGGEST_AI_ENDPOINT="https://api.openai.com/v1"
result=$(_zsh_autosuggest_strategy_ai "test" 2>&1)
if [[ $? -eq 0 ]]; then
print_pass "Base URL correctly constructs full endpoint"
else
print_fail "Endpoint construction failed"
fi
# Test 2: Custom base URL
print_test "Custom base URL endpoint construction"
((TESTS_RUN++))
export ZSH_AUTOSUGGEST_AI_ENDPOINT="http://localhost:11434/v1"
# Override curl mock for this test
curl() {
local url=""
while [[ $# -gt 0 ]]; do
case "$1" in
http*)
url="$1"
shift
;;
*)
shift
;;
esac
done
if [[ "$url" == "http://localhost:11434/v1/chat/completions" ]]; then
echo '{"choices":[{"message":{"content":"ollama response"}}]}'
echo '200'
else
echo '{"error":"wrong endpoint"}'
echo '400'
fi
}
result=$(_zsh_autosuggest_strategy_ai "test" 2>&1)
if [[ $? -eq 0 ]]; then
print_pass "Custom base URL works correctly"
else
print_fail "Custom base URL failed"
fi
# Reset curl mock
curl() {
local url=""
local data=""
while [[ $# -gt 0 ]]; do
case "$1" in
-d)
data="$2"
shift 2
;;
http*)
url="$1"
shift
;;
*)
shift
;;
esac
done
echo '{"choices":[{"message":{"content":"git status"}}]}'
echo '200'
}
# Test 3: Environment context gathering
print_test "Environment context gathering"
((TESTS_RUN++))
typeset -g reply
_zsh_autosuggest_strategy_ai_gather_env_context
if [[ -n "${reply[dir_contents]}" ]]; then
print_pass "Directory contents captured: ${reply[dir_contents]}"
else
print_fail "Directory contents not captured"
fi
# Test 4: PWD-aware history gathering
print_test "PWD-aware history gathering"
((TESTS_RUN++))
# Add some history
fc -p # Push history
print -s "git status"
print -s "ls -la"
print -s "cd /tmp"
_zsh_autosuggest_strategy_ai_gather_context
if [[ ${#reply[@]} -gt 0 ]]; then
print_pass "History context gathered: ${#reply[@]} entries"
else
print_fail "History gathering failed"
fi
# Test 5: Response normalization with prompt artifacts
print_test "Prompt artifact stripping"
((TESTS_RUN++))
result=$(_zsh_autosuggest_strategy_ai_normalize "$ git status" "git")
if [[ "$result" == "git status" ]]; then
print_pass "$ prompt artifact stripped correctly"
else
print_fail "Prompt artifact stripping failed: got '$result'"
fi
# Test 6: Response normalization with > artifact
print_test "> prompt artifact stripping"
((TESTS_RUN++))
result=$(_zsh_autosuggest_strategy_ai_normalize "> ls -la" "ls")
if [[ "$result" == "ls -la" ]]; then
print_pass "> prompt artifact stripped correctly"
else
print_fail "> artifact stripping failed: got '$result'"
fi
# Test 7: Minimum input length (now default 0)
print_test "Minimum input length allows empty buffer"
((TESTS_RUN++))
export ZSH_AUTOSUGGEST_AI_MIN_INPUT=0
result=$(_zsh_autosuggest_strategy_ai "" 2>&1)
# Should not fail due to length check
if [[ $? -eq 0 ]] || [[ "$result" != *"too short"* ]]; then
print_pass "Empty buffer allowed with MIN_INPUT=0"
else
print_fail "Empty buffer rejected incorrectly"
fi
# Test 8: JSON escaping
print_test "JSON escaping for special characters"
((TESTS_RUN++))
result=$(_zsh_autosuggest_strategy_ai_json_escape 'test "quote" and \backslash')
if [[ "$result" == *'\"'* ]] && [[ "$result" == *'\\'* ]]; then
print_pass "Special characters escaped correctly"
else
print_fail "JSON escaping failed: got '$result'"
fi
# Summary
echo ""
echo "=========================================="
echo "Test Summary"
echo "=========================================="
echo "Tests run: $TESTS_RUN"
echo "Tests passed: ${GREEN}$TESTS_PASSED${NC}"
echo "Tests failed: ${RED}$TESTS_FAILED${NC}"
echo ""
if [[ $TESTS_FAILED -eq 0 ]]; then
echo "${GREEN}✓ All tests passed!${NC}"
exit 0
else
echo "${RED}✗ Some tests failed${NC}"
exit 1
fi

146
test_ai_simple.sh Executable file
View file

@ -0,0 +1,146 @@
#!/bin/bash
#
# Simple AI Strategy Test
# Validates core AI functionality
#
echo "=== AI Strategy Core Functionality Tests ==="
echo ""
# Test 1: Check source files exist
echo "[TEST 1] Source files exist"
if [[ -f "src/strategies/ai.zsh" ]] && [[ -f "src/config.zsh" ]]; then
echo "✓ PASS: Source files found"
else
echo "✗ FAIL: Source files missing"
exit 1
fi
# Test 2: Check built file includes AI strategy
echo ""
echo "[TEST 2] Built file includes AI strategy"
if grep -q "_zsh_autosuggest_strategy_ai" zsh-autosuggestions.zsh; then
echo "✓ PASS: AI strategy function present in built file"
else
echo "✗ FAIL: AI strategy missing from built file"
exit 1
fi
# Test 3: Verify base URL configuration
echo ""
echo "[TEST 3] Base URL configuration"
if grep -q "ZSH_AUTOSUGGEST_AI_ENDPOINT='https://api.openai.com/v1'" zsh-autosuggestions.zsh; then
echo "✓ PASS: Base URL configured correctly"
else
echo "✗ FAIL: Base URL configuration incorrect"
exit 1
fi
# Test 4: Verify endpoint construction code
echo ""
echo "[TEST 4] Endpoint construction logic"
if grep -q 'local base_url=.*ZSH_AUTOSUGGEST_AI_ENDPOINT' zsh-autosuggestions.zsh && \
grep -q 'local endpoint=.*chat/completions' zsh-autosuggestions.zsh; then
echo "✓ PASS: Endpoint construction code present"
else
echo "✗ FAIL: Endpoint construction logic missing"
exit 1
fi
# Test 5: Environment context function exists
echo ""
echo "[TEST 5] Environment context gathering function"
if grep -q "_zsh_autosuggest_strategy_ai_gather_env_context" zsh-autosuggestions.zsh; then
echo "✓ PASS: Environment context function present"
else
echo "✗ FAIL: Environment context function missing"
exit 1
fi
# Test 6: Prompt artifact stripping
echo ""
echo "[TEST 6] Prompt artifact stripping code"
if grep -q 'response=.*##\\$ ' zsh-autosuggestions.zsh && \
grep -q 'response=.*##> ' zsh-autosuggestions.zsh; then
echo "✓ PASS: Prompt artifact stripping present"
else
echo "✗ FAIL: Prompt artifact stripping missing"
exit 1
fi
# Test 7: Empty buffer support
echo ""
echo "[TEST 7] Empty buffer configuration"
if grep -q "ALLOW_EMPTY_BUFFER" zsh-autosuggestions.zsh; then
echo "✓ PASS: Empty buffer support present"
else
echo "✗ FAIL: Empty buffer support missing"
exit 1
fi
# Test 8: Dual prompt system
echo ""
echo "[TEST 8] Dual prompt system (predict vs complete)"
if grep -q "prediction engine" zsh-autosuggestions.zsh && \
grep -q "auto-completion engine" zsh-autosuggestions.zsh; then
echo "✓ PASS: Dual prompt system present"
else
echo "✗ FAIL: Dual prompt system missing"
exit 1
fi
# Test 9: Temperature configuration
echo ""
echo "[TEST 9] Temperature configuration"
if grep -q '"temperature": %s' zsh-autosuggestions.zsh; then
echo "✓ PASS: Dynamic temperature configuration present"
else
echo "✗ FAIL: Temperature configuration missing"
exit 1
fi
# Test 10: MIN_INPUT default is 0
echo ""
echo "[TEST 10] MIN_INPUT default value"
if grep -q 'ZSH_AUTOSUGGEST_AI_MIN_INPUT=0' zsh-autosuggestions.zsh; then
echo "✓ PASS: MIN_INPUT default is 0"
else
echo "✗ FAIL: MIN_INPUT default incorrect"
exit 1
fi
# Test 11: Documentation updated
echo ""
echo "[TEST 11] Documentation updates"
if grep -q "ALLOW_EMPTY_BUFFER" README.md && \
grep -q "Empty Buffer Suggestions" README.md; then
echo "✓ PASS: Documentation includes new features"
else
echo "✗ FAIL: Documentation missing updates"
exit 1
fi
# Test 12: RSpec tests added
echo ""
echo "[TEST 12] RSpec test coverage"
if grep -q "empty buffer" spec/strategies/ai_spec.rb && \
grep -q "prompt artifact" spec/strategies/ai_spec.rb; then
echo "✓ PASS: New test cases added"
else
echo "✗ FAIL: Test coverage incomplete"
exit 1
fi
echo ""
echo "=========================================="
echo "✓ All 12 core functionality tests passed!"
echo "=========================================="
echo ""
echo "AI Strategy is ready for use with:"
echo " - Base URL configuration"
echo " - Empty buffer suggestions"
echo " - Environment context gathering"
echo " - PWD-aware history"
echo " - Dual prompt modes"
echo " - Prompt artifact stripping"
echo ""

View file

@ -120,6 +120,35 @@ typeset -g ZSH_AUTOSUGGEST_ORIGINAL_WIDGET_PREFIX=autosuggest-orig-
(( ! ${+ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME} )) && (( ! ${+ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME} )) &&
typeset -g ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME=zsh_autosuggest_completion_pty typeset -g ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME=zsh_autosuggest_completion_pty
# AI strategy configuration
# API base URL for AI suggestions (OpenAI-compatible)
(( ! ${+ZSH_AUTOSUGGEST_AI_ENDPOINT} )) &&
typeset -g ZSH_AUTOSUGGEST_AI_ENDPOINT='https://api.openai.com/v1'
# AI model to use for suggestions
(( ! ${+ZSH_AUTOSUGGEST_AI_MODEL} )) &&
typeset -g ZSH_AUTOSUGGEST_AI_MODEL='gpt-3.5-turbo'
# Timeout in seconds for AI API requests
(( ! ${+ZSH_AUTOSUGGEST_AI_TIMEOUT} )) &&
typeset -g ZSH_AUTOSUGGEST_AI_TIMEOUT=5
# Minimum input length before querying AI
# Set to 0 to allow empty-buffer AI suggestions
(( ! ${+ZSH_AUTOSUGGEST_AI_MIN_INPUT} )) &&
typeset -g ZSH_AUTOSUGGEST_AI_MIN_INPUT=1
# Number of recent history lines to include as context
(( ! ${+ZSH_AUTOSUGGEST_AI_HISTORY_LINES} )) &&
typeset -g ZSH_AUTOSUGGEST_AI_HISTORY_LINES=5
# Prefer history entries from current directory
(( ! ${+ZSH_AUTOSUGGEST_AI_PREFER_PWD_HISTORY} )) &&
typeset -g ZSH_AUTOSUGGEST_AI_PREFER_PWD_HISTORY=no
# Enable AI debug logs to stderr (opt-in).
# Set to any value except 0/false/no/off to enable.
#--------------------------------------------------------------------# #--------------------------------------------------------------------#
# Utility Functions # # Utility Functions #
#--------------------------------------------------------------------# #--------------------------------------------------------------------#
@ -276,8 +305,9 @@ _zsh_autosuggest_disable() {
# Enable suggestions # Enable suggestions
_zsh_autosuggest_enable() { _zsh_autosuggest_enable() {
unset _ZSH_AUTOSUGGEST_DISABLED unset _ZSH_AUTOSUGGEST_DISABLED
local min_input="${ZSH_AUTOSUGGEST_AI_MIN_INPUT:-1}"
if (( $#BUFFER )); then if (( $#BUFFER )) || (( min_input == 0 )); then
_zsh_autosuggest_fetch _zsh_autosuggest_fetch
fi fi
} }
@ -337,10 +367,13 @@ _zsh_autosuggest_modify() {
fi fi
# Get a new suggestion if the buffer is not empty after modification # Get a new suggestion if the buffer is not empty after modification
local min_input="${ZSH_AUTOSUGGEST_AI_MIN_INPUT:-1}"
if (( $#BUFFER > 0 )); then if (( $#BUFFER > 0 )); then
if [[ -z "$ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE" ]] || (( $#BUFFER <= $ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE )); then if [[ -z "$ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE" ]] || (( $#BUFFER <= $ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE )); then
_zsh_autosuggest_fetch _zsh_autosuggest_fetch
fi fi
elif (( min_input == 0 )); then
_zsh_autosuggest_fetch
fi fi
return $retval return $retval
@ -362,8 +395,9 @@ _zsh_autosuggest_suggest() {
emulate -L zsh emulate -L zsh
local suggestion="$1" local suggestion="$1"
local min_input="${ZSH_AUTOSUGGEST_AI_MIN_INPUT:-1}"
if [[ -n "$suggestion" ]] && (( $#BUFFER )); then if [[ -n "$suggestion" ]] && { (( $#BUFFER )) || (( min_input == 0 )); }; then
POSTDISPLAY="${suggestion#$BUFFER}" POSTDISPLAY="${suggestion#$BUFFER}"
else else
POSTDISPLAY= POSTDISPLAY=
@ -494,6 +528,297 @@ _zsh_autosuggest_partial_accept() {
done done
} }
#--------------------------------------------------------------------#
# AI Suggestion Strategy #
#--------------------------------------------------------------------#
# Queries an OpenAI-compatible LLM API to generate command
# completions based on partial input, working directory, and
# recent shell history.
#
_zsh_autosuggest_strategy_ai_json_escape() {
# Reset options to defaults and enable LOCAL_OPTIONS
emulate -L zsh
local input="$1"
local output=""
local char
for ((i=1; i<=${#input}; i++)); do
char="${input:$((i-1)):1}"
case "$char" in
'\\') output+='\\\\' ;;
'"') output+='\"' ;;
$'\n') output+='\n' ;;
$'\t') output+='\t' ;;
$'\r') output+='\r' ;;
[[:cntrl:]]) ;; # Skip other control chars
*) output+="$char" ;;
esac
done
printf '%s' "$output"
}
_zsh_autosuggest_strategy_ai_gather_context() {
# Reset options to defaults and enable LOCAL_OPTIONS
emulate -L zsh
local max_lines="${ZSH_AUTOSUGGEST_AI_HISTORY_LINES:-5}"
local prefer_pwd="${ZSH_AUTOSUGGEST_AI_PREFER_PWD_HISTORY:-no}"
local pwd_basename="${PWD:t}"
local -a context_lines
local -a pwd_lines
local -a other_lines
local line
# Iterate from most recent history
for line in "${(@On)history}"; do
# Truncate long lines
if [[ ${#line} -gt 200 ]]; then
line="${line:0:200}..."
fi
# Categorize by PWD relevance - match full path, basename, or PWD-relevant commands
if [[ "$prefer_pwd" == "yes" ]] && [[ "$line" == *"$PWD"* || "$line" == *"$pwd_basename"* || "$line" == cd* || "$line" == ls* || "$line" == "git "* || "$line" == *"./"* || "$line" == *"../"* ]]; then
pwd_lines+=("$line")
else
other_lines+=("$line")
fi
done
# Cap PWD lines at 2/3 of max to maintain diversity
local pwd_max=$(( (max_lines * 2) / 3 ))
local pwd_count=${#pwd_lines}
[[ $pwd_count -gt $pwd_max ]] && pwd_count=$pwd_max
# Prioritize PWD-relevant lines, then fill with others
context_lines=("${(@)pwd_lines[1,$pwd_count]}" "${other_lines[@]}")
context_lines=("${(@)context_lines[1,$max_lines]}")
# Return via reply array
reply=("${context_lines[@]}")
}
_zsh_autosuggest_strategy_ai_gather_env_context() {
# Reset options to defaults and enable LOCAL_OPTIONS
emulate -L zsh
local -A env_info
# Directory listing (up to 20 entries)
local dir_contents
dir_contents=$(command ls -1 2>/dev/null | head -20 | tr '\n' ', ' | sed 's/, $//')
[[ -n "$dir_contents" ]] && env_info[dir_contents]="$dir_contents"
# Git branch (try two methods)
local git_branch
git_branch=$(command git branch --show-current 2>/dev/null)
[[ -z "$git_branch" ]] && git_branch=$(command git rev-parse --abbrev-ref HEAD 2>/dev/null)
[[ -n "$git_branch" ]] && env_info[git_branch]="$git_branch"
# Git status (up to 10 lines)
local git_status
git_status=$(command git status --porcelain 2>/dev/null | head -10 | tr '\n' '; ' | sed 's/; $//')
[[ -n "$git_status" ]] && env_info[git_status]="$git_status"
# Return via reply associative array
typeset -gA reply
reply=("${(@kv)env_info}")
}
_zsh_autosuggest_strategy_ai_normalize() {
# Reset options to defaults and enable LOCAL_OPTIONS
emulate -L zsh
local response="$1"
local buffer="$2"
local result=""
# Strip \r
response="${response//$'\r'/}"
# Strip leading prompt artifacts ($ or >)
response="${response##\$ }"
response="${response##> }"
# Strip markdown code fences
response="${response##\`\`\`*$'\n'}"
response="${response%%$'\n'\`\`\`}"
# Strip surrounding quotes
if [[ "$response" == \"*\" || "$response" == \'*\' ]]; then
response="${response:1:-1}"
fi
# Trim whitespace
response="${response##[[:space:]]##}"
response="${response%%[[:space:]]##}"
# Take first line only
result="${response%%$'\n'*}"
# If response starts with buffer, extract suffix
if [[ "$result" == "$buffer"* ]]; then
local suffix="${result#$buffer}"
result="$buffer$suffix"
# If response looks like a pure suffix, prepend buffer
elif [[ -n "$buffer" ]] && [[ "$result" != "$buffer"* ]] && [[ "${buffer}${result}" == [[:print:]]* ]]; then
result="$buffer$result"
fi
printf '%s' "$result"
}
_zsh_autosuggest_strategy_ai_debug_log() {
# Reset options to defaults and enable LOCAL_OPTIONS
emulate -L zsh
local debug="${ZSH_AUTOSUGGEST_AI_DEBUG:-0}"
case "${debug:l}" in
0|false|no|off) return ;;
esac
print -ru2 -- "[zsh-autosuggestions ai] $1"
}
_zsh_autosuggest_strategy_ai() {
# Reset options to defaults and enable LOCAL_OPTIONS
emulate -L zsh
typeset -g suggestion
local buffer="$1"
# Early return if API key not set (opt-in gate)
if [[ -z "$ZSH_AUTOSUGGEST_AI_API_KEY" ]]; then
_zsh_autosuggest_strategy_ai_debug_log "API key not set; skipping AI request."
return
fi
# Early return if curl or jq not available
if [[ -z "${commands[curl]}" ]] || [[ -z "${commands[jq]}" ]]; then
_zsh_autosuggest_strategy_ai_debug_log "Missing dependency: curl and jq are required."
return
fi
# Early return if input too short
local min_input="${ZSH_AUTOSUGGEST_AI_MIN_INPUT:-1}"
if [[ ${#buffer} -lt $min_input ]]; then
_zsh_autosuggest_strategy_ai_debug_log "Input shorter than ZSH_AUTOSUGGEST_AI_MIN_INPUT=$min_input."
return
fi
# Gather history context
local -a context
_zsh_autosuggest_strategy_ai_gather_context
context=("${reply[@]}")
# Gather environment context
local -A env_context
_zsh_autosuggest_strategy_ai_gather_env_context
env_context=("${(@kv)reply}")
# Build context string
local context_str=""
for line in "${context[@]}"; do
if [[ -n "$context_str" ]]; then
context_str+=", "
fi
context_str+="\"$(_zsh_autosuggest_strategy_ai_json_escape "$line")\""
done
# Determine prompt mode (empty vs non-empty buffer)
local system_prompt user_message temperature
if [[ -z "$buffer" ]]; then
# Empty buffer: predict next command
system_prompt="You are a shell command prediction engine. Based on the working directory, directory contents, git status, and recent history, suggest the single most likely next command the user wants to run. Reply ONLY with the complete command. No explanations, no markdown, no quotes."
temperature="0.5"
user_message="Working directory: $PWD"
[[ -n "${env_context[dir_contents]}" ]] && user_message+="\nDirectory contents: ${env_context[dir_contents]}"
[[ -n "${env_context[git_branch]}" ]] && user_message+="\nGit branch: ${env_context[git_branch]}"
[[ -n "${env_context[git_status]}" ]] && user_message+="\nGit changes: ${env_context[git_status]}"
user_message+="\nRecent history: [$context_str]"
else
# Non-empty buffer: complete partial command
system_prompt="You are a shell command auto-completion engine. Given the user's partial command, working directory, and recent history, predict the complete command. Reply ONLY with the complete command. No explanations, no markdown, no quotes."
temperature="0.3"
user_message="Working directory: $PWD"
[[ -n "${env_context[dir_contents]}" ]] && user_message+="\nDirectory contents: ${env_context[dir_contents]}"
[[ -n "${env_context[git_branch]}" ]] && user_message+="\nGit branch: ${env_context[git_branch]}"
[[ -n "${env_context[git_status]}" ]] && user_message+="\nGit changes: ${env_context[git_status]}"
user_message+="\nRecent history: [$context_str]"
user_message+="\nPartial command: $buffer"
fi
# Build JSON request body
local json_body
json_body=$(printf '{
"model": "%s",
"messages": [
{"role": "system", "content": "%s"},
{"role": "user", "content": "%s"}
],
"temperature": %s,
"max_tokens": 100
}' \
"${ZSH_AUTOSUGGEST_AI_MODEL:-gpt-3.5-turbo}" \
"$(_zsh_autosuggest_strategy_ai_json_escape "$system_prompt")" \
"$(_zsh_autosuggest_strategy_ai_json_escape "$user_message")" \
"$temperature")
# Make API request
local base_url="${ZSH_AUTOSUGGEST_AI_ENDPOINT:-https://api.openai.com/v1}"
local endpoint="${base_url}/chat/completions"
local timeout="${ZSH_AUTOSUGGEST_AI_TIMEOUT:-5}"
local response
_zsh_autosuggest_strategy_ai_debug_log "Requesting $endpoint (model=${ZSH_AUTOSUGGEST_AI_MODEL:-gpt-3.5-turbo}, input_len=${#buffer})."
response=$(curl --silent --max-time "$timeout" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ZSH_AUTOSUGGEST_AI_API_KEY" \
-d "$json_body" \
-w '\n%{http_code}' \
"$endpoint" 2>/dev/null)
# Check curl exit status
local curl_status=$?
if [[ $curl_status -ne 0 ]]; then
_zsh_autosuggest_strategy_ai_debug_log "curl failed with exit code $curl_status."
return
fi
# Split response body from HTTP status
local http_code="${response##*$'\n'}"
local body="${response%$'\n'*}"
# Early return on non-2xx status
if [[ "$http_code" != 2* ]]; then
_zsh_autosuggest_strategy_ai_debug_log "HTTP $http_code from AI endpoint."
return
fi
# Extract content from JSON response
local content
content=$(printf '%s' "$body" | jq -r '.choices[0].message.content // empty' 2>/dev/null)
# Early return if extraction failed
if [[ -z "$content" ]]; then
_zsh_autosuggest_strategy_ai_debug_log "No suggestion content in API response."
return
fi
# Normalize response
local normalized
normalized="$(_zsh_autosuggest_strategy_ai_normalize "$content" "$buffer")"
# Set suggestion
if [[ -n "$normalized" ]]; then
suggestion="$normalized"
_zsh_autosuggest_strategy_ai_debug_log "AI suggestion accepted: '$normalized'."
fi
}
#--------------------------------------------------------------------# #--------------------------------------------------------------------#
# Completion Suggestion Strategy # # Completion Suggestion Strategy #
#--------------------------------------------------------------------# #--------------------------------------------------------------------#
@ -865,3 +1190,18 @@ fi
# Start the autosuggestion widgets on the next precmd # Start the autosuggestion widgets on the next precmd
add-zsh-hook precmd _zsh_autosuggest_start add-zsh-hook precmd _zsh_autosuggest_start
_zsh_autosuggest_line_init() {
emulate -L zsh
local min_input="${ZSH_AUTOSUGGEST_AI_MIN_INPUT:-1}"
if (( min_input == 0 )) && \
(( ! ${+_ZSH_AUTOSUGGEST_DISABLED} )); then
_zsh_autosuggest_fetch
fi
}
# Use add-zle-hook-widget (zsh 5.3+) to avoid conflicts with other plugins
if (( ${+functions[add-zle-hook-widget]} )) || \
autoload -Uz add-zle-hook-widget 2>/dev/null; then
add-zle-hook-widget zle-line-init _zsh_autosuggest_line_init
fi