Zsh prompt (#67)

* feat: initial prompt pass

* fix: code quality

* fix: perf

* fix: layout

* fix: cleanup

* fix: perf

* feat: nvm/pyenv lazy-load

* fix: profile eager load

* fix: caching

* fix: spelling

* fix: compinit/spelling

* fix: feedback

* fix: comments

* Remove TRAPINT/flash customization, use stock Ctrl+C behavior

* feat: re-introduce flash

* fix: error display

* feat: clear suggestions on ctrl+c

* feat: grey prompt indicator

* feat: git

* feat: git spacing

* feat: async git

* feat: git perf

* fix: var names

* fix: exits
This commit is contained in:
2025-12-19 15:55:06 +02:00
committed by GitHub
parent c21270a35d
commit 574384ab69
3 changed files with 356 additions and 106 deletions

View File

@@ -2,6 +2,8 @@ alias bench='ZSH_BENCH=1 exec zsh'
alias dots='cd $DOTFILES' alias dots='cd $DOTFILES'
alias j='z' alias j='z'
alias reload='source ~/.zshrc' alias reload='source ~/.zshrc'
alias reload-path='rm -f "${XDG_CACHE_HOME:-$HOME/.cache}/dots/path" && exec zsh'
alias reload-cache='rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}/dots" ~/.zcompdump* && exec zsh'
alias zen='curl -s https://api.github.com/zen && echo' alias zen='curl -s https://api.github.com/zen && echo'
alias la='ls -la' alias la='ls -la'
alias colby='copilot --allow-all-tools --allow-all-paths --banner' alias colby='copilot --allow-all-tools --allow-all-paths --banner'

View File

@@ -1,73 +1,29 @@
# xdg data & config # Environment
# ----------------------------------------------------------------- export XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
export XDG_DATA_HOME=${XDG_DATA_HOME:-"$HOME/.local/share"} export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
mkdir -p "$XDG_DATA_HOME" export WORKSPACE="${WORKSPACE:-$HOME/Workspace}"
export XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-"$HOME/.config"} export DOTFILES="${DOTFILES:-$HOME/.dotfiles}"
mkdir -p "$XDG_CONFIG_HOME"
# local user binaries # Tool roots
# ----------------------------------------------------------------- export NVM_DIR="${NVM_DIR:-$HOME/.config/nvm}"
if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then export PYENV_ROOT="${PYENV_ROOT:-$HOME/.pyenv}"
export PATH="$HOME/.local/bin:$PATH" export POETRY_ROOT="${POETRY_ROOT:-$HOME/.poetry}"
export HOMEBREW_NO_ANALYTICS=1
# PATH setup with caching
_dots_path_cache="${XDG_CACHE_HOME:-$HOME/.cache}/dots/path"
if [[ -f "$_dots_path_cache" && "$_dots_path_cache" -nt ~/.profile ]]; then
export PATH="$(cat "$_dots_path_cache")"
else
[[ ":$PATH:" != *":$HOME/.local/bin:"* ]] && export PATH="$HOME/.local/bin:$PATH"
[[ ":$PATH:" != *":$PYENV_ROOT/shims:"* ]] && export PATH="$PYENV_ROOT/shims:$PATH"
[[ ":$PATH:" != *":$PYENV_ROOT/bin:"* ]] && export PATH="$PYENV_ROOT/bin:$PATH"
[[ ":$PATH:" != *":$POETRY_ROOT/bin:"* ]] && export PATH="$POETRY_ROOT/bin:$PATH"
[[ -x "/opt/homebrew/bin/brew" && ":$PATH:" != *":/opt/homebrew/bin:"* ]] && export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:$PATH"
[ -f "$NVM_DIR/alias/lts/jod" ] && export PATH="$NVM_DIR/versions/node/$(cat "$NVM_DIR/alias/lts/jod")/bin:$PATH"
# Cache the result
mkdir -p "$(dirname "$_dots_path_cache")"
echo "$PATH" > "$_dots_path_cache"
fi fi
mkdir -p "$HOME/.local/bin" unset _dots_path_cache
# workspace
# -----------------------------------------------------------------
export WORKSPACE=${WORKSPACE:-"$HOME/Workspace"}
mkdir -p "$WORKSPACE"
# dotfiles
# -----------------------------------------------------------------
export DOTFILES=${DOTFILES:-"$HOME/.dotfiles"}
# Initialise and load Node
# -----------------------------------------------------------------
export NVM_DIR=${NVM_DIR:-"$HOME/.nvm"}
mkdir -p "$NVM_DIR"
_dots_load_nvm() {
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" --no-use
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
}
_dots_load_nvm
node_alias="$NVM_DIR/alias/lts/jod"
if [ -f "$node_alias" ]; then
VERSION=$(cat "$node_alias")
node_bin_path="$NVM_DIR/versions/node/$VERSION/bin"
if [[ ":$PATH:" != *":$node_bin_path:"* ]]; then
export PATH="$node_bin_path:$PATH"
fi
fi
unset node_alias VERSION node_bin_path
# Initialise and load Python
# -----------------------------------------------------------------
export PYENV_ROOT=${PYENV_ROOT:-"$HOME/.pyenv"}
if [[ ":$PATH:" != *":$PYENV_ROOT/bin:"* ]]; then
export PATH="$PYENV_ROOT/bin:$PATH"
fi
_dots_load_pyenv() {
[ -x $(command -v pyenv) ] && eval "$(pyenv init --path)"
}
_dots_load_pyenv
export POETRY_ROOT=${POETRY_ROOT:-"$HOME/.poetry"}
if [[ ":$PATH:" != *":$POETRY_ROOT/bin:"* ]]; then
export PATH="$POETRY_ROOT/bin:$PATH"
fi
# aliases
# -----------------------------------------------------------------
if [ -f ~/.aliases ]; then
source ~/.aliases
fi
# Load homebrew
# -----------------------------------------------------------------------------
_dots_load_brew() {
export HOMEBREW_NO_ANALYTICS=1
[ -x "/opt/homebrew/bin/brew" ] && eval "$(/opt/homebrew/bin/brew shellenv)"
}
_dots_load_brew

