# ZEAL: smart & fast ZSH config # ============================================================================ # Configuration (override these in .zshrc BEFORE sourcing zeal.zsh) # ============================================================================ # History : ${ZEAL_HISTSIZE:=10000} : ${ZEAL_CONTEXT_HISTORY_MAX:=100} # Max commands per directory in memory : ${ZEAL_SESSION_HISTORY_MAX:=1000} # Max commands tracked per session : ${ZEAL_MENU_MAX_RESULTS:=100} # Max matches in CTRL+R menu : ${ZEAL_MENU_DROPDOWN_SIZE:=5} # Visible items in auto-dropdown : ${ZEAL_MENU_CTRLR_SIZE:=12} # Visible items in CTRL+R menu # Frecency: score = frequency * recency_weight # Recent commands get exponentially higher weight : ${ZEAL_FRECENCY_HALFLIFE:=14} # Days until a command's recency weight halves # Prompt : ${ZEAL_SHORTEN_PATH:=true} # Fish-style shortened paths : ${ZEAL_SHOW_EXEC_TIME:=true} # Show command execution time in RPROMPT : ${ZEAL_SHOW_TIMESTAMP:=true} # Show clock in RPROMPT : ${ZEAL_GIT_AUTO_FETCH:=true} # Auto-fetch on cd into new repo # Syntax highlighting colors (ANSI color names or numbers) : ${ZEAL_COLOR_CMD_VALID:=green} # Valid command : ${ZEAL_COLOR_CMD_INVALID:=red} # Unknown command : ${ZEAL_COLOR_FLAG:=cyan} # Flags like --verbose, -v : ${ZEAL_COLOR_STRING:=yellow} # Quoted strings : ${ZEAL_COLOR_OPERATOR:=magenta} # Pipes, redirections, &&, ||, ; : ${ZEAL_COLOR_COMMENT:=8} # Comments (#) # Typo correction: when the typed command is unknown (shown as invalid), offer # the nearest matching command from history as an accept-able grey suggestion. : ${ZEAL_TYPO_CORRECTION:=true} # Enable fuzzy fix for unknown commands : ${ZEAL_TYPO_MAX_DISTANCE:=2} # Max edit distance for a typo match # ============================================================================ # History settings HISTFILE=~/.zsh_history HISTSIZE=$ZEAL_HISTSIZE SAVEHIST=$ZEAL_HISTSIZE setopt HIST_IGNORE_ALL_DUPS setopt HIST_IGNORE_SPACE # Ignore commands that start with a space. setopt HIST_REDUCE_BLANKS # Remove unnecessary blank lines. setopt HIST_FIND_NO_DUPS setopt HIST_SAVE_NO_DUPS setopt SHARE_HISTORY setopt INC_APPEND_HISTORY # Write to history file immediately # Job control settings setopt NO_NOTIFY # Don't report background job status immediately setopt NO_HUP # Don't kill background jobs on shell exit # Load hook utilities - deferred to reduce startup cost _init_hooks() { autoload -U add-zle-hook-widget autoload -U add-zsh-hook # Load substring search widgets autoload -Uz up-line-or-beginning-search down-line-or-beginning-search zle -N up-line-or-beginning-search zle -N down-line-or-beginning-search # Register all ZLE hooks add-zle-hook-widget line-init _autosuggest_show_frequent add-zle-hook-widget line-pre-redraw _autosuggest_modify add-zle-hook-widget line-finish _autosuggest_clear_on_finish add-zle-hook-widget line-pre-redraw _history_cycle_check_reset add-zle-hook-widget line-pre-redraw _menu_search_buffer_change add-zle-hook-widget line-pre-redraw _syntax_highlight # Register directory change hooks add-zsh-hook chpwd _auto_git_fetch add-zsh-hook chpwd _history_cycle_reset } # Schedule hook initialization after prompt sched +0 _init_hooks # ---------------------------------------------------------------------------- # Contextual Command History (Directory-Aware Suggestions) # ---------------------------------------------------------------------------- # Global state for contextual history typeset -gA _CONTEXTUAL_HISTORY # dir -> "cmd1\ncmd2\ncmd3" (newest first) typeset -gA _CONTEXTUAL_HISTORY_MOST_FREQUENT # dir -> most frequent command (cached) typeset -g _CONTEXTUAL_HISTORY_FILE="$HOME/.zsh_history_contextual" typeset -g _CONTEXTUAL_HISTORY_LOADED=false typeset -g _CONTEXTUAL_HISTORY_LOADING=false typeset -g _CONTEXTUAL_HISTORY_SEARCH_CACHE_DIR="" typeset -ga _CONTEXTUAL_HISTORY_SEARCH_CACHE=() # Commands that should always use global history (not contextual) typeset -ga _CONTEXTUAL_HISTORY_GLOBAL_WHITELIST _CONTEXTUAL_HISTORY_GLOBAL_WHITELIST=( clear history exit source ps top htop free df ) # Track current session commands for ARROW UP/DOWN when buffer is empty typeset -ga _SESSION_HISTORY_COMMANDS typeset -g _SESSION_HISTORY_MAX=$ZEAL_SESSION_HISTORY_MAX # State for arrow-up/down cycling through session history typeset -g _HISTORY_CYCLE_INDEX=0 # Current position in list typeset -g _HISTORY_CYCLE_BUFFER="" # What user originally typed typeset -ga _HISTORY_CYCLE_SESSION # Cached session matches (filtered by prefix) typeset -g _HISTORY_CYCLE_IN_PROGRESS=false # Are we mid-cycle? # CTRL+R visual menu search state typeset -g _MENU_SEARCH_ACTIVE=false # Are we in menu search mode? typeset -g _MENU_EXPLICIT_MODE=false # Was menu started via CTRL+R (vs auto-dropdown)? typeset -g _MENU_SEARCH_QUERY="" # Current search query typeset -ga _MENU_MATCHES_CONTEXTUAL # Array of contextual matches typeset -ga _MENU_MATCHES_GLOBAL # Array of global matches typeset -g _MENU_SELECTED_INDEX=0 # Currently selected item (1-based, 0 = none selected) typeset -g _MENU_DROPDOWN_ENTERED=false # Has user explicitly entered dropdown (via arrow down)? typeset -g _MENU_DISPLAY_OFFSET=0 # Scroll offset for long lists typeset -g _MENU_ORIGINAL_BUFFER="" # Buffer before search started typeset -g _MENU_ORIGINAL_KEYMAP="" # Keymap before search started typeset -g _MENU_MAX_DISPLAY=$ZEAL_MENU_DROPDOWN_SIZE typeset -g _TAB_COMPLETION_ACTIVE=false # Flag to suppress dropdown during TAB completion # Signal handler for async load completion (using USR2, USR1 is used by git fetch) TRAPUSR2() { local cache_file="/tmp/zsh_ctx_hist_$$" # Source the cached data file if [[ -f "$cache_file" ]]; then local cache_size=$(wc -c < "$cache_file" 2>/dev/null) local cache_lines=$(wc -l < "$cache_file" 2>/dev/null) source "$cache_file" 2>/dev/null command rm -f "$cache_file" # Prevent alias interference _CONTEXTUAL_HISTORY_LOADED=true _CONTEXTUAL_HISTORY_LOADING=false # Show frequent command and refresh prompt if zle; then zle autosuggest-show-frequent 2>/dev/null zle reset-prompt 2>/dev/null fi fi } # Async load contextual history using background process + signal _load_contextual_history_async() { # Don't reload if already loaded or loading [[ "$_CONTEXTUAL_HISTORY_LOADED" == "true" ]] && return [[ "$_CONTEXTUAL_HISTORY_LOADING" == "true" ]] && return # Create file if doesn't exist [[ ! -f "$_CONTEXTUAL_HISTORY_FILE" ]] && touch "$_CONTEXTUAL_HISTORY_FILE" _CONTEXTUAL_HISTORY_LOADING=true local parent_pid=$$ # Background job to parse and generate cache file { typeset -A temp_history local line dir cmd ts rest # Read last 10000 lines (most recent commands) # Supports both old format (dir|||cmd) and new format (dir|||timestamp|||cmd) while IFS= read -r line; do [[ -z "$line" ]] && continue # Split on first occurrence of ||| dir="${line%%|||*}" rest="${line#*|||}" [[ -z "$rest" || -z "$dir" || "$rest" == "$line" ]] && continue # Check if rest contains another ||| (new format with timestamp) if [[ "$rest" == *"|||"* ]]; then ts="${rest%%|||*}" cmd="${rest#*|||}" else # Old format: no timestamp, use 0 ts="0" cmd="$rest" fi [[ -z "$cmd" ]] && continue # Store as "timestamp|||cmd" per entry (newest first) local entry="${ts}|||${cmd}" if [[ -n "${temp_history[$dir]}" ]]; then temp_history[$dir]="${entry}"$'\n'"${temp_history[$dir]}" else temp_history[$dir]="${entry}" fi done < <(tail -10000 "$_CONTEXTUAL_HISTORY_FILE" 2>/dev/null) # Limit each directory to 100 newest entries (list is already newest-first) for dir in "${(@k)temp_history}"; do local cmd_count=$(echo "${temp_history[$dir]}" | wc -l) if (( cmd_count > ZEAL_CONTEXT_HISTORY_MAX )); then temp_history[$dir]=$(echo "${temp_history[$dir]}" | head -$ZEAL_CONTEXT_HISTORY_MAX) fi done # Write to cache file that parent will source local cache_file="/tmp/zsh_ctx_hist_${parent_pid}" { # Generate shell code to populate the array for dir in "${(@k)temp_history}"; do # Properly escape the values printf '_CONTEXTUAL_HISTORY[%q]=%q\n' "$dir" "${temp_history[$dir]}" done } > "$cache_file" # Signal parent that data is ready (using USR2, not USR1 which is used by git fetch) kill -USR2 $parent_pid 2>/dev/null } &! } # Track last command for post-execution validation typeset -g _LAST_COMMAND="" typeset -g _LAST_COMMAND_PWD="" typeset -g _PENDING_HISTORY_ENTRY="" # Hook to capture commands with directory context zshaddhistory() { local command="${1%%$'\n'}" # Skip if empty or starts with space (HIST_IGNORE_SPACE) [[ -z "$command" || "$command" == " "* ]] && return 0 # Track command in session history (for ARROW UP when buffer is empty) _SESSION_HISTORY_COMMANDS+=("$command") # Keep only last N commands to prevent memory bloat if (( ${#_SESSION_HISTORY_COMMANDS[@]} > _SESSION_HISTORY_MAX )); then _SESSION_HISTORY_COMMANDS=(${_SESSION_HISTORY_COMMANDS[@]:(-$_SESSION_HISTORY_MAX)}) fi # Store command for post-execution processing (will check exit code in precmd) _LAST_COMMAND="$command" _LAST_COMMAND_PWD="$PWD" _PENDING_HISTORY_ENTRY="$1" # Reject from history for now - we'll add it back in precmd if it succeeds return 1 } # Store command in contextual history (called after execution with known exit code) _store_contextual_history() { local command="$1" local cmd_pwd="$2" # Check if command should be stored in contextual history local first_word="${command%% *}" local store_contextual=true # Skip explicit whitelist commands if (( ${_CONTEXTUAL_HISTORY_GLOBAL_WHITELIST[(I)$first_word]} )); then store_contextual=false fi # Skip commands with total length < 4 chars (cd, ls, rm, cp, mv, pwd, etc.) if (( ${#command} < 4 )); then store_contextual=false fi # Only store in contextual history if not whitelisted if [[ "$store_contextual" == "true" ]]; then # Append to contextual history file with timestamp local now=$EPOCHSECONDS echo "$cmd_pwd|||${now}|||$command" >> "$_CONTEXTUAL_HISTORY_FILE" # Update in-memory index immediately (if loaded) if [[ "$_CONTEXTUAL_HISTORY_LOADED" == "true" ]]; then # Prepend to directory's command list (newest first), format: "timestamp|||cmd" local entry="${now}|||${command}" if [[ -n "${_CONTEXTUAL_HISTORY[$cmd_pwd]}" ]]; then _CONTEXTUAL_HISTORY[$cmd_pwd]="${entry}"$'\n'"${_CONTEXTUAL_HISTORY[$cmd_pwd]}" else _CONTEXTUAL_HISTORY[$cmd_pwd]="${entry}" fi local cmd_count=$(echo "${_CONTEXTUAL_HISTORY[$cmd_pwd]}" | wc -l) if (( cmd_count > ZEAL_CONTEXT_HISTORY_MAX )); then _CONTEXTUAL_HISTORY[$cmd_pwd]=$(echo "${_CONTEXTUAL_HISTORY[$cmd_pwd]}" | head -$ZEAL_CONTEXT_HISTORY_MAX) fi if [[ "$_CONTEXTUAL_HISTORY_SEARCH_CACHE_DIR" == "$cmd_pwd" ]]; then _CONTEXTUAL_HISTORY_SEARCH_CACHE_DIR="" _CONTEXTUAL_HISTORY_SEARCH_CACHE=() fi # Recalculate most frequent command for this directory _recalc_most_frequent_for_dir "$cmd_pwd" fi # Periodic cleanup of file (every ~1000 commands) if (( RANDOM % 1000 == 0 )); then ( tail -10000 "$_CONTEXTUAL_HISTORY_FILE" > "${_CONTEXTUAL_HISTORY_FILE}.tmp" 2>/dev/null && \ mv "${_CONTEXTUAL_HISTORY_FILE}.tmp" "$_CONTEXTUAL_HISTORY_FILE" 2>/dev/null ) &! fi fi } # Fast contextual search for auto-suggestions (prefix match) _ensure_contextual_history_search_cache() { unsetopt xtrace 2>/dev/null [[ "$_CONTEXTUAL_HISTORY_LOADED" == "true" ]] || return 1 [[ "$_CONTEXTUAL_HISTORY_SEARCH_CACHE_DIR" == "$PWD" && ${#_CONTEXTUAL_HISTORY_SEARCH_CACHE[@]} -gt 0 ]] && return 0 local cmd_list="${_CONTEXTUAL_HISTORY[$PWD]}" [[ -n "$cmd_list" ]] || return 1 _CONTEXTUAL_HISTORY_SEARCH_CACHE_DIR="$PWD" _CONTEXTUAL_HISTORY_SEARCH_CACHE=("${(@f)cmd_list}") } _search_contextual_history() { unsetopt xtrace 2>/dev/null local buffer="$1" # If not loaded yet, return nothing (will fall back to global) _ensure_contextual_history_search_cache || return 1 # Check if command is in global whitelist local first_word="${buffer%% *}" if (( ${_CONTEXTUAL_HISTORY_GLOBAL_WHITELIST[(I)$first_word]} )); then # Command is whitelisted - skip contextual lookup return 1 fi local entry cmd for entry in "${_CONTEXTUAL_HISTORY_SEARCH_CACHE[@]}"; do [[ -z "$entry" ]] && continue # Inline extraction: "ts|||cmd" -> cmd, or plain cmd for old format [[ "$entry" == *"|||"* ]] && cmd="${entry#*|||}" || cmd="$entry" if [[ "$cmd" == "$buffer"* && "$cmd" != "$buffer" ]]; then echo "$cmd" return 0 fi done return 1 } # Contextual search for auto-suggestions using substring match (fallback) _search_contextual_history_substring() { unsetopt xtrace 2>/dev/null local buffer="$1" _ensure_contextual_history_search_cache || return 1 local first_word="${buffer%% *}" if (( ${_CONTEXTUAL_HISTORY_GLOBAL_WHITELIST[(I)$first_word]} )); then return 1 fi local entry cmd for entry in "${_CONTEXTUAL_HISTORY_SEARCH_CACHE[@]}"; do [[ -z "$entry" ]] && continue [[ "$entry" == *"|||"* ]] && cmd="${entry#*|||}" || cmd="$entry" if [[ "$cmd" == *"$buffer"* && "$cmd" != "$buffer"* && "$cmd" != "$buffer" ]]; then echo "$cmd" return 0 fi done return 1 } # Search global history file for prefix match (for auto-suggestion fallback) typeset -ga _GLOBAL_HISTORY_SEARCH_CACHE=() typeset -g _GLOBAL_HISTORY_SEARCH_CACHE_FILE="" _ensure_global_history_search_cache() { unsetopt xtrace 2>/dev/null local history_file="${HISTFILE:-$HOME/.zsh_history}" [[ -f "$history_file" ]] || return 1 [[ "$_GLOBAL_HISTORY_SEARCH_CACHE_FILE" == "$history_file" && ${#_GLOBAL_HISTORY_SEARCH_CACHE[@]} -gt 0 ]] && return 0 _GLOBAL_HISTORY_SEARCH_CACHE_FILE="$history_file" _GLOBAL_HISTORY_SEARCH_CACHE=() local line cmd while IFS= read -r line; do # ZSH history format: ": timestamp:elapsed;command" or plain command if [[ "$line" == ":"*";"* ]]; then cmd="${line#*;}" else cmd="$line" fi [[ -n "$cmd" ]] && _GLOBAL_HISTORY_SEARCH_CACHE+=("$cmd") done < <(tail -2000 "$history_file" 2>/dev/null | awk '{lines[NR]=$0} END{for(i=NR;i>=1;i--)print lines[i]}') } _search_global_history() { unsetopt xtrace 2>/dev/null local buffer="$1" _ensure_global_history_search_cache || return 1 local -a matches local cmd matches=("${(@M)_GLOBAL_HISTORY_SEARCH_CACHE:#${buffer}*}") for cmd in "${matches[@]}"; do [[ "$cmd" != "$buffer" ]] && { echo "$cmd"; return 0; } done return 1 } # Search global history file for substring match (for auto-suggestion fallback) _search_global_history_substring() { unsetopt xtrace 2>/dev/null local buffer="$1" _ensure_global_history_search_cache || return 1 local -a matches matches=("${(@M)_GLOBAL_HISTORY_SEARCH_CACHE:#*${buffer}*}") for cmd in "${matches[@]}"; do # Check substring match (but not prefix - we already tried those) if [[ "$cmd" == *"$buffer"* && "$cmd" != "$buffer"* && "$cmd" != "$buffer" ]]; then echo "$cmd" return 0 fi done return 1 } typeset -g _AUTOSUGGEST_SEARCH_RESULT="" _autosuggest_find_suggestion() { unsetopt xtrace 2>/dev/null local buffer="$1" _AUTOSUGGEST_SEARCH_RESULT="" local first_word="${buffer%% *}" if (( ! ${_CONTEXTUAL_HISTORY_GLOBAL_WHITELIST[(I)$first_word]} )) && _ensure_contextual_history_search_cache; then local entry cmd for entry in "${_CONTEXTUAL_HISTORY_SEARCH_CACHE[@]}"; do [[ -z "$entry" ]] && continue [[ "$entry" == *"|||"* ]] && cmd="${entry#*|||}" || cmd="$entry" if [[ "$cmd" == "$buffer"* && "$cmd" != "$buffer" ]]; then _AUTOSUGGEST_SEARCH_RESULT="$cmd" return 0 fi done for entry in "${_CONTEXTUAL_HISTORY_SEARCH_CACHE[@]}"; do [[ -z "$entry" ]] && continue [[ "$entry" == *"|||"* ]] && cmd="${entry#*|||}" || cmd="$entry" if [[ "$cmd" == *"$buffer"* && "$cmd" != "$buffer"* && "$cmd" != "$buffer" ]]; then _AUTOSUGGEST_SEARCH_RESULT="$cmd" return 0 fi done fi _ensure_global_history_search_cache || return 1 local -a matches matches=("${(@M)_GLOBAL_HISTORY_SEARCH_CACHE:#${buffer}*}") for cmd in "${matches[@]}"; do if [[ "$cmd" != "$buffer" ]]; then _AUTOSUGGEST_SEARCH_RESULT="$cmd" return 0 fi done matches=("${(@M)_GLOBAL_HISTORY_SEARCH_CACHE:#*${buffer}*}") for cmd in "${matches[@]}"; do if [[ "$cmd" == *"$buffer"* && "$cmd" != "$buffer"* && "$cmd" != "$buffer" ]]; then _AUTOSUGGEST_SEARCH_RESULT="$cmd" return 0 fi done # Last resort: the command word itself is a typo (unknown command). Offer the # closest historical command by edit distance. _autosuggest_find_fuzzy_correction "$buffer" && return 0 return 1 } # ---------------------------------------------------------------------------- # Typo Correction (nearest fuzzy command match from history) # ---------------------------------------------------------------------------- # Optimal string alignment (restricted Damerau-Levenshtein) distance between two # short strings. Unlike plain Levenshtein it counts an adjacent transposition # (the most common typo, e.g. "gti"->"git") as a single edit. The result is # written to _ZEAL_EDIT_DIST instead of echoed to avoid a subshell per candidate. typeset -gi _ZEAL_EDIT_DIST=0 _zeal_edit_distance() { local a="$1" b="$2" local -i la=${#a} lb=${#b} (( la == 0 )) && { _ZEAL_EDIT_DIST=$lb; return; } (( lb == 0 )) && { _ZEAL_EDIT_DIST=$la; return; } # Rows are 1-offset: row[k+1] holds the distance for the length-k prefix. local -a prev2 prev cur local -i i j cost del ins sub m for (( j = 0; j <= lb; j++ )); do prev[j+1]=$j; done for (( i = 1; i <= la; i++ )); do cur=(); cur[1]=$((i)) local ca="${a[i]}" for (( j = 1; j <= lb; j++ )); do [[ "$ca" == "${b[j]}" ]] && cost=0 || cost=1 del=$(( prev[j+1] + 1 )) ins=$(( cur[j] + 1 )) sub=$(( prev[j] + cost )) m=$del (( ins < m )) && m=$ins (( sub < m )) && m=$sub # Transposition of adjacent characters counts as one edit if (( i > 1 && j > 1 )) && [[ "$ca" == "${b[j-1]}" && "${a[i-1]}" == "${b[j]}" ]]; then (( prev2[j-1] + 1 < m )) && m=$(( prev2[j-1] + 1 )) fi cur[j+1]=$m done prev2=("${prev[@]}") prev=("${cur[@]}") done _ZEAL_EDIT_DIST=${prev[lb+1]} } # Whether a word is a known command, reusing the syntax highlighter's cache so # the invalid-command (red) verdict and the typo trigger stay in sync. _zeal_is_valid_command() { local w="$1" if [[ -z "${_SYNTAX_CMD_CACHE[$w]+x}" ]]; then whence "$w" &>/dev/null && _SYNTAX_CMD_CACHE[$w]=0 || _SYNTAX_CMD_CACHE[$w]=1 fi # String compare: arithmetic subscripts break on keys containing ] ) \ etc. [[ "${_SYNTAX_CMD_CACHE[$w]}" == 0 ]] } # Scan a history cache array (passed by name) for the command whose first word # is the closest edit-distance match to a typo. Writes the winner to # _ZEAL_FUZZY_BEST; returns success when a match within max distance is found. typeset -g _ZEAL_FUZZY_BEST="" _zeal_fuzzy_scan() { local arrname="$1" typo="$2" rest="$3" local -i max_dist=$4 tlen=${#2} local -A seen_word local entry cmd cand_word cand_rest best_cmd="" local -i best_dist=99 clen for entry in "${(@P)arrname}"; do [[ -z "$entry" ]] && continue [[ "$entry" == *"|||"* ]] && cmd="${entry#*|||}" || cmd="$entry" cand_word="${cmd%% *}" [[ "$cand_word" == "$typo" ]] && continue # Cheap length prune before the O(n*m) distance computation clen=${#cand_word} (( clen < tlen - max_dist || clen > tlen + max_dist )) && continue if [[ -n "$rest" ]]; then # User already typed arguments; the candidate must continue with them [[ "$cmd" == *" "* ]] || continue cand_rest="${cmd#* }" [[ "$cand_rest" == "$rest"* ]] || continue else # One correction per distinct command word (cache is newest-first, so the # first occurrence is the most recent full command for that word) [[ -n "${seen_word[$cand_word]}" ]] && continue seen_word[$cand_word]=1 fi _zeal_edit_distance "$typo" "$cand_word" if (( _ZEAL_EDIT_DIST <= max_dist && _ZEAL_EDIT_DIST < best_dist )); then best_dist=$_ZEAL_EDIT_DIST best_cmd="$cmd" (( best_dist == 1 )) && break # closest possible; take the most recent fi done _ZEAL_FUZZY_BEST="$best_cmd" [[ -n "$best_cmd" ]] } # Suggest the nearest historical command when the typed command word is unknown. # Prefers directory-local history over global. Sets _AUTOSUGGEST_SEARCH_RESULT. _autosuggest_find_fuzzy_correction() { unsetopt xtrace 2>/dev/null [[ "$ZEAL_TYPO_CORRECTION" == "true" ]] || return 1 local buffer="$1" local typo="${buffer%% *}" # Need a complete, non-trivial command word that is actually unknown (red) (( ${#typo} >= 3 )) || return 1 _zeal_is_valid_command "$typo" && return 1 # Scale tolerance with word length so short words don't over-match local -i max_dist=$ZEAL_TYPO_MAX_DISTANCE (( ${#typo} <= 4 && max_dist > 1 )) && max_dist=1 # Preserve anything the user typed after the command word as a required prefix local rest="" [[ "$buffer" == *" "* ]] && rest="${buffer#* }" _ensure_contextual_history_search_cache if _zeal_fuzzy_scan _CONTEXTUAL_HISTORY_SEARCH_CACHE "$typo" "$rest" $max_dist; then _AUTOSUGGEST_SEARCH_RESULT="$_ZEAL_FUZZY_BEST" return 0 fi _ensure_global_history_search_cache if _zeal_fuzzy_scan _GLOBAL_HISTORY_SEARCH_CACHE "$typo" "$rest" $max_dist; then _AUTOSUGGEST_SEARCH_RESULT="$_ZEAL_FUZZY_BEST" return 0 fi return 1 } # Ensure cache is populated for current directory (call BEFORE using cache in subshell) _ensure_most_frequent_cached() { [[ "$_CONTEXTUAL_HISTORY_LOADED" != "true" ]] && return 1 [[ -n "${_CONTEXTUAL_HISTORY_MOST_FREQUENT[$PWD]}" ]] && return 0 _recalc_most_frequent_for_dir "$PWD" } # Get most frequent command (MUST call _ensure_most_frequent_cached first in parent shell!) _get_most_frequent_contextual_command() { [[ "$_CONTEXTUAL_HISTORY_LOADED" != "true" ]] && return 1 local cached="${_CONTEXTUAL_HISTORY_MOST_FREQUENT[$PWD]}" [[ -n "$cached" ]] && echo "$cached" && return 0 return 1 } # Recalculate best command for a directory using frecency scoring # Frecency = sum of recency weights per occurrence # Weight decays with age: halflife / (halflife + age) _recalc_most_frequent_for_dir() { local dir="$1" local cmd_list="${_CONTEXTUAL_HISTORY[$dir]}" [[ -z "$cmd_list" ]] && return 1 local -a entries=("${(@f)cmd_list}") local now=$EPOCHSECONDS local -i halflife_secs=$((ZEAL_FRECENCY_HALFLIFE * 86400)) local -A scores local entry cmd ts age_secs weight best_cmd="" best_score=0 for entry in "${entries[@]:0:$ZEAL_CONTEXT_HISTORY_MAX}"; do [[ -z "$entry" ]] && continue if [[ "$entry" == *"|||"* ]]; then ts="${entry%%|||*}" cmd="${entry#*|||}" else ts=0; cmd="$entry" fi [[ -z "$cmd" ]] && continue if (( ts > 0 )); then age_secs=$(( now - ts )) (( age_secs < 0 )) && age_secs=0 weight=$(( (halflife_secs * 1000) / (halflife_secs + age_secs) )) else weight=100 fi scores[(e)$cmd]=$(( ${scores[(e)$cmd]:-0} + weight )) if (( ${scores[(e)$cmd]} > best_score )); then best_score=${scores[(e)$cmd]} best_cmd="$cmd" fi done [[ -n "$best_cmd" ]] && _CONTEXTUAL_HISTORY_MOST_FREQUENT[$dir]="$best_cmd" } # Get all contextual matches for cycling (used by arrow-up) _get_all_contextual_matches() { local buffer="$1" local -a matches # If not loaded yet, return empty [[ "$_CONTEXTUAL_HISTORY_LOADED" != "true" ]] && return 1 # Check if command is in global whitelist if [[ -n "$buffer" ]]; then local first_word="${buffer%% *}" if (( ${_CONTEXTUAL_HISTORY_GLOBAL_WHITELIST[(I)$first_word]} )); then # Command is whitelisted - skip contextual lookup return 1 fi fi # Get command list for current directory local cmd_list="${_CONTEXTUAL_HISTORY[$PWD]}" [[ -z "$cmd_list" ]] && return 1 local entry cmd while IFS= read -r entry; do [[ -z "$entry" ]] && continue [[ "$entry" == *"|||"* ]] && cmd="${entry#*|||}" || cmd="$entry" if [[ -z "$buffer" || "$cmd" == "$buffer"* ]]; then matches+=("$cmd") fi done <<< "$cmd_list" # Return matches (one per line) if (( ${#matches[@]} > 0 )); then printf '%s\n' "${matches[@]}" return 0 fi return 1 } # Get contextual matches using substring search (for CTRL+R menu) _menu_collect_contextual_substring_matches() { unsetopt xtrace 2>/dev/null local query="$1" local -A seen _MENU_MATCHES_CONTEXTUAL=() _ensure_contextual_history_search_cache || return 1 local entry cmd count=0 for entry in "${_CONTEXTUAL_HISTORY_SEARCH_CACHE[@]}"; do [[ -z "$entry" ]] && continue [[ "$entry" == *"|||"* ]] && cmd="${entry#*|||}" || cmd="$entry" [[ -n "${seen[$cmd]}" ]] && continue if [[ -z "$query" || "$cmd" == *"$query"* ]]; then _MENU_MATCHES_CONTEXTUAL+=("$cmd") seen[$cmd]=1 (( ++count >= ZEAL_MENU_MAX_RESULTS )) && break fi done (( ${#_MENU_MATCHES_CONTEXTUAL[@]} > 0 )) } _menu_get_contextual_substring_matches() { local query="$1" local -a matches local -A seen # If not loaded yet, return empty [[ "$_CONTEXTUAL_HISTORY_LOADED" != "true" ]] && return 1 # Get command list for current directory local cmd_list="${_CONTEXTUAL_HISTORY[$PWD]}" [[ -z "$cmd_list" ]] && return 1 local entry cmd count=0 while IFS= read -r entry; do [[ -z "$entry" ]] && continue [[ "$entry" == *"|||"* ]] && cmd="${entry#*|||}" || cmd="$entry" [[ -n "${seen[$cmd]}" ]] && continue if [[ -z "$query" || "$cmd" == *"$query"* ]]; then matches+=("$cmd") seen[$cmd]=1 (( ++count >= ZEAL_MENU_MAX_RESULTS )) && break fi done <<< "$cmd_list" # Return matches (one per line) - already newest first, deduplicated if (( ${#matches[@]} > 0 )); then printf '%s\n' "${matches[@]}" return 0 fi return 1 } # Get global matches using substring search (for CTRL+R menu) _menu_get_global_substring_matches() { local query="$1" local -a matches local -A seen local count=0 local history_file="${HISTFILE:-$HOME/.zsh_history}" [[ ! -f "$history_file" ]] && return 1 # Read history file in reverse (newest first), extract command part local line cmd while IFS= read -r line; do # ZSH history format: ": timestamp:elapsed;command" # Extract just the command part after the semicolon if [[ "$line" == ":"*";"* ]]; then cmd="${line#*;}" else # Simple format without timestamp cmd="$line" fi [[ -z "$cmd" ]] && continue # Skip if already seen (deduplication) [[ -n "${seen[$cmd]}" ]] && continue # If query is empty, match all; otherwise check substring if [[ -z "$query" || "$cmd" == *"$query"* ]]; then matches+=("$cmd") seen[$cmd]=1 count=$((count + 1)) [[ $count -ge $ZEAL_MENU_MAX_RESULTS ]] && break fi done < <(tail -2000 "$history_file" 2>/dev/null | awk '{lines[NR]=$0} END{for(i=NR;i>=1;i--)print lines[i]}') # Output matches (one per line) if (( ${#matches[@]} > 0 )); then printf '%s\n' "${matches[@]}" return 0 fi return 1 } # Enable completion system (lazy-loaded) # Global state for completion system typeset -g _COMPLETION_LOADED=false typeset -g _ZCOMPDUMP="${ZDOTDIR:-$HOME}/.zcompdump" # Function to load completion system synchronously _load_compinit_now() { [[ "$_COMPLETION_LOADED" == "true" ]] && return # Completion options (configure before compinit) zstyle ':completion:*' menu select # Smart matching: case insensitive, then prefix-or-substring (tries prefix first, falls back to substring) zstyle ':completion:*' matcher-list 'm:{a-zA-Z}={A-Za-z}' 'r:|[._-]=* r:|=*' 'l:|=* r:|=*' setopt COMPLETE_IN_WORD setopt AUTO_MENU autoload -Uz compinit # Run compinit only once a day for performance # -C flag skips security check (much faster), only do full check once per day if [[ -f "$_ZCOMPDUMP" && -n "$_ZCOMPDUMP"(#qNmh+24) ]]; then # File older than 24 hours - do full compinit with security check compinit -d "$_ZCOMPDUMP" else # File is recent - skip expensive security check compinit -C -d "$_ZCOMPDUMP" fi _COMPLETION_LOADED=true } # Defer loading: intercept first Tab press _completion_on_demand() { # If not loaded yet, load synchronously (fast enough for UX - will be pre-warmed) if [[ "$_COMPLETION_LOADED" != "true" ]]; then _load_compinit_now fi # Clear any existing autosuggestion before TAB completion POSTDISPLAY="" region_highlight=("${(@)region_highlight:#*memo=autosuggest*}") region_highlight=("${(@)region_highlight:#*autosuggest*}") region_highlight=("${(@)region_highlight:#*fg=240,bold*}") # Set flag to suppress dropdown during TAB completion _TAB_COMPLETION_ACTIVE=true # Run the actual completion # Note: flag is reset in _autosuggest_modify when user types new content zle expand-or-complete } # Create ZLE widget for on-demand completion zle -N _completion_on_demand # Override Tab to use our on-demand loader bindkey '^I' _completion_on_demand # Tab key # Start loading immediately after prompt (will likely finish before first Tab press) sched +0 _load_compinit_now # ---------------------------------------------------------------------------- # History-based Auto-complete # ---------------------------------------------------------------------------- # Fish-style autosuggestions (lightweight native implementation) typeset -g _AUTOSUGGEST_SUGGESTION="" # Show most frequent command on fresh prompt (line-init hook) _autosuggest_show_frequent() { # Only show if buffer is empty (fresh prompt) [[ -n "$BUFFER" ]] && return # Clear any previous autosuggest highlighting region_highlight=("${(@)region_highlight:#*memo=autosuggest*}") region_highlight=("${(@)region_highlight:#*autosuggest*}") region_highlight=("${(@)region_highlight:#*fg=240,bold*}") POSTDISPLAY="" _AUTOSUGGEST_SUGGESTION="" _ensure_most_frequent_cached local freq_cmd freq_cmd=$(_get_most_frequent_contextual_command) if [[ -n "$freq_cmd" ]]; then _AUTOSUGGEST_SUGGESTION="$freq_cmd" POSTDISPLAY="$freq_cmd" # Highlight POSTDISPLAY in grey (starts at CURSOR position which is 0 for empty buffer) region_highlight+=("0 ${#freq_cmd} fg=240,bold autosuggest") fi } _autosuggest_modify() { emulate -L zsh unsetopt xtrace 2>/dev/null # Check if history cycling should be reset (user typed something) if [[ "$_HISTORY_CYCLE_IN_PROGRESS" == "true" ]]; then local _cycle_current="" if (( _HISTORY_CYCLE_INDEX > 0 )); then _cycle_current="${_HISTORY_CYCLE_SESSION[$_HISTORY_CYCLE_INDEX]}" fi # Reset if buffer doesn't match current cycling position if [[ "$BUFFER" != "$_cycle_current" ]]; then _history_cycle_reset fi fi # During history cycling, suppress autosuggestions to avoid visual confusion if [[ "$_HISTORY_CYCLE_IN_PROGRESS" == "true" ]]; then _AUTOSUGGEST_SUGGESTION="" region_highlight=("${(@)region_highlight:#*memo=autosuggest*}") region_highlight=("${(@)region_highlight:#*autosuggest*}") region_highlight=("${(@)region_highlight:#*fg=240,bold*}") POSTDISPLAY="" if [[ "$_MENU_EXPLICIT_MODE" != "true" ]]; then zle -M "" fi return fi # During TAB completion, don't modify display at all to avoid interference if [[ "$_TAB_COMPLETION_ACTIVE" == "true" ]]; then # Check if we should exit TAB completion mode local from_completion=false if [[ "$LASTWIDGET" == "_completion_on_demand" || \ "$LASTWIDGET" == "expand-or-complete" || \ "$LASTWIDGET" == "complete-word" || \ "$LASTWIDGET" == "menu-complete" || \ "$LASTWIDGET" == "reverse-menu-complete" || \ "$LASTWIDGET" == ".expand-or-complete" || \ "$LASTWIDGET" == ".complete-word" ]]; then from_completion=true fi # Exit TAB mode only when user types a non-space character if [[ "$from_completion" != "true" && "${BUFFER: -1}" != " " ]]; then _TAB_COMPLETION_ACTIVE=false # Don't process suggestions in the same cycle - let next redraw handle it return else # Still in TAB completion mode - don't touch the display return fi fi # Clear previous suggestion _AUTOSUGGEST_SUGGESTION="" region_highlight=("${(@)region_highlight:#*memo=autosuggest*}") region_highlight=("${(@)region_highlight:#*autosuggest*}") region_highlight=("${(@)region_highlight:#*fg=240,bold*}") POSTDISPLAY="" # Only suggest if buffer is not empty and cursor is at end of buffer if [[ -n "$BUFFER" && $CURSOR -eq ${#BUFFER} ]]; then local suggestion="" local displayed_suggestion="" _autosuggest_find_suggestion "$BUFFER" && suggestion="$_AUTOSUGGEST_SEARCH_RESULT" if [[ -n "$suggestion" ]]; then if [[ "$suggestion" != "$BUFFER" && "$suggestion" == "$BUFFER"* ]]; then displayed_suggestion="$suggestion" # Extract the completion part _AUTOSUGGEST_SUGGESTION="${suggestion#$BUFFER}" # Add grey suggestion to buffer display POSTDISPLAY="$_AUTOSUGGEST_SUGGESTION" # Highlight it in grey region_highlight+=("$CURSOR $(( CURSOR + ${#_AUTOSUGGEST_SUGGESTION} )) fg=240,bold autosuggest") # For substring matches (and fuzzy typo corrections), show the full # command. A typo correction differs from BUFFER at the command word, so # it is neither a prefix nor a substring — this branch catches both. elif [[ "$suggestion" != "$BUFFER" ]]; then displayed_suggestion="$suggestion" # Show full command as suggestion _AUTOSUGGEST_SUGGESTION="$suggestion" # Add grey suggestion to buffer display POSTDISPLAY=" → $_AUTOSUGGEST_SUGGESTION" # Highlight it in grey (different style to indicate substring match) region_highlight+=("$CURSOR $(( CURSOR + ${#POSTDISPLAY} )) fg=240,bold autosuggest") fi fi # Show dropdown menu with matches (always visible while typing) _autosuggest_show_dropdown "$displayed_suggestion" elif [[ -z "$BUFFER" ]]; then # Buffer is empty - show most frequent contextual command as suggestion if [[ "$_MENU_EXPLICIT_MODE" != "true" ]]; then _MENU_SEARCH_ACTIVE=false _MENU_SEARCH_QUERY="" _MENU_MATCHES_CONTEXTUAL=() _MENU_MATCHES_GLOBAL=() _MENU_SELECTED_INDEX=0 _MENU_DROPDOWN_ENTERED=false zle -M "" # Show most frequent command as grey suggestion _ensure_most_frequent_cached local freq_cmd freq_cmd=$(_get_most_frequent_contextual_command) if [[ -n "$freq_cmd" ]]; then _AUTOSUGGEST_SUGGESTION="$freq_cmd" POSTDISPLAY="$freq_cmd" region_highlight+=("$CURSOR $(( CURSOR + ${#freq_cmd} )) fg=240,bold autosuggest") fi fi fi # Note: If buffer is non-empty but cursor not at end, no suggestion is shown (intentional) } # Show dropdown menu with history matches while typing _autosuggest_show_dropdown() { unsetopt xtrace 2>/dev/null local displayed_suggestion="$1" # Don't interfere if already in explicit menu mode (CTRL+R was pressed) if [[ "$_MENU_EXPLICIT_MODE" == "true" ]]; then return fi # Don't show dropdown during TAB completion if [[ "$_TAB_COMPLETION_ACTIVE" == "true" ]]; then return fi # Use 5 items for auto-dropdown _MENU_MAX_DISPLAY=$ZEAL_MENU_DROPDOWN_SIZE # Check if buffer changed while in dropdown (user typed something) if [[ "$_MENU_DROPDOWN_ENTERED" == "true" && "$BUFFER" != "$_MENU_SEARCH_QUERY" ]]; then # Exit dropdown mode since query changed _MENU_SEARCH_ACTIVE=false _MENU_DROPDOWN_ENTERED=false _MENU_SELECTED_INDEX=0 fi # If user has entered dropdown and query unchanged, just re-render (keep selection) if [[ "$_MENU_DROPDOWN_ENTERED" == "true" ]]; then _menu_render return fi # Only update matches if query changed if [[ "$BUFFER" != "$_MENU_SEARCH_QUERY" ]]; then _MENU_SEARCH_QUERY="$BUFFER" _MENU_SELECTED_INDEX=0 _MENU_DISPLAY_OFFSET=0 # Only get contextual matches for auto-dropdown (no global fallback) _MENU_MATCHES_CONTEXTUAL=() _MENU_MATCHES_GLOBAL=() _menu_collect_contextual_substring_matches "$BUFFER" # Remove the grey auto-suggestion from the dropdown when it is also the # first contextual match. if [[ -n "$displayed_suggestion" && "${_MENU_MATCHES_CONTEXTUAL[1]}" == "$displayed_suggestion" ]]; then _MENU_MATCHES_CONTEXTUAL=("${_MENU_MATCHES_CONTEXTUAL[@]:1}") fi fi # Only render if we have matches after removing the first (which is shown as grey auto-suggestion) if (( ${#_MENU_MATCHES_CONTEXTUAL[@]} > 0 )); then _menu_render else zle -M "" fi } _autosuggest_accept() { if [[ -n "$_AUTOSUGGEST_SUGGESTION" ]]; then # Snapshot whether the current display is a substring match before clearing it. local was_substring_match=false [[ "$POSTDISPLAY" == " → "* ]] && was_substring_match=true # Clear the autosuggest display/highlight first. The grey highlight was created # for POSTDISPLAY offsets; after accepting, those offsets become real BUFFER # text, so leaving the highlight around even for one redraw makes the accepted # command appear greyed out. local old_buffer="$BUFFER" local accepted_suggestion="$_AUTOSUGGEST_SUGGESTION" _AUTOSUGGEST_SUGGESTION="" POSTDISPLAY="" # Force ZLE to drop POSTDISPLAY before mutating BUFFER. Without this, # multi-part suggestions can leave the old POSTDISPLAY color state on the # accepted argument/path text until the line is accepted. zle -R region_highlight=() zle -R if [[ "$was_substring_match" == "true" ]]; then BUFFER="$accepted_suggestion" else # Append the completion part (prefix match) BUFFER="$BUFFER$accepted_suggestion" fi CURSOR=${#BUFFER} # Force the accepted text back to the terminal default for this redraw. # Syntax highlighting will add more specific regions (e.g. command names) # after this, so the first word can still be colored while plain arguments # do not inherit the previous grey autosuggest color. region_highlight+=("0 ${#BUFFER} fg=default") zle -R zle reset-prompt 2>/dev/null fi } # Accept suggestion if present, otherwise move cursor forward (for arrow right) _autosuggest_accept_or_forward_char() { if [[ -n "$_AUTOSUGGEST_SUGGESTION" ]]; then _autosuggest_accept else zle forward-char fi } _autosuggest_clear() { _AUTOSUGGEST_SUGGESTION="" POSTDISPLAY="" region_highlight=("${(@)region_highlight:#*memo=autosuggest*}") region_highlight=("${(@)region_highlight:#*autosuggest*}") region_highlight=("${(@)region_highlight:#*fg=240,bold*}") } # Widget to show frequent command (can be called from signal handlers) _autosuggest_show_frequent_widget() { [[ -n "$BUFFER" ]] && return [[ "$_CONTEXTUAL_HISTORY_LOADED" != "true" ]] && return _ensure_most_frequent_cached local freq_cmd freq_cmd=$(_get_most_frequent_contextual_command) if [[ -n "$freq_cmd" ]]; then _AUTOSUGGEST_SUGGESTION="$freq_cmd" POSTDISPLAY="$freq_cmd" region_highlight=("0 ${#freq_cmd} fg=240,bold autosuggest") fi } # Create ZLE widgets zle -N autosuggest-accept _autosuggest_accept zle -N autosuggest-accept-or-forward-char _autosuggest_accept_or_forward_char zle -N autosuggest-clear _autosuggest_clear zle -N autosuggest-show-frequent _autosuggest_show_frequent_widget # Reset history cycling state _history_cycle_reset() { _HISTORY_CYCLE_INDEX=0 _HISTORY_CYCLE_BUFFER="" _HISTORY_CYCLE_SESSION=() _HISTORY_CYCLE_IN_PROGRESS=false } # ============================================================================ # CTRL+R Menu Search Functions # ============================================================================ # Reset menu search state _menu_search_reset() { _MENU_SEARCH_ACTIVE=false _MENU_EXPLICIT_MODE=false _MENU_SEARCH_QUERY="" _MENU_MATCHES_CONTEXTUAL=() _MENU_MATCHES_GLOBAL=() _MENU_SELECTED_INDEX=0 _MENU_DROPDOWN_ENTERED=false _MENU_DISPLAY_OFFSET=0 _MENU_ORIGINAL_BUFFER="" # Clear the menu display zle -M "" } # Update matches based on current search query _menu_update_matches() { local query="$1" # Reset matches _MENU_MATCHES_CONTEXTUAL=() _MENU_MATCHES_GLOBAL=() # Get contextual matches first (prioritize directory-specific history) local matches_output matches_output=$(_menu_get_contextual_substring_matches "$query") if [[ -n "$matches_output" ]]; then local line while IFS= read -r line; do _MENU_MATCHES_CONTEXTUAL+=("$line") done <<< "$matches_output" fi # If no contextual matches, fall back to global history if (( ${#_MENU_MATCHES_CONTEXTUAL[@]} == 0 )); then local global_matches_output global_matches_output=$(_menu_get_global_substring_matches "$query") if [[ -n "$global_matches_output" ]]; then local line while IFS= read -r line; do _MENU_MATCHES_GLOBAL+=("$line") done <<< "$global_matches_output" fi fi } # Truncate command to fit terminal width _menu_truncate_command() { local cmd="$1" local max_len="${2:-80}" if (( ${#cmd} > max_len )); then echo "${cmd:0:$((max_len - 3))}..." else echo "$cmd" fi } # Render the visual menu _menu_render() { local total_contextual=${#_MENU_MATCHES_CONTEXTUAL[@]} local total_global=${#_MENU_MATCHES_GLOBAL[@]} # If no matches at all if (( total_contextual == 0 && total_global == 0 )); then # In explicit CTRL+R mode, show message; in auto mode, just clear if [[ "$_MENU_EXPLICIT_MODE" == "true" ]]; then zle -M "No history matches found" else zle -M "" fi return fi # Get terminal width for proper truncation local term_width=${COLUMNS:-80} local cmd_width=$((term_width - 10)) # Leave space for prefix and padding # Build menu as array of lines (plain text, no colors) local -a menu_lines local visible_start=$_MENU_DISPLAY_OFFSET local visible_end=$((visible_start + _MENU_MAX_DISPLAY)) local current_idx=0 # Determine which matches to show local -a matches_to_show local match_type="" if (( total_contextual > 0 )); then matches_to_show=("${_MENU_MATCHES_CONTEXTUAL[@]}") match_type="contextual" else matches_to_show=("${_MENU_MATCHES_GLOBAL[@]}") match_type="global" fi # Display matches for cmd in "${matches_to_show[@]}"; do current_idx=$((current_idx + 1)) # Skip if before visible window (( current_idx < visible_start )) && continue # Stop if past visible window (( current_idx > visible_end )) && break # Highlight selected item (only if dropdown entered and item selected) local truncated_cmd=$(_menu_truncate_command "$cmd" "$cmd_width") if [[ "$_MENU_DROPDOWN_ENTERED" == "true" ]] && (( current_idx == _MENU_SELECTED_INDEX )); then menu_lines+=("> ${truncated_cmd}") else menu_lines+=(" ${truncated_cmd}") fi done # Add match counter at bottom with type indicator if [[ "$match_type" == "contextual" ]]; then menu_lines+=("[${total_contextual} contextual matches]") else menu_lines+=("[${total_global} global matches]") fi # Join lines with newlines and display local menu_text="${(F)menu_lines}" zle -M "$menu_text" } # Update display (refresh prompt and menu) _menu_update_display() { # Render the menu _menu_render # Keep cursor at end of buffer CURSOR=${#BUFFER} } # Move selection up or down _menu_move_selection() { local direction="$1" # -1 for up, +1 for down # Determine total matches (contextual or global) local total_matches if (( ${#_MENU_MATCHES_CONTEXTUAL[@]} > 0 )); then total_matches=${#_MENU_MATCHES_CONTEXTUAL[@]} else total_matches=${#_MENU_MATCHES_GLOBAL[@]} fi # If no matches, do nothing (( total_matches == 0 )) && return # Update selected index _MENU_SELECTED_INDEX=$((_MENU_SELECTED_INDEX + direction)) # Wrap around if (( _MENU_SELECTED_INDEX < 1 )); then _MENU_SELECTED_INDEX=$total_matches elif (( _MENU_SELECTED_INDEX > total_matches )); then _MENU_SELECTED_INDEX=1 fi # Update scroll offset if needed if (( _MENU_SELECTED_INDEX < _MENU_DISPLAY_OFFSET )); then _MENU_DISPLAY_OFFSET=$((_MENU_SELECTED_INDEX - 1)) elif (( _MENU_SELECTED_INDEX > _MENU_DISPLAY_OFFSET + _MENU_MAX_DISPLAY )); then _MENU_DISPLAY_OFFSET=$((_MENU_SELECTED_INDEX - _MENU_MAX_DISPLAY)) fi # Update display _menu_update_display } # ============================================================================ # CTRL+R Menu Widget Functions # ============================================================================ # Main entry point: Start menu search (CTRL+R) _menu_search_start() { # If already in explicit menu mode, just refresh if [[ "$_MENU_EXPLICIT_MODE" == "true" ]]; then return fi # Enter explicit menu search mode (CTRL+R was pressed) _MENU_SEARCH_ACTIVE=true _MENU_EXPLICIT_MODE=true _MENU_ORIGINAL_BUFFER="$BUFFER" _MENU_SEARCH_QUERY="$BUFFER" # Get initial matches (if buffer is empty, gets recent commands) _menu_update_matches "$BUFFER" # Display menu (use larger display for explicit CTRL+R mode) _MENU_MAX_DISPLAY=$ZEAL_MENU_CTRLR_SIZE # On empty buffer, immediately enter dropdown with first item selected if [[ -z "$BUFFER" ]]; then _MENU_DROPDOWN_ENTERED=true _MENU_SELECTED_INDEX=1 else _MENU_DROPDOWN_ENTERED=false _MENU_SELECTED_INDEX=0 fi _menu_update_display } # Hook to handle buffer changes in explicit menu mode (CTRL+R) _menu_search_buffer_change() { # Only handle explicit CTRL+R mode here; auto-dropdown is handled in _autosuggest_modify if [[ "$_MENU_EXPLICIT_MODE" == "true" ]]; then # Buffer changed - update query and matches if [[ "$BUFFER" != "$_MENU_SEARCH_QUERY" ]]; then _MENU_SEARCH_QUERY="$BUFFER" # Reset dropdown state when user types (they need to press arrow down again) _MENU_SELECTED_INDEX=0 _MENU_DROPDOWN_ENTERED=false _MENU_DISPLAY_OFFSET=0 _menu_update_matches "$BUFFER" _menu_update_display fi fi } # Handle up arrow in menu mode _menu_search_up() { if [[ "$_MENU_DROPDOWN_ENTERED" == "true" ]]; then # Dropdown was entered - navigate up local total_matches=0 if [[ "$_MENU_EXPLICIT_MODE" == "true" ]]; then # Explicit CTRL+R mode: use contextual or global if (( ${#_MENU_MATCHES_CONTEXTUAL[@]} > 0 )); then total_matches=${#_MENU_MATCHES_CONTEXTUAL[@]} elif (( ${#_MENU_MATCHES_GLOBAL[@]} > 0 )); then total_matches=${#_MENU_MATCHES_GLOBAL[@]} fi else # Auto-dropdown mode: only contextual matches total_matches=${#_MENU_MATCHES_CONTEXTUAL[@]} fi (( _MENU_SELECTED_INDEX-- )) (( _MENU_SELECTED_INDEX < 1 )) && _MENU_SELECTED_INDEX=$total_matches # Render menu and redraw prompt _menu_render zle -R else # Dropdown not entered - cycle through history zle autosuggest-up-or-history fi } # Handle down arrow in menu mode _menu_search_down() { # Determine total matches based on mode local total_matches=0 if [[ "$_MENU_EXPLICIT_MODE" == "true" ]]; then # Explicit CTRL+R mode: use contextual or global if (( ${#_MENU_MATCHES_CONTEXTUAL[@]} > 0 )); then total_matches=${#_MENU_MATCHES_CONTEXTUAL[@]} elif (( ${#_MENU_MATCHES_GLOBAL[@]} > 0 )); then total_matches=${#_MENU_MATCHES_GLOBAL[@]} fi else # Auto-dropdown mode: only contextual matches total_matches=${#_MENU_MATCHES_CONTEXTUAL[@]} fi # If we have matches, handle dropdown navigation (allow empty buffer in explicit CTRL+R mode) if (( total_matches > 0 )) && [[ -n "$BUFFER" || "$_MENU_EXPLICIT_MODE" == "true" ]]; then if [[ "$_MENU_DROPDOWN_ENTERED" != "true" ]]; then # First arrow down: enter dropdown and select first item _MENU_SEARCH_ACTIVE=true _MENU_DROPDOWN_ENTERED=true _MENU_SELECTED_INDEX=1 else # Already in dropdown: move to next item (( _MENU_SELECTED_INDEX++ )) (( _MENU_SELECTED_INDEX > total_matches )) && _MENU_SELECTED_INDEX=1 fi # Render menu and redraw prompt _menu_render zle -R else # No matches or empty buffer - use standard down arrow behavior zle autosuggest-down-or-history fi } # Hook that clears suggestions when line is being finalized _autosuggest_clear_on_finish() { _AUTOSUGGEST_SUGGESTION="" POSTDISPLAY="" region_highlight=() # Clear menu state (menu display clears automatically on new prompt) _MENU_SEARCH_ACTIVE=false _MENU_EXPLICIT_MODE=false _MENU_SEARCH_QUERY="" _MENU_MATCHES_CONTEXTUAL=() _MENU_MATCHES_GLOBAL=() _MENU_SELECTED_INDEX=0 _MENU_DROPDOWN_ENTERED=false _MENU_MAX_DISPLAY=$ZEAL_MENU_DROPDOWN_SIZE } # Handle Enter in menu mode _menu_search_accept() { # Reset TAB completion flag on Enter _TAB_COMPLETION_ACTIVE=false if [[ "$_MENU_SEARCH_ACTIVE" == "true" ]]; then local selected_cmd="" # Only get selected command if dropdown was entered if [[ "$_MENU_DROPDOWN_ENTERED" == "true" && $_MENU_SELECTED_INDEX -gt 0 ]]; then if (( ${#_MENU_MATCHES_CONTEXTUAL[@]} > 0 )); then selected_cmd="${_MENU_MATCHES_CONTEXTUAL[$_MENU_SELECTED_INDEX]}" else selected_cmd="${_MENU_MATCHES_GLOBAL[$_MENU_SELECTED_INDEX]}" fi fi # CRITICAL: Set these to false BEFORE modifying BUFFER # This prevents the line-pre-redraw hook from interfering _MENU_SEARCH_ACTIVE=false _MENU_EXPLICIT_MODE=false # Clean up menu state first _MENU_SEARCH_QUERY="" _MENU_MATCHES_CONTEXTUAL=() _MENU_MATCHES_GLOBAL=() _MENU_SELECTED_INDEX=0 _MENU_DROPDOWN_ENTERED=false _MENU_DISPLAY_OFFSET=0 _MENU_ORIGINAL_BUFFER="" _MENU_MAX_DISPLAY=$ZEAL_MENU_DROPDOWN_SIZE # Clear the menu display zle -M "" # If dropdown was entered and we have a selection, use it if [[ -n "$selected_cmd" ]]; then BUFFER="$selected_cmd" CURSOR=${#BUFFER} # Force a complete redraw to show the selected command zle -R return 0 else # Dropdown not entered: execute the current buffer (typed command) zle .accept-line return 0 fi else # Not in menu mode - just accept normally, the hook will clear suggestions zle .accept-line fi } # Handle ESC/CTRL+G in menu mode (cancel) _menu_search_cancel() { # Reset TAB completion flag on cancel _TAB_COMPLETION_ACTIVE=false if [[ "$_MENU_EXPLICIT_MODE" == "true" ]]; then # CRITICAL: Set these to false FIRST, before any operations # This prevents the line-pre-redraw hook from re-rendering the menu _MENU_SEARCH_ACTIVE=false _MENU_EXPLICIT_MODE=false # Save the original buffer before cleanup local saved_buffer="$_MENU_ORIGINAL_BUFFER" # Clean up ALL state variables immediately _MENU_SEARCH_QUERY="" _MENU_MATCHES_CONTEXTUAL=() _MENU_MATCHES_GLOBAL=() _MENU_SELECTED_INDEX=0 _MENU_DROPDOWN_ENTERED=false _MENU_DISPLAY_OFFSET=0 _MENU_ORIGINAL_BUFFER="" _MENU_MAX_DISPLAY=$ZEAL_MENU_DROPDOWN_SIZE # Restore the original buffer (what was there before entering menu mode) BUFFER="$saved_buffer" CURSOR=${#BUFFER} POSTDISPLAY="" # Clear autosuggestion state _AUTOSUGGEST_SUGGESTION="" region_highlight=() # Clear the menu display and reset prompt zle -M "" zle -R # Return success to prevent any default key handling return 0 elif [[ "$_MENU_SEARCH_ACTIVE" == "true" ]]; then # Auto-dropdown mode - just clear it _MENU_SEARCH_ACTIVE=false _MENU_SEARCH_QUERY="" _MENU_MATCHES_CONTEXTUAL=() _MENU_MATCHES_GLOBAL=() _MENU_SELECTED_INDEX=0 _MENU_DROPDOWN_ENTERED=false zle -M "" zle send-break return 0 else # Not in menu mode - standard CTRL+C behavior # Send interrupt to show ^C and start fresh line below zle send-break return 0 fi } # Handle CTRL+C specifically - use EXACT same behavior as ESC _menu_search_interrupt() { # Just call the cancel function - they should behave identically _menu_search_cancel } # Hook to reset cycle on buffer modification _history_cycle_check_reset() { if [[ "$_HISTORY_CYCLE_IN_PROGRESS" == "true" ]]; then local current_displayed="" if (( _HISTORY_CYCLE_INDEX > 0 )); then current_displayed="${_HISTORY_CYCLE_SESSION[$_HISTORY_CYCLE_INDEX]}" fi # Reset if user modified the buffer if [[ "$BUFFER" != "$current_displayed" ]]; then _history_cycle_reset fi fi } # Custom up-arrow: cycles through session history (previous commands) _autosuggest_up_or_history() { # Clear any visible suggestion if [[ -n "$_AUTOSUGGEST_SUGGESTION" ]]; then _autosuggest_clear fi # Initialize cycling if not in progress if [[ "$_HISTORY_CYCLE_IN_PROGRESS" != "true" ]]; then _HISTORY_CYCLE_BUFFER="$BUFFER" _HISTORY_CYCLE_INDEX=0 _HISTORY_CYCLE_IN_PROGRESS=true # Copy session history (reverse order - newest first) _HISTORY_CYCLE_SESSION=() local -i i for (( i=${#_SESSION_HISTORY_COMMANDS[@]}; i>0; i-- )); do _HISTORY_CYCLE_SESSION+=("${_SESSION_HISTORY_COMMANDS[$i]}") done fi # Cycle through session history if (( ${#_HISTORY_CYCLE_SESSION[@]} > 0 && _HISTORY_CYCLE_INDEX < ${#_HISTORY_CYCLE_SESSION[@]} )); then BUFFER="${_HISTORY_CYCLE_SESSION[$((_HISTORY_CYCLE_INDEX + 1))]}" CURSOR=${#BUFFER} _HISTORY_CYCLE_INDEX=$((_HISTORY_CYCLE_INDEX + 1)) else # Exhausted session, fall back to global history zle up-line-or-beginning-search fi } zle -N autosuggest-up-or-history _autosuggest_up_or_history # Custom down-arrow: reverse cycling through session history _autosuggest_down_or_history() { # If not cycling, use standard down behavior if [[ "$_HISTORY_CYCLE_IN_PROGRESS" != "true" ]]; then zle down-line-or-beginning-search return fi # Go back in session history if (( _HISTORY_CYCLE_INDEX > 0 )); then _HISTORY_CYCLE_INDEX=$((_HISTORY_CYCLE_INDEX - 1)) if (( _HISTORY_CYCLE_INDEX == 0 )); then # Back to original buffer BUFFER="$_HISTORY_CYCLE_BUFFER" else BUFFER="${_HISTORY_CYCLE_SESSION[$_HISTORY_CYCLE_INDEX]}" fi CURSOR=${#BUFFER} else # Already at start, reset cycling BUFFER="$_HISTORY_CYCLE_BUFFER" CURSOR=${#BUFFER} _history_cycle_reset fi } zle -N autosuggest-down-or-history _autosuggest_down_or_history # Register menu search widgets zle -N menu-search-start _menu_search_start zle -N menu-search-up _menu_search_up zle -N menu-search-down _menu_search_down zle -N menu-search-accept _menu_search_accept zle -N menu-search-cancel _menu_search_cancel zle -N menu-search-interrupt _menu_search_interrupt # Note: We use a line-pre-redraw hook to handle typing in menu mode # This is simpler than creating a custom keymap # Key bindings # CTRL+R: Start contextual history menu search bindkey '^R' menu-search-start # Arrow keys (support both standard and application mode) bindkey '^[[A' menu-search-up # Up arrow: menu navigation or history bindkey '^[OA' menu-search-up # Up arrow (application mode) bindkey '^[[B' menu-search-down # Down arrow: menu navigation or history bindkey '^[OB' menu-search-down # Down arrow (application mode) bindkey '^[[C' autosuggest-accept-or-forward-char # Right arrow: accept suggestion or move cursor bindkey '^[OC' autosuggest-accept-or-forward-char # Right arrow (application mode) # Word navigation — these must be explicit because the bare '^[' (ESC) binding above # would otherwise consume the escape prefix before ZSH can match the full sequence. bindkey '^[[1;5D' backward-word # CTRL+LEFT (xterm / most modern terminals) bindkey '^[[1;5C' forward-word # CTRL+RIGHT bindkey '^[[5D' backward-word # CTRL+LEFT (older xterm) bindkey '^[[5C' forward-word # CTRL+RIGHT bindkey '^[Od' backward-word # CTRL+LEFT (application cursor mode) bindkey '^[Oc' forward-word # CTRL+RIGHT bindkey '^[^?' backward-kill-word # CTRL+BACKSPACE via ESC+DEL (some terminals) bindkey '^[^H' backward-kill-word # CTRL+BACKSPACE via ESC+BS (some terminals) # CTRL+BACKSPACE — most terminals send ^H (0x08), not ESC-prefixed bindkey '^H' backward-kill-word # Swallow CSI escape sequences we don't have an explicit binding for (e.g. the # Kitty keyboard protocol's CSI-u key reports like "\e[99;5u"/"\e[99;5:3u"). # TUI apps (codex, claude, ...) can enable that protocol and get killed by a # CTRL+C flood before disabling it again, so the terminal keeps encoding keys # that way until it's reset (see precmd below) — without this, each byte of # those sequences gets self-inserted as literal text into the command line. # Any *specific* sequence bound above (arrows, CTRL+LEFT/RIGHT, ...) still # wins over this catch-all since ZLE prefers the longest matching binding. _discard_unknown_csi() { local char # Consume parameter/intermediate bytes until the CSI final byte (0x40-0x7E). while read -t 0.05 -k 1 char 2>/dev/null; do case "$char" in [@-~]) break ;; esac done } zle -N _discard_unknown_csi bindkey '^[[' _discard_unknown_csi # Disable terminal interrupt character by default so ZLE can handle CTRL+C # This will be re-enabled in preexec() before commands run, and disabled again in precmd() typeset -g _TTY_INTR_UNDEF=true stty intr undef # Menu search control keys bindkey '^M' menu-search-accept # Enter: accept selection or normal accept bindkey '^C' menu-search-interrupt # CTRL+C: interrupt/cancel search bindkey '^G' menu-search-cancel # CTRL+G: cancel search bindkey '^[' menu-search-cancel # ESC: cancel search bindkey '^F' autosuggest-accept # Ctrl+F: accepts whole suggestion bindkey '^ ' autosuggest-accept # Ctrl+Space: accepts whole suggestion # ---------------------------------------------------------------------------- # Command Execution Time Tracking & iTerm Title # ---------------------------------------------------------------------------- # Initialize exit code to 0 (for first prompt) typeset -g cmd_exit_code=0 function set_iterm_title() { echo -ne "\033]0;${PWD##*/} - $1\007" } preexec() { # Reset history cycling when executing a command (start fresh for next ARROW UP) _history_cycle_reset # Reset TAB completion flag _TAB_COMPLETION_ACTIVE=false # Clear any remaining auto-suggestions _AUTOSUGGEST_SUGGESTION="" POSTDISPLAY="" region_highlight=() # Start timer for execution time cmd_start_time=$SECONDS # Set iTerm title to command being executed set_iterm_title "$1" # Re-enable CTRL+C for interrupting commands stty intr '^C' _TTY_INTR_UNDEF=false } precmd() { # MUST capture exit code first, before any other commands run local last_exit_code=$? # Process last command based on exit code if [[ -n "$_LAST_COMMAND" ]]; then if [[ $last_exit_code -eq 0 ]]; then # Command succeeded - add to global history and contextual history local should_add_to_global=true # Skip short commands (< 4 chars) from global history (cd, ls, rm, cp, mv, pwd, etc.) if (( ${#_LAST_COMMAND} < 4 )); then should_add_to_global=false fi # General handling: exclude commands with relative paths from global history if [[ "$should_add_to_global" == "true" ]]; then local -a args args=(${(z)_LAST_COMMAND}) # Check every argument to see if it's a relative path # Use pattern matching (not file existence) to avoid issues with mv/rm changing filesystem for arg in "${args[@]:1}"; do # Skip flags (start with -) [[ "$arg" == "-"* ]] && continue # Skip absolute paths and tilde paths (these are fine for global history) [[ "$arg" == "/"* || "$arg" == "~"* ]] && continue # Skip URLs (contain ://) [[ "$arg" == *"://"* ]] && continue # Skip pure numbers (ports, PIDs, counts, etc.) [[ "$arg" =~ ^[0-9]+$ ]] && continue # "." and ".." are relative path references if [[ "$arg" == "." || "$arg" == ".." ]]; then should_add_to_global=false break fi # Skip very short strings (likely options like -v, -d, single chars) (( ${#arg} < 2 )) && continue # If it contains a slash, it's a relative path (absolute paths filtered above) # This catches: cd foo/bar, mv v5/file.txt ., cat ../foo, open ./bar if [[ "$arg" == *"/"* ]]; then should_add_to_global=false break fi # For simple filenames without slash, check if file exists # This catches: vim file.txt, cat README, cd Desktop if ( cd "$_LAST_COMMAND_PWD" 2>/dev/null && [[ -e "$arg" ]] ); then should_add_to_global=false break fi done fi if [[ "$should_add_to_global" == "true" ]]; then # Manually append to history file in ZSH format local timestamp=$EPOCHSECONDS local hist_entry=": ${timestamp}:0;${_LAST_COMMAND}" print -r -- "$hist_entry" >> "$HISTFILE" # Also add to in-memory history print -s "$_LAST_COMMAND" _GLOBAL_HISTORY_SEARCH_CACHE=("$_LAST_COMMAND" "${_GLOBAL_HISTORY_SEARCH_CACHE[@]}") fi _store_contextual_history "$_LAST_COMMAND" "$_LAST_COMMAND_PWD" fi fi # Clear the last command tracking _LAST_COMMAND="" _LAST_COMMAND_PWD="" _PENDING_HISTORY_ENTRY="" # Reset the git check flags for next prompt cycle _VCS_INFO_CURRENT_HEAD_CHECKED=false _VCS_INFO_REGENERATED_THIS_CYCLE=false # Calculate execution time if [ -n "$cmd_start_time" ]; then # A command was executed - use its exit code cmd_exit_code=$last_exit_code local cmd_end_time=$SECONDS # Convert to integer for arithmetic (handles both int and float SECONDS) local elapsed=$(( ${cmd_end_time%.*} - ${cmd_start_time%.*} )) # Format execution time if (( elapsed >= 60 )); then local minutes=$(( elapsed / 60 )) local seconds=$(( elapsed % 60 )) cmd_exec_time="${minutes}m ${seconds}s" else cmd_exec_time="${elapsed}s" fi unset cmd_start_time else # No command was executed (empty line) - reset exit code cmd_exit_code=0 cmd_exec_time="" fi # Set iTerm title back to zsh set_iterm_title "zsh" # Disable CTRL+C interrupt character so ZLE can handle it. Guarded because # an empty prompt cycle (e.g. spamming ENTER on a blank line) skips preexec # entirely, so intr is already undef and re-forking `stty` would be wasted. if [[ "$_TTY_INTR_UNDEF" != true ]]; then stty intr undef _TTY_INTR_UNDEF=true fi # TUI apps (codex, claude, ...) can enable the Kitty keyboard protocol (CSI-u # key encoding) and get killed by a CTRL+C flood before disabling it again, # leaving the terminal to keep encoding keys that way. Unconditionally clear # the enhancement flags so subsequent keys go back to normal encoding; any # CSI-u bytes already in flight are swallowed by _discard_unknown_csi below. print -n '\e[=0u' } # ---------------------------------------------------------------------------- # Syntax Highlighting # ---------------------------------------------------------------------------- typeset -gA _SYNTAX_CMD_CACHE # cmd -> 0 (valid) or 1 (invalid) typeset -g _SYNTAX_LAST_BUF="" # cached buffer for skip-if-unchanged typeset -ga _SYNTAX_LAST_HL=() # cached highlight entries _syntax_highlight() { emulate -L zsh # Remove previous syntax/highlight-reset entries (preserve active autosuggest) region_highlight=("${(@)region_highlight:#*syntax*}") # Keep one-shot `none` reset highlights for this redraw; they counteract # stale grey autosuggest styling after accepting POSTDISPLAY text. [[ -z "$BUFFER" ]] && { _SYNTAX_LAST_BUF=""; return; } # Skip re-parse if buffer unchanged — just re-apply cached highlights. # Always include a full-buffer default foreground reset first; cached entries # may come from before this reset existed, and unhighlighted arguments/paths # otherwise can keep stale grey POSTDISPLAY styling after accepting a suggestion. if [[ "$BUFFER" == "$_SYNTAX_LAST_BUF" ]]; then region_highlight+=("0 ${#BUFFER} fg=default syntax") region_highlight+=("${_SYNTAX_LAST_HL[@]}") return fi local buf="$BUFFER" local -i len=${#buf} i=0 local -i word_start=-1 is_cmd=1 local in_q="" local -i q_start=0 # Collect syntax highlights in local array. Start with an explicit default # foreground over the whole buffer so unhighlighted words/arguments don't # visually inherit stale colors from a previous POSTDISPLAY autosuggestion. local -a _sh_hl=("0 $len fg=default syntax") while (( i <= len )); do local c="" (( i < len )) && c="${buf:$i:1}" # Inside quotes if [[ -n "$in_q" ]]; then if [[ "$c" == "$in_q" ]]; then _sh_hl+=("$q_start $((i + 1)) fg=${ZEAL_COLOR_STRING} syntax") in_q="" elif [[ -z "$c" ]]; then _sh_hl+=("$q_start $i fg=${ZEAL_COLOR_STRING} syntax") in_q="" fi (( i++ )); continue fi # Start quote if [[ "$c" == "'" || "$c" == '"' ]]; then (( word_start >= 0 )) && { _sh_w; word_start=-1; is_cmd=0; } in_q="$c"; q_start=$i; (( i++ )); continue fi # Semicolon if [[ "$c" == ";" ]]; then (( word_start >= 0 )) && { _sh_w; word_start=-1; } _sh_hl+=("$i $((i + 1)) fg=${ZEAL_COLOR_OPERATOR} syntax") (( i++ )); is_cmd=1; continue fi # Pipe if [[ "$c" == "|" ]]; then (( word_start >= 0 )) && { _sh_w; word_start=-1; } local -i olen=1; [[ "${buf:$((i+1)):1}" == "|" ]] && olen=2 _sh_hl+=("$i $((i + olen)) fg=${ZEAL_COLOR_OPERATOR} syntax") (( i += olen )); is_cmd=1; continue fi # Double ampersand if [[ "$c" == "&" && "${buf:$((i+1)):1}" == "&" ]]; then (( word_start >= 0 )) && { _sh_w; word_start=-1; } _sh_hl+=("$i $((i + 2)) fg=${ZEAL_COLOR_OPERATOR} syntax") (( i += 2 )); is_cmd=1; continue fi # Redirections if [[ "$c" == ">" || "$c" == "<" ]]; then (( word_start >= 0 )) && { _sh_w; word_start=-1; } local -i rlen=1 [[ "${buf:$((i+1)):1}" == "$c" || "${buf:$((i+1)):1}" == "&" ]] && rlen=2 _sh_hl+=("$i $((i + rlen)) fg=${ZEAL_COLOR_OPERATOR} syntax") (( i += rlen )); continue fi # Comment if [[ "$c" == "#" && ( $i -eq 0 || "${buf:$((i-1)):1}" == " " ) ]]; then (( word_start >= 0 )) && { _sh_w; word_start=-1; } _sh_hl+=("$i $len fg=${ZEAL_COLOR_COMMENT} syntax") break fi # Word boundary or end if [[ "$c" == " " || -z "$c" ]]; then if (( word_start >= 0 )); then local w="${buf:$word_start:$((i - word_start))}" _sh_w if (( is_cmd )); then case "$w" in sudo|command|builtin|exec|env|noglob|nocorrect) ;; *) is_cmd=0 ;; esac fi word_start=-1 fi (( i++ )); continue fi (( word_start < 0 )) && word_start=$i (( i++ )) done # Cache and apply _SYNTAX_LAST_BUF="$buf" _SYNTAX_LAST_HL=("${_sh_hl[@]}") region_highlight+=("${_sh_hl[@]}") } # Highlight a single word — reads word_start, i, is_cmd, buf from caller scope # Appends to caller's _sh_hl array (no subshell) _sh_w() { local w="${buf:$word_start:$((i - word_start))}" if (( is_cmd )); then if _zeal_is_valid_command "$w"; then _sh_hl+=("$word_start $i fg=${ZEAL_COLOR_CMD_VALID} syntax") else _sh_hl+=("$word_start $i fg=${ZEAL_COLOR_CMD_INVALID},underline syntax") fi elif [[ "$w" == -* && ${#w} -gt 1 ]]; then _sh_hl+=("$word_start $i fg=${ZEAL_COLOR_FLAG} syntax") fi } # ---------------------------------------------------------------------------- # Agnoster-style Prompt with Git Branch # ---------------------------------------------------------------------------- # Colors work with %F{} syntax, no need to load colors module # Lazy-load vcs_info on first use typeset -g _VCS_INFO_LOADED=false # PROMPT_SUBST needed for dynamic prompt evaluation setopt PROMPT_SUBST typeset -g _VCS_STAGED_MARKER='%F{yellow}✚%f' typeset -g _VCS_UNSTAGED_MARKER='%F{yellow}●%f' # Red + bold for contrast: a bare "?" is a thin glyph and read as nearly # invisible in plain yellow against a dark background. typeset -g _VCS_UNTRACKED_MARKER='%B%F{red}?%f%b' # The marker glyphs are visually dense, so a full space on either side of the # cluster reads as a gap. A thin space keeps them separated from the branch # name and the segment edge without the hole. typeset -g _VCS_MARKER_PAD=$'\u2009' _init_vcs_info() { [[ "$_VCS_INFO_LOADED" == "true" ]] && return # Load version control info autoload -Uz vcs_info # Configure vcs_info for git zstyle ':vcs_info:*' enable git # Left off: vcs_info's own dirty check spawns four more `git` processes per # prompt, and precmd_check_git_head already knows the answer. The hook below # supplies %u/%c instead (so unstagedstr/stagedstr go unused as well). zstyle ':vcs_info:*' check-for-changes false # Markers carry their own %F{}...%f color (see _VCS_*_MARKER below) since # untracked needs a color distinct from staged/unstaged for contrast. zstyle ':vcs_info:git:*' formats ' ⎇ %b%u%f%m' zstyle ':vcs_info:git:*' actionformats ' ⎇ %b|%a%u%f%m' zstyle ':vcs_info:git*+set-message:*' hooks git-status-summary _VCS_INFO_LOADED=true } # Fill in %u/%c and %m from the porcelain output precmd_check_git_head already # collected, instead of letting vcs_info re-derive them: the dirty markers cost # it four `git` spawns and the ahead/behind counts one more, at ~10ms of # process startup each, for data we are holding. +vi-git-status-summary() { # Single field (not %u + %c) so the separator can be conditional: a literal # space in the format string would show up after a clean branch name too, # lengthening every prompt instead of just dirty ones. local markers="${_VCS_INFO_CURRENT_UNSTAGED}${_VCS_INFO_CURRENT_UNTRACKED}${_VCS_INFO_CURRENT_STAGED}" hook_com[staged]="" hook_com[unstaged]="${markers:+$_VCS_MARKER_PAD$markers}" # %m carries the segment's trailing pad (prompt_git no longer appends one) so # both sides of the marker cluster shrink together; a clean branch keeps the # full space it always had. local pad="${${markers:+$_VCS_MARKER_PAD}:- }" hook_com[misc]="$pad" # Branch header, e.g. "## main...origin/main [ahead 1, behind 2]". Brackets # cannot occur in a ref name, so the bracketed part is always the tracking # field ("gone" when the upstream is missing, hence the digit extraction). local header="$_VCS_INFO_CURRENT_UPSTREAM" [[ "$header" == *\[*\] ]] || return local tracking="${${header##*\[}%\]}" local -a divergence [[ "$tracking" == *ahead* ]] && divergence+=("↑${${tracking#*ahead }%%[^0-9]*}") [[ "$tracking" == *behind* ]] && divergence+=("↓${${tracking#*behind }%%[^0-9]*}") if (( ${#divergence} > 0 )); then hook_com[misc]="${pad}%F{black}${(j: :)divergence}%f " fi } # Cache for vcs_info to prevent multiple calls typeset -g _VCS_INFO_CACHE="" typeset -g _VCS_INFO_CACHE_PWD="" typeset -g _VCS_INFO_CACHE_HEAD="" typeset -g _VCS_INFO_CACHE_STATUS="" typeset -g _VCS_INFO_CACHE_UPSTREAM="" typeset -g _VCS_INFO_CURRENT_HEAD_CHECKED=false typeset -g _VCS_INFO_CURRENT_HEAD="" typeset -g _VCS_INFO_CURRENT_STATUS="" typeset -g _VCS_INFO_CURRENT_STAGED="" typeset -g _VCS_INFO_CURRENT_UNSTAGED="" typeset -g _VCS_INFO_CURRENT_UNTRACKED="" typeset -g _VCS_INFO_CURRENT_UPSTREAM="" typeset -g _VCS_INFO_REGENERATED_THIS_CYCLE=false typeset -g _VCS_INFO_LAST_CHECK_PWD="" typeset -g _VCS_INFO_LAST_CHECK_TIME=0 # Only consecutive prompt cycles closer together than this reuse the previous # git state (see precmd_check_git_head). Short enough that any human-paced # ENTER re-checks, long enough to absorb key auto-repeat. typeset -g _VCS_INFO_CHECK_DEBOUNCE_SECONDS=0.3 zmodload zsh/datetime # $EPOCHREALTIME # Check git HEAD once per prompt cycle precmd_check_git_head() { # Guard: only run once per prompt cycle if [[ "$_VCS_INFO_CURRENT_HEAD_CHECKED" == "true" ]]; then return fi # Fast path: back-to-back empty prompt cycles in the same directory (e.g. # holding ENTER on a blank line) reuse the last-checked values instead of # spawning `git` again. Time-bounded because git state can also change # without this shell running a command — another terminal, an editor saving # a file — so anything slower than key auto-repeat re-checks. if [[ -z "$_LAST_COMMAND" && "$PWD" == "$_VCS_INFO_LAST_CHECK_PWD" ]] && (( EPOCHREALTIME - _VCS_INFO_LAST_CHECK_TIME < _VCS_INFO_CHECK_DEBOUNCE_SECONDS )); then _VCS_INFO_CURRENT_HEAD_CHECKED=true return fi _VCS_INFO_CURRENT_HEAD="" _VCS_INFO_CURRENT_UPSTREAM="" _VCS_INFO_CURRENT_STATUS="" _VCS_INFO_CURRENT_STAGED="" _VCS_INFO_CURRENT_UNSTAGED="" _VCS_INFO_CURRENT_UNTRACKED="" # One `git status` feeds both the cache key below and the rendering hook, # because spawning `git` costs ~10ms of process startup before it looks at # the repo at all — far more than any of the work asked of it here. # `--porcelain -b` reports in a single call whether we are in a repo (no # output if not), the state of every changed path, and the branch's tracking # state ("## main...origin/main [ahead 1]"), which moves whenever the # upstream the ahead/behind display follows moves. `--ignore-submodules` # matches how vcs_info judges dirtiness. HEAD still needs its own lookup: it # changes without the others during a rebase or on a detached HEAD. local -a status_lines status_lines=( ${(f)"$(git status --porcelain -b --ignore-submodules=dirty 2>/dev/null)"} ) if (( ${#status_lines} > 0 )); then _VCS_INFO_CURRENT_UPSTREAM="${status_lines[1]}" _VCS_INFO_CURRENT_HEAD=$(git rev-parse HEAD 2>/dev/null) # Porcelain marks each path as "XY path": X is its state in the index, Y # in the worktree. "??" is untracked (neither slot reflects index/worktree # state, so it needs its own marker); ignored ("!!") paths carry no marker # at all. local line for line in "${(@)status_lines[2,-1]}"; do if [[ "$line" == '??'* ]]; then _VCS_INFO_CURRENT_UNTRACKED="$_VCS_UNTRACKED_MARKER" elif [[ "$line" != '!!'* ]]; then [[ "${line[1]}" != ' ' ]] && _VCS_INFO_CURRENT_STAGED="$_VCS_STAGED_MARKER" [[ "${line[2]}" != ' ' ]] && _VCS_INFO_CURRENT_UNSTAGED="$_VCS_UNSTAGED_MARKER" fi [[ -n "$_VCS_INFO_CURRENT_STAGED" && -n "$_VCS_INFO_CURRENT_UNSTAGED" && -n "$_VCS_INFO_CURRENT_UNTRACKED" ]] && break done _VCS_INFO_CURRENT_STATUS="${_VCS_INFO_CURRENT_STAGED}${_VCS_INFO_CURRENT_UNSTAGED}${_VCS_INFO_CURRENT_UNTRACKED}" fi _VCS_INFO_LAST_CHECK_PWD="$PWD" _VCS_INFO_LAST_CHECK_TIME=$EPOCHREALTIME _VCS_INFO_CURRENT_HEAD_CHECKED=true } # Update vcs_info before each prompt (with caching) precmd_vcs_info() { # Initialize vcs_info on first call _init_vcs_info # Use the HEAD and status that were checked once at the start of this prompt cycle local current_head="$_VCS_INFO_CURRENT_HEAD" local current_status="$_VCS_INFO_CURRENT_STATUS" local current_upstream="$_VCS_INFO_CURRENT_UPSTREAM" # Fast path: if PWD, HEAD, status, and upstream haven't changed, use cache if [[ "$PWD" == "$_VCS_INFO_CACHE_PWD" && "$current_head" == "$_VCS_INFO_CACHE_HEAD" && "$current_status" == "$_VCS_INFO_CACHE_STATUS" && "$current_upstream" == "$_VCS_INFO_CACHE_UPSTREAM" ]]; then vcs_info_msg_0_="$_VCS_INFO_CACHE" return fi # Guard: only regenerate once per cycle even if conditions changed if [[ "$_VCS_INFO_REGENERATED_THIS_CYCLE" == "true" ]]; then vcs_info_msg_0_="$_VCS_INFO_CACHE" return fi # Regenerate vcs_info vcs_info _VCS_INFO_CACHE="$vcs_info_msg_0_" _VCS_INFO_CACHE_PWD="$PWD" _VCS_INFO_CACHE_HEAD="$current_head" _VCS_INFO_CACHE_STATUS="$current_status" _VCS_INFO_CACHE_UPSTREAM="$current_upstream" _VCS_INFO_REGENERATED_THIS_CYCLE=true } precmd_functions+=( precmd_check_git_head precmd_vcs_info ) # ---------------------------------------------------------------------------- # Smart Git Fetch on Directory Change # ---------------------------------------------------------------------------- # Track current git repo typeset -g _CURRENT_GIT_REPO="" typeset -g _GIT_FETCH_IN_PROGRESS=false # Signal handler for async git fetch completion TRAPUSR1() { _GIT_FETCH_IN_PROGRESS=false # Clear all vcs_info cache to force complete refresh _VCS_INFO_CACHE="" _VCS_INFO_CACHE_PWD="" _VCS_INFO_CACHE_HEAD="" _VCS_INFO_CACHE_STATUS="" _VCS_INFO_CACHE_UPSTREAM="" _VCS_INFO_CURRENT_HEAD_CHECKED=false _VCS_INFO_CURRENT_HEAD="" _VCS_INFO_CURRENT_STATUS="" _VCS_INFO_CURRENT_UPSTREAM="" _VCS_INFO_REGENERATED_THIS_CYCLE=false # Force regeneration of vcs_info by calling precmd hooks precmd_check_git_head precmd_vcs_info # Reset prompt to show updated git info immediately if zle; then zle reset-prompt 2>/dev/null fi #echo -e "\r\033[0;90m[fetch complete]\033[0m" } # Function to run git fetch when entering new repo _auto_git_fetch() { [[ "$ZEAL_GIT_AUTO_FETCH" != "true" ]] && return # Quick check: if we're still in the cached repo, skip everything if [[ -n "$_CURRENT_GIT_REPO" && "$PWD" == "$_CURRENT_GIT_REPO"* ]]; then return fi # Check if we're in a git repo if git rev-parse --git-dir > /dev/null 2>&1; then local git_root=$(git rev-parse --show-toplevel 2>/dev/null) # Only fetch if this is a different repo than before if [[ "$git_root" != "$_CURRENT_GIT_REPO" ]]; then _CURRENT_GIT_REPO="$git_root" # Skip if a fetch is already in progress if [[ "$_GIT_FETCH_IN_PROGRESS" == "true" ]]; then return fi # Fetch only current branch asynchronously local current_branch=$(git branch --show-current 2>/dev/null) if [[ -n "$current_branch" ]]; then _GIT_FETCH_IN_PROGRESS=true #echo -e "\033[0;90m[fetching origin/$current_branch...]\033[0m" # Run fetch in background and signal parent when done local parent_pid=$$ ( git fetch origin "$current_branch" 2>/dev/null # Signal parent shell that fetch is complete kill -USR1 $parent_pid 2>/dev/null ) &! fi fi else # Not in a git repo anymore _CURRENT_GIT_REPO="" fi } # Defer git fetch to after prompt is displayed (non-blocking startup) # Schedule to run after current event loop zmodload zsh/sched sched +0 _auto_git_fetch # Powerline separator POWERLINE_SEPARATOR=$'\uE0B0' # # Prompt segments prompt_status() { local result="" # Show red X if last command failed if [[ $cmd_exit_code -ne 0 ]]; then result="%F{red}✗ %f" fi echo "$result" } prompt_user() { local user="%n" local host="%m" local default_user="$(stat -c '%U' "$HOME" 2>/dev/null || stat -f '%Su' "$HOME" 2>/dev/null)" # Only show username if not default user or in SSH if [[ "$USER" != "$default_user" || -n "$SSH_CLIENT" ]]; then echo "%F{white}%K{black} ${user}@${host} %k%f" fi } # Shorten path: keep only first char of each dir except last (fish-style) _shorten_path() { local path="${PWD/#$HOME/~}" local -a path_parts path_parts=("${(@s:/:)path}") # If only one or zero parts, return as-is if [[ ${#path_parts[@]} -le 1 ]]; then echo "$path" return fi local -a shortened # Process all parts except the last for i in {1..$(( ${#path_parts[@]} - 1 ))}; do if [[ -n "${path_parts[$i]}" ]]; then shortened+=("${path_parts[$i]:0:1}") else # Handle leading slash for absolute paths shortened+=("") fi done # Add the last part in full shortened+=("${path_parts[-1]}") echo "${(j:/:)shortened}" } prompt_dir() { local dir_display if [[ "$ZEAL_SHORTEN_PATH" == "true" ]]; then dir_display="$(_shorten_path)" else dir_display="${PWD/#$HOME/~}" fi local result="%K{blue}%F{black} ${dir_display} %k%f" echo "$result" } prompt_git() { local result if [[ -n ${vcs_info_msg_0_} ]]; then # Blue triangle on green background for transition, then git info, then green triangle result="%K{green}%F{blue}${POWERLINE_SEPARATOR}%f%F{black}${vcs_info_msg_0_}%k%f%F{green}${POWERLINE_SEPARATOR}%f" else # No git branch - just blue triangle result="%F{blue}${POWERLINE_SEPARATOR}%f" fi echo "$result" } # Build prompt PROMPT='$(prompt_status)$(prompt_user)$(prompt_dir)$(prompt_git) ' # Right prompt with execution time and clock _build_rprompt() { local parts="" [[ "$ZEAL_SHOW_EXEC_TIME" == "true" ]] && parts+='${cmd_exec_time:+took $cmd_exec_time}' [[ "$ZEAL_SHOW_TIMESTAMP" == "true" ]] && parts+=' %D{%H:%M:%S}' echo "%F{green}${parts}%f" } RPROMPT="$(_build_rprompt)" # ---------------------------------------------------------------------------- # Initialize Contextual History # ---------------------------------------------------------------------------- # Defer loading contextual history to after prompt is displayed (non-blocking startup) # This improves perceived startup time significantly sched +0 _load_contextual_history_async