From 574384ab69e923a921ecf146b5d4da0a21b4dc40 Mon Sep 17 00:00:00 2001 From: Andrejus Kostarevas Date: Fri, 19 Dec 2025 15:55:06 +0200 Subject: [PATCH] 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 --- files/home/.aliases | 2 + files/home/.profile | 98 ++++-------- files/home/.zshrc | 362 +++++++++++++++++++++++++++++++++++++++----- 3 files changed, 356 insertions(+), 106 deletions(-) diff --git a/files/home/.aliases b/files/home/.aliases index 985bee4..1427b37 100644 --- a/files/home/.aliases +++ b/files/home/.aliases @@ -2,6 +2,8 @@ alias bench='ZSH_BENCH=1 exec zsh' alias dots='cd $DOTFILES' alias j='z' 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 la='ls -la' alias colby='copilot --allow-all-tools --allow-all-paths --banner' diff --git a/files/home/.profile b/files/home/.profile index 70c5c6b..b9d4b3e 100644 --- a/files/home/.profile +++ b/files/home/.profile @@ -1,73 +1,29 @@ -# xdg data & config -# ----------------------------------------------------------------- -export XDG_DATA_HOME=${XDG_DATA_HOME:-"$HOME/.local/share"} -mkdir -p "$XDG_DATA_HOME" -export XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-"$HOME/.config"} -mkdir -p "$XDG_CONFIG_HOME" +# Environment +export XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" +export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +export WORKSPACE="${WORKSPACE:-$HOME/Workspace}" +export DOTFILES="${DOTFILES:-$HOME/.dotfiles}" -# local user binaries -# ----------------------------------------------------------------- -if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then - export PATH="$HOME/.local/bin:$PATH" +# Tool roots +export NVM_DIR="${NVM_DIR:-$HOME/.config/nvm}" +export PYENV_ROOT="${PYENV_ROOT:-$HOME/.pyenv}" +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 -mkdir -p "$HOME/.local/bin" - -# 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 +unset _dots_path_cache diff --git a/files/home/.zshrc b/files/home/.zshrc index 3e9b6c9..cda50d0 100644 --- a/files/home/.zshrc +++ b/files/home/.zshrc @@ -1,55 +1,347 @@ -# Prefix all functions with "_dots" for easier profiling -# ----------------------------------------------------------------------------- -if [[ -n "$ZSH_BENCH" ]]; then - zmodload zsh/zprof -fi +# Profiling: ZSH_BENCH=1 zsh +[[ -n "$ZSH_BENCH" ]] && zmodload zsh/zprof -# Load profile -# ----------------------------------------------------------------------------- -_dots_load_profile() { - source "$HOME/.profile" -} +_dots_cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/dots" + +_dots_load_profile() { source "$HOME/.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() { export DISABLE_AUTO_UPDATE="true" + export DISABLE_LS_COLORS="true" export ZSH="$HOME/.oh-my-zsh" + export ZSH_THEME="" plugins=( z zsh-autosuggestions 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" } _dots_load_omz -# Build shell prompt -# ----------------------------------------------------------------------------- -_dots_build_prompt() { - local final_prompt="" +# Prompt +(( ${+PROMPT_MIN_DURATION} )) || typeset -gi PROMPT_MIN_DURATION=2 # show duration after N seconds +(( ${+PROMPT_FLASH_DELAY} )) || typeset -gi PROMPT_FLASH_DELAY=4 # flash prompt for N centiseconds - local user_host="%{$fg_bold[green]%}%n@%m%{$reset_color%}" - final_prompt+="$user_host " +typeset -gi _dots_prompt_cmd_start=0 +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]%}%~" - final_prompt+="$dir_section " - - local prompt_char="\λ" - local prompt_suffix="%{$reset_color%}%${prompt_char}%{$reset_color%}" - final_prompt+="$prompt_suffix " - - PROMPT="$final_prompt" +_dots_init_colors() { + if [[ "$COLORTERM" == (truecolor|24bit) ]]; then + _dots_pc=( + teal $'%{\e[38;2;44;180;148m%}' + orange $'%{\e[38;2;248;140;20m%}' + red $'%{\e[38;2;244;4;4m%}' + grey $'%{\e[38;2;114;144;184m%}' + ) + 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 -# ----------------------------------------------------------------------------- -if [[ -n "$ZSH_BENCH" ]]; then - zprof -fi +_dots_abbrev_path() { + local dir="${PWD/#$HOME/~}" + local -a parts=( "${(@s:/:)dir}" ) + local count=${#parts[@]} + + (( count <= 3 )) && { print -r -- "$dir"; return } + + 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]}" +} -export NVM_DIR="$HOME/.config/nvm" -[ -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 +_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:-$(/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