View File

@@ -1,55 +1,347 @@
# Prefix all functions with "_dots" for easier profiling # Profiling: ZSH_BENCH=1 zsh
# ----------------------------------------------------------------------------- [[ -n "$ZSH_BENCH" ]] && zmodload zsh/zprof
if [[ -n "$ZSH_BENCH" ]]; then
zmodload zsh/zprof
fi
# Load profile _dots_cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/dots"
# -----------------------------------------------------------------------------
_dots_load_profile() { _dots_load_profile() { source "$HOME/.profile" }
source "$HOME/.profile"
}
_dots_load_profile _dots_load_profile
# Load oh-my-zsh _dots_setup_dirs() {
# ----------------------------------------------------------------------------- mkdir -p "$XDG_DATA_HOME" "$XDG_CONFIG_HOME" "$HOME/.local/bin" "$WORKSPACE" "$NVM_DIR" "$_dots_cache_dir"
}
_dots_setup_dirs
_dots_cache_ls_colors() {
local cache_file="$_dots_cache_dir/ls-colours"
if [[ -f "$cache_file" ]]; then
source "$cache_file"
else
if ls --color -d . &>/dev/null; then
echo 'alias ls="ls --color=auto"' > "$cache_file"
elif ls -G -d . &>/dev/null; then
echo 'alias ls="ls -G"' > "$cache_file"
fi
[[ -f "$cache_file" ]] && source "$cache_file"
fi
}
_dots_cache_ls_colors
[[ -f ~/.aliases ]] && source ~/.aliases
_dots_load_omz() { _dots_load_omz() {
export DISABLE_AUTO_UPDATE="true" export DISABLE_AUTO_UPDATE="true"
export DISABLE_LS_COLORS="true"
export ZSH="$HOME/.oh-my-zsh" export ZSH="$HOME/.oh-my-zsh"
export ZSH_THEME=""
plugins=( plugins=(
z z
zsh-autosuggestions zsh-autosuggestions
zsh-syntax-highlighting zsh-syntax-highlighting
) )
# Daily security check: skip compaudit if already checked today
local marker="$_dots_cache_dir/.compaudit_checked"
local today=$(date +'%Y-%j')
if [[ -f "$marker" ]] && [[ "$(cat "$marker" 2>/dev/null)" == "$today" ]]; then
export ZSH_DISABLE_COMPFIX="true"
else
echo "$today" > "$marker"
fi
source "$ZSH/oh-my-zsh.sh" source "$ZSH/oh-my-zsh.sh"
} }
_dots_load_omz _dots_load_omz
# Build shell prompt # Prompt
# ----------------------------------------------------------------------------- (( ${+PROMPT_MIN_DURATION} )) || typeset -gi PROMPT_MIN_DURATION=2 # show duration after N seconds
_dots_build_prompt() { (( ${+PROMPT_FLASH_DELAY} )) || typeset -gi PROMPT_FLASH_DELAY=4 # flash prompt for N centiseconds
local final_prompt=""
local user_host="%{$fg_bold[green]%}%n@%m%{$reset_color%}" typeset -gi _dots_prompt_cmd_start=0
final_prompt+="$user_host " typeset -gi _dots_prompt_cmd_ran=0
typeset -gi _dots_prompt_flashing=0
typeset -g _dots_prompt_base=""
typeset -gA _dots_pc
local dir_section="%{$fg_bold[blue]%}%~" _dots_init_colors() {
final_prompt+="$dir_section " if [[ "$COLORTERM" == (truecolor|24bit) ]]; then
_dots_pc=(
local prompt_char="\λ" teal $'%{\e[38;2;44;180;148m%}'
local prompt_suffix="%{$reset_color%}%${prompt_char}%{$reset_color%}" orange $'%{\e[38;2;248;140;20m%}'
final_prompt+="$prompt_suffix " red $'%{\e[38;2;244;4;4m%}'
grey $'%{\e[38;2;114;144;184m%}'
PROMPT="$final_prompt" )
elif [[ "$TERM" == *256color* ]]; then
_dots_pc=(
teal $'%{\e[38;5;43m%}'
orange $'%{\e[38;5;208m%}'
red $'%{\e[38;5;196m%}'
grey $'%{\e[38;5;103m%}'
)
else
_dots_pc=(
teal $'%{\e[36m%}'
orange $'%{\e[33m%}'
red $'%{\e[31m%}'
grey $'%{\e[34m%}'
)
fi
_dots_pc[reset]=$'%{\e[0m%}'
_dots_pc[bold]=$'%{\e[1m%}'
} }
_dots_build_prompt
# Finish bench profiling _dots_abbrev_path() {
# ----------------------------------------------------------------------------- local dir="${PWD/#$HOME/~}"
if [[ -n "$ZSH_BENCH" ]]; then local -a parts=( "${(@s:/:)dir}" )
zprof local count=${#parts[@]}
fi
export NVM_DIR="$HOME/.config/nvm" (( count <= 3 )) && { print -r -- "$dir"; return }
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion local result=""
local i
for (( i=1; i <= count-3; i++ )); do
result+="${parts[i][1]}/"
done
print -r -- "${result}${parts[-3]}/${parts[-2]}/${parts[-1]}"
}
_dots_session() {
[[ -n "$CODESPACE_NAME" ]] && { print -r -- "$CODESPACE_NAME"; return }
[[ -n "$SSH_CONNECTION" || -n "$SSH_CLIENT" || -n "$SSH_TTY" ]] && { print -r -- "%n@%m"; return }
[[ -f /.dockerenv ]] && { print -r -- "${DEVCONTAINER_ID:-$(</etc/hostname)}"; return }
}
_dots_git_info_sync() {
local branch="" ahead=0 behind=0 staged=0 unstaged=0 untracked=0
local line
while IFS= read -r line; do
case "$line" in
"# branch.head "*)
branch="${line#\# branch.head }"
[[ "$branch" == "(detached)" ]] && branch=""
;;
"# branch.oid "*)
# Use short oid for detached HEAD
[[ -z "$branch" ]] && branch="${${line#\# branch.oid }:0:7}"
;;
"# branch.ab "*)
local ab="${line#\# branch.ab }"
ahead="${ab%% *}"; ahead="${ahead#+}"
behind="${ab##* }"; behind="${behind#-}"
;;
"1 "*) # changed entry
[[ "${line:2:1}" != "." ]] && (( staged++ ))
[[ "${line:3:1}" != "." ]] && (( unstaged++ ))
;;
"2 "*) # renamed/copied
[[ "${line:2:1}" != "." ]] && (( staged++ ))
[[ "${line:3:1}" != "." ]] && (( unstaged++ ))
;;
"? "*) # untracked
(( untracked++ ))
;;
"u "*) # unmerged
(( staged++ ))
;;
esac
done < <(git status --porcelain=v2 --branch 2>/dev/null)
[[ -z "$branch" ]] && return
local info="${_dots_pc[grey]}(${branch})${_dots_pc[reset]}"
local dirty=""
(( staged )) && dirty+="${_dots_pc[teal]}+${staged}${_dots_pc[reset]}"
(( unstaged )) && dirty+="${_dots_pc[orange]}~${unstaged}${_dots_pc[reset]}"
(( untracked )) && dirty+="${_dots_pc[grey]}?${untracked}${_dots_pc[reset]}"
[[ -n "$dirty" ]] && info+=" ${dirty}"
local arrows=""
(( ahead )) && arrows+="${_dots_pc[teal]}${ahead}${_dots_pc[reset]}"
(( behind )) && arrows+="${_dots_pc[orange]}${behind}${_dots_pc[reset]}"
[[ -n "$arrows" ]] && info+=" ${arrows}"
print -r -- "$info"
}
# Async git info
typeset -g _dots_git_info_result=""
typeset -g _dots_git_info_pwd=""
typeset -g _dots_git_async_fd=""
_dots_git_async_callback() {
local fd=$1
_dots_git_info_result=""
# Use sysread for efficient non-blocking read from fd
if [[ -n "$fd" ]] && sysread -i "$fd" _dots_git_info_result 2>/dev/null; then
_dots_git_info_result="${_dots_git_info_result%$'\n'}" # trim trailing newline
_dots_build_dots_prompt_base
PROMPT="$_dots_prompt_base"
zle && zle reset-prompt
fi
# Clean up
exec {fd}<&-
zle -F "$fd" 2>/dev/null
_dots_git_async_fd=""
}
_dots_git_async_start() {
# Cancel any pending async job
if [[ -n "$_dots_git_async_fd" ]]; then
exec {_dots_git_async_fd}<&- 2>/dev/null
zle -F "$_dots_git_async_fd" 2>/dev/null
_dots_git_async_fd=""
fi
# Clear result on directory change
if [[ "$PWD" != "$_dots_git_info_pwd" ]]; then
_dots_git_info_result=""
_dots_git_info_pwd="$PWD"
fi
# Start background job
exec {_dots_git_async_fd}< <(
_dots_git_info_sync
)
zle -F "$_dots_git_async_fd" _dots_git_async_callback
}
_dots_build_dots_prompt_base() {
local dir_path="$(_dots_abbrev_path)"
local symbol="${_dots_pc[grey]}>${_dots_pc[reset]}"
(( EUID == 0 )) && symbol="${_dots_pc[orange]}${_dots_pc[bold]}#${_dots_pc[reset]}"
local line1="${_dots_pc[teal]}${dir_path}${_dots_pc[reset]}"
[[ -n "$_dots_git_info_result" ]] && line1+=" ${_dots_git_info_result}"
_dots_prompt_base=$'\n'"${line1}"$'\n'"${symbol} "
}
_dots_preexec() {
_dots_prompt_cmd_start=$EPOCHSECONDS
_dots_prompt_cmd_ran=1
}
_dots_precmd() {
local e=$? d=0
# Only show exit code if a command actually ran
(( _dots_prompt_cmd_ran )) || e=0
_dots_prompt_cmd_ran=0
# First prompt should never show error from shell init
[[ -z "$_dots_first_prompt" ]] && { _dots_first_prompt=1; e=0; }
# Build RPROMPT: time, error, host
local rp_parts=()
if (( _dots_prompt_cmd_start )); then
d=$(( EPOCHSECONDS - _dots_prompt_cmd_start ))
_dots_prompt_cmd_start=0
if (( d >= PROMPT_MIN_DURATION )); then
(( d >= 60 )) && rp_parts+=("${_dots_pc[grey]}($(( d/60 ))m$(( d%60 ))s)${_dots_pc[reset]}") \
|| rp_parts+=("${_dots_pc[grey]}(${d}s)${_dots_pc[reset]}")
fi
fi
(( e )) && rp_parts+=("${_dots_pc[red]}[${e}]${_dots_pc[reset]}")
local session="$(_dots_session)"
[[ -n "$session" ]] && rp_parts+=("${_dots_pc[orange]}[${session}]${_dots_pc[reset]}")
RPROMPT="${(j: :)rp_parts}"
# Clear git info on directory change before building prompt
[[ "$PWD" != "$_dots_git_info_pwd" ]] && _dots_git_info_result=""
_dots_build_dots_prompt_base
_dots_git_async_start
PROMPT="$_dots_prompt_base"
}
TRAPINT() {
# Only customize when ZLE is active (at prompt, not during command)
if [[ -o zle ]] && [[ -o interactive ]] && (( ${+WIDGET} )); then
if [[ -z "$BUFFER" ]] && (( ! _dots_prompt_flashing )); then
# Empty buffer: flash the prompt symbol
_dots_prompt_flashing=1
local git_part=""
[[ -n "$_dots_git_info_result" ]] && git_part=" ${_dots_git_info_result}"
local flash_prompt=$'\n'"${_dots_pc[teal]}$(_dots_abbrev_path)${_dots_pc[reset]}${git_part}"$'\n'$'%{\e[48;2;248;140;20m\e[30m%}> %{\e[0m%}'
PROMPT="$flash_prompt"
zle reset-prompt
zselect -t $PROMPT_FLASH_DELAY
_dots_prompt_flashing=0
PROMPT="$_dots_prompt_base"
zle reset-prompt
return 0
elif [[ -n "$BUFFER" ]]; then
# Buffer has content: clear autosuggest, then default behavior
zle autosuggest-clear 2>/dev/null
fi
fi
# Propagate signal: use special return code -1 to let zsh handle normally
return $((128 + ${1:-2}))
}
_dots_prompt_init() {
zmodload zsh/datetime 2>/dev/null
zmodload zsh/zselect 2>/dev/null
zmodload zsh/system 2>/dev/null
_dots_init_colors
_dots_build_dots_prompt_base
setopt PROMPT_SUBST EXTENDED_HISTORY INC_APPEND_HISTORY_TIME
autoload -Uz add-zsh-hook
add-zsh-hook preexec _dots_preexec
add-zsh-hook precmd _dots_precmd
add-zsh-hook chpwd _dots_build_dots_prompt_base
PROMPT="$_dots_prompt_base" RPROMPT=""
}
_dots_prompt_init
# Lazy loading
_dots_init_nvm() {
unfunction nvm node npm npx yarn pnpm corepack 2>/dev/null
[[ -s "$NVM_DIR/nvm.sh" ]] && source "$NVM_DIR/nvm.sh"
[[ -s "$NVM_DIR/bash_completion" ]] && source "$NVM_DIR/bash_completion"
}
_dots_load_nvm_lazy() {
local -a nvm_cmds=(nvm node npm npx yarn pnpm corepack)
for cmd in "${nvm_cmds[@]}"; do
eval "${cmd}() { _dots_init_nvm; ${cmd} \"\$@\" }"
done
}
_dots_init_pyenv() {
unfunction pyenv python python3 pip pip3 poetry pipx 2>/dev/null
if command -v pyenv &>/dev/null; then
eval "$(pyenv init -)"
eval "$(pyenv virtualenv-init -)" 2>/dev/null
fi
}
_dots_load_pyenv_lazy() {
local -a pyenv_cmds=(pyenv python python3 pip pip3 poetry pipx)
for cmd in "${pyenv_cmds[@]}"; do
eval "${cmd}() { _dots_init_pyenv; ${cmd} \"\$@\" }"
done
}
_dots_setup_lazy_completions() {
compdef '_dots_init_nvm; _npm' npm 2>/dev/null
compdef '_dots_init_nvm; _node' node 2>/dev/null
compdef '_dots_init_pyenv; _pip' pip 2>/dev/null
compdef '_dots_init_pyenv; _python' python 2>/dev/null
}
_dots_lazy_init() {
_dots_load_nvm_lazy
_dots_load_pyenv_lazy
_dots_setup_lazy_completions
}
_dots_lazy_init
[[ -n "$ZSH_BENCH" ]] && zprof || true