zsh-autosuggestions/src/strategies/ai.zsh
Frad LEE cd66c5695a refactor(ai): replace empty buffer with min input
- Replace ZSH_AUTOSUGGEST_ALLOW_EMPTY_BUFFER with AI_MIN_INPUT
- Add ZSH_AUTOSUGGEST_AI_DEBUG environment variable
- Add debug logging function to diagnose failures
- Update history lines default from 20 to 5
- Update pwd history preference default to no

Min input provides clearer semantics: set to 0 for empty-buffer
suggestions or higher to require minimum input. Debug logging helps
diagnose missing suggestions by showing API request flow.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-05 14:39:12 +08:00

291 lines
9.1 KiB
Bash

#--------------------------------------------------------------------#
# 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
}