# Interactive zsh configuration. # ── Terminal ────────────────────────────────────────────────────────────────── stty -ixon # disable XON/XOFF flow control (frees Ctrl-S/Ctrl-Q) ttyctl -f # freeze terminal state; programs can't leave it broken # ── Options ─────────────────────────────────────────────────────────────────── # Note: appendhistory, nomatch, notify are zsh defaults — not set here. setopt autocd # cd by typing directory name setopt extendedglob # extended glob patterns (#, ~, ^) setopt interactivecomments # allow # comments in interactive shell setopt rmstarsilent # don't confirm rm * setopt prompt_subst # expand variables/functions in prompt setopt auto_pushd # cd pushes old dir onto stack (cd - to browse) setopt pushd_ignore_dups # don't push duplicate dirs onto stack unsetopt beep # no terminal bell # ── History ─────────────────────────────────────────────────────────────────── HISTFILE="$XDG_STATE_HOME/zsh/history" HISTSIZE=50000 SAVEHIST=50000 setopt extended_history # save timestamp and duration per entry setopt share_history # share history across concurrent sessions setopt hist_ignore_all_dups # remove older duplicate when adding new entry setopt hist_find_no_dups # skip duplicates when searching history setopt hist_reduce_blanks # trim superfluous whitespace from entries setopt hist_ignore_space # commands starting with space are not saved # ── Emacs keybindings ───────────────────────────────────────────────────────── bindkey -e # ── Prompt ──────────────────────────────────────────────────────────────────── autoload -Uz colors && colors # git-prompt.sh: distro-managed on Arch, nix-store-managed on the VM for _f in \ /usr/share/git/completion/git-prompt.sh \ $HOME/.nix-profile/share/git/contrib/completion/git-prompt.sh; do [[ -r $_f ]] && { source "$_f"; break; } done unset _f PROMPT='%B%{$fg[green]%}%n%{$reset_color%}@%{$fg[cyan]%}%m%{$reset_color%}:%b%{$fg[yellow]%}%~%{$reset_color%}$(__git_ps1 " (%s)")%(?..[%{$fg[red]%}%?%{$reset_color%}]) %(!.#.>) ' # ── Completion ──────────────────────────────────────────────────────────────── fpath=($XDG_DATA_HOME/zsh/completion $fpath) # Pick up completions shipped by nix-installed packages on the remote-dev VM # (Ubuntu's system zsh doesn't add the nix-profile share dirs to fpath). for _d in "$HOME/.nix-profile/share/zsh/site-functions" \ "$HOME/.nix-profile/share/zsh/vendor-completions"; do [[ -d $_d ]] && fpath=($_d $fpath) done unset _d autoload -Uz compinit && compinit -d "$XDG_CACHE_HOME/zsh/zcompdump" zstyle ':completion:*' menu select # arrow-key driven menu for ambiguous completions zstyle ':completion:*' completer _expand_alias _complete _ignored _match _approximate # │ │ │ │ └ fuzzy match (typo tolerance) # │ │ │ └ try pattern matching # │ │ └ include normally hidden completions # │ └ standard completion # └ expand aliases before completing zstyle ':completion:*:default' list-colors ${(s.:.)LS_COLORS} # colorize file completions like ls zstyle ':completion:*' use-cache on # cache completions (speeds up pip, dpkg, etc.) zstyle ':completion:*' cache-path "$XDG_CACHE_HOME/zsh" zstyle ':completion:*' rehash true # rebuild PATH hash on every completion (catches paru, cargo, pip, manual installs) zstyle ':completion:*:match:*' original only # only show original when pattern-matching zstyle ':completion:*:functions' ignored-patterns '_*' # hide internal completion functions zstyle ':completion:*:*:kill:*' menu yes select # interactive menu for kill completion zstyle ':completion:*:kill:*' force-list always # always show process list for kill zstyle ':completion:*:cd:*' ignore-parents parent pwd # cd never completes . or .. zstyle ':completion::complete:*' gain-privileges 1 # use sudo for privileged completions zstyle -e ':completion:*:approximate:*' \ max-errors 'reply=($((($#PREFIX+$#SUFFIX)/3))numeric)' # allow 1 typo per 3 chars typed _comp_options+=(globdots) # include hidden files in completion # ── Terminal key setup ───────────────────────────────────────────────────────── # Application mode ensures terminfo values are valid during line editing. # Without this, some terminals send wrong sequences for special keys. if (( ${+terminfo[smkx]} && ${+terminfo[rmkx]} )); then autoload -Uz add-zle-hook-widget # shellcheck disable=all # zsh brace-group-as-function-body isn't bash syntax function zle_application_mode_start { echoti smkx } function zle_application_mode_stop { echoti rmkx } add-zle-hook-widget -Uz zle-line-init zle_application_mode_start add-zle-hook-widget -Uz zle-line-finish zle_application_mode_stop fi # Up/Down stored for history-substring-search bindings (set after plugin source) typeset -g -A key key[Up]="${terminfo[kcuu1]}" key[Down]="${terminfo[kcud1]}" # ── Custom keybindings ──────────────────────────────────────────────────────── bindkey \^U backward-kill-line # Word navigation (Ctrl-Right also accepts autosuggestion word-by-word — fish-like) bindkey '^[[1;5C' forward-word # Ctrl-Right bindkey '^[[1;5D' backward-word # Ctrl-Left bindkey '^[[1;3C' forward-word # Alt-Right bindkey '^[[1;3D' backward-word # Alt-Left bindkey '^H' backward-kill-word # Ctrl-Backspace bindkey '^[[3;5~' kill-word # Ctrl-Delete # Ctrl-Z: toggle foreground/background (no need to type 'fg') toggle-fg-bg() { if (( ${#jobstates} )); then zle .push-input BUFFER="fg" zle .accept-line else zle .push-input zle .clear-screen fi } zle -N toggle-fg-bg bindkey '^Z' toggle-fg-bg # Ctrl-D exits even on non-empty line exit_zsh() { exit } zle -N exit_zsh bindkey '^D' exit_zsh # Ctrl-X Ctrl-E: edit command in $EDITOR autoload -Uz edit-command-line zle -N edit-command-line bindkey "^X^E" edit-command-line # Ctrl-Y: copy current command line to clipboard (OSC 52 — terminal-native) copy-line-to-clipboard() { printf '\033]52;c;%s\a' "$(echo -n "$BUFFER" | base64)" } zle -N copy-line-to-clipboard bindkey '^Y' copy-line-to-clipboard # ── Word style ──────────────────────────────────────────────────────────────── # Ctrl-W/Alt-B/Alt-F use shell quoting rules for word boundaries autoload -Uz select-word-style select-word-style shell # ── Smart dot expansion ─────────────────────────────────────────────────────── # Typing .. automatically expands: ... → ../.. , .... → ../../.. , etc. rationalise-dot() { if [[ $LBUFFER = *.. ]]; then LBUFFER+=/.. else LBUFFER+=. fi } zle -N rationalise-dot bindkey . rationalise-dot # ── Window title ────────────────────────────────────────────────────────────── autoload -Uz add-zsh-hook xterm_title_precmd() { print -Pn -- '\e]2;%~\a' } xterm_title_preexec() { print -Pn -- '\e]2;%~ %# ' && print -n -- "${(q)1}\a" } if [[ "$TERM" == (xterm-ghostty|st*|screen*|xterm*|rxvt*|tmux*|putty*|konsole*|gnome*) ]]; then add-zsh-hook -Uz precmd xterm_title_precmd add-zsh-hook -Uz preexec xterm_title_preexec fi # ── Zellij tab naming (dir:cmd, no position prefix) ────────────────────────── # Zellij's default "Tab #N" name is baked in at tab creation (the N is the # immutable creation index, not the live position) and never updates when # tabs are closed or moved — so the default is actively misleading. We rename # to `dir:cmd` from the shell hooks; position is implied by visual order in # the tab bar. # # `zellij action rename-tab` always targets the *focused* tab (no way to # target by pane id from the CLI), and on session resurrect every shell # fires precmd nearly simultaneously while one tab is focused — without a # guard, every pane would rename that one tab and the last writer wins, # leaving every other tab stuck at "Tab #N". So we only rename when our # pane is currently focused. Background panes' labels update lazily the # next time the user focuses them and triggers a prompt. if [[ -n "$ZELLIJ" ]]; then _zellij_dir() { [[ "$PWD" == "$HOME" ]] && echo '~' || echo "${PWD##*/}"; } _zellij_is_focused_pane() { [[ -n $ZELLIJ_PANE_ID ]] || return 1 local focused focused=$(zellij action list-clients 2>/dev/null | awk 'NR>1 {print $2}') [[ -n $focused ]] && print -r -- "$focused" | grep -qx "terminal_${ZELLIJ_PANE_ID}" } _zellij_tab_precmd() { _zellij_is_focused_pane && zellij action rename-tab "$(_zellij_dir)" 2>/dev/null; } _zellij_tab_preexec() { _zellij_is_focused_pane && zellij action rename-tab "$(_zellij_dir):${1%% *}" 2>/dev/null; } add-zsh-hook precmd _zellij_tab_precmd add-zsh-hook preexec _zellij_tab_preexec fi # ── Recent directories ──────────────────────────────────────────────────────── autoload -Uz chpwd_recent_dirs cdr add-zsh-hook chpwd chpwd_recent_dirs [[ -d ${XDG_STATE_HOME}/zsh ]] || mkdir -p "${XDG_STATE_HOME}/zsh" zstyle ':chpwd:*' recent-dirs-file "$XDG_STATE_HOME/zsh/chpwd-recent-dirs" zstyle ':completion:*:*:cdr:*:*' menu selection # ── OSC 7 — report CWD to terminal (zellij uses this for new pane/tab CWD) ── _osc7_chpwd() { printf '\e]7;file://%s%s\e\\' "${HOST}" "${PWD}" } add-zsh-hook chpwd _osc7_chpwd _osc7_chpwd # ── Help system ─────────────────────────────────────────────────────────────── autoload -Uz run-help run-help-git run-help-ip (( $+aliases[run-help] )) && unalias run-help alias help=run-help # ── Bracketed paste ─────────────────────────────────────────────────────────── autoload -Uz bracketed-paste-magic zle -N bracketed-paste bracketed-paste-magic # ── Aliases ─────────────────────────────────────────────────────────────────── # Files alias l='lsd -l' alias la='lsd -lA' alias lt='lsd --tree' alias mkdir='mkdir -p' alias du='du -h' alias df='df -h' alias free='free -h' # Grep / diff with color alias grep='grep --color=auto' alias fgrep='grep -F --color=auto' alias egrep='grep -E --color=auto' alias diff='diff --color=auto' alias dmesg='dmesg --color=auto' alias dm='dmesg --color=always | less -r' # Networking alias ip="ip -color=auto" alias lsip="ip -human -color=auto --brief address show" alias ipa="ip -stats -details -human -color=auto address show" alias ipecho='curl ipecho.net/plain' alias ss='sudo ss -tupnl' # Privilege escalation alias gimme='sudo chown $USER:$(id -gn $USER)' alias pacdiff='sudo pacdiff' # Pacman # List packages installed only because they are someone's *optional* dep # (no package strictly requires them), and for each one show the parent # package(s) that list it as an Optional Dep along with the upstream reason. pacopt() { local -a leaves leaves=( ${(f)"$(comm -13 <(pacman -Qqdt | sort) <(pacman -Qqdtt | sort))"} ) (( ${#leaves[@]} )) || return 0 # One pass over `pacman -Qi`: emit "depparentreason" lines # for every (parent, optional-dep) edge in the local DB. local index index=$(pacman -Qi 2>/dev/null | awk ' function emit(line, parent, n, dep, reason) { sub(/^ +/, "", line); sub(/ \[installed\]$/, "", line) n = index(line, ":") if (n) { dep = substr(line, 1, n-1); reason = substr(line, n+2); sub(/^ +/, "", reason) } else { dep = line; reason = "" } print dep "\t" parent "\t" reason } /^Name +:/ { name=$3; in_od=0; next } /^Optional Deps +:/ { sub(/^[^:]*: */, "") if ($0 == "None") { in_od=0; next } in_od=1; emit($0, name); next } /^[A-Z][a-z]+[a-zA-Z ]*: / { in_od=0; next } in_od && NF { emit($0, name) } ') local p for p in "${leaves[@]}"; do print -P -- "%B${p}%b" print -r -- "$index" | awk -F'\t' -v p="$p" ' $1 == p { printf " ← %s%s\n", $2, ($3 ? ": " $3 : "") } ' done } # Git alias g='git' # Systemd alias sys='systemctl' alias ssys='sudo systemctl' alias sysu='systemctl --user' # Navigation alias c='clear' # Yazi: cd-on-exit wrapper y() { local tmp="$(mktemp -t "yazi-cwd.XXXXXX")" command yazi "$@" --cwd-file="$tmp" IFS= read -r -d '' cwd < "$tmp" [[ "$cwd" != "$PWD" ]] && [[ -d "$cwd" ]] && builtin cd -- "$cwd" rm -f -- "$tmp" } # Tools alias curl='curlie' alias cpr='rsync --archive -hh --partial --info=stats1,progress2 --modify-window=1' alias mvr='rsync --archive -hh --partial --info=stats1,progress2 --modify-window=1 --remove-source-files' alias sub='subliminal download -l en' # wl-copy that also passes stdin through to stdout (tee-like). # Use `| wlc` to copy AND see the output. wlc() { tee >(wl-copy "$@"); } # Copy the *last* command's output to the clipboard, retroactively. # Works only inside zellij — uses `zellij action dump-screen --full` and # locates prompt boundaries by matching the prompt prefix `user@host:`. # Bound to Alt+Shift+Y as a zle widget. copy-last-output() { emulate -L zsh if [[ -z $ZELLIJ ]]; then zle -M "copy-last-output: not inside a zellij session" return 1 fi local dump dump=$(zellij action dump-screen --full 2>/dev/null) || { zle -M "copy-last-output: dump-screen failed" return 1 } local prompt_re='^[[:alnum:]_-]+@[[:alnum:]_-]+:' local -a lines lines=("${(@f)dump}") local -a prompt_idx local i for (( i = 1; i <= ${#lines}; i++ )); do [[ ${lines[i]} =~ $prompt_re ]] && prompt_idx+=($i) done if (( ${#prompt_idx} < 2 )); then zle -M "copy-last-output: need at least 2 prompts in scrollback" return 1 fi local start=$(( ${prompt_idx[-2]} + 1 )) local end=$(( ${prompt_idx[-1]} - 1 )) if (( start > end )); then zle -M "copy-last-output: previous command produced no output" return 0 fi print -r -- "${(F)lines[start,end]}" | wl-copy zle -M "copied $((end - start + 1)) line(s) of last command output" } zle -N copy-last-output bindkey '^[Y' copy-last-output # Alt+Shift+Y # Neovim alias n='nvim' alias ndiff='nvim -d' alias nd='nvim -d' alias nview='nvim -R' alias nv='nvim -R' alias ng='nvim +Neogit' # Zellij: smart attach — 0 sessions: create, 1: attach, many: welcome picker za() { if [[ -n $ZELLIJ ]]; then echo "Already inside zellij" >&2 return 1 fi local -a sessions=("${(@f)$(zellij list-sessions -ns 2>/dev/null)}") sessions=(${sessions:#}) case ${#sessions} in 0) zellij ;; 1) zellij attach "${sessions[1]}" ;; *) zellij -l welcome ;; esac } # Re-import session env from the running sway process. Useful inside a # stale zellij pane whose server was started in a different session # (e.g. attached over SSH, then reattached locally, or vice versa). reload-env() { local pid pid=$(pgrep -u "$UID" -x sway | head -1) || { echo "reload-env: no sway process found for $USER" >&2 return 1 } local kv while IFS= read -r -d '' kv; do case $kv in WAYLAND_DISPLAY=*|SWAYSOCK=*|DISPLAY=*|\ DBUS_SESSION_BUS_ADDRESS=*|XDG_RUNTIME_DIR=*|\ XDG_CURRENT_DESKTOP=*|XDG_SESSION_TYPE=*|\ SSH_AUTH_SOCK=*) export "$kv" ;; esac done < "/proc/$pid/environ" } # Just alias j='just' alias dj='just --justfile ~/dotfiles/justfile --working-directory ~/dotfiles' alias rj='just --justfile ~/.local/share/dotfiles/nix/justfile --working-directory ~/.local/share/dotfiles/nix' # Home-Manager (flake-based; standalone HM defaults to legacy ~/.config/home-manager) hm() { local profile=host [ -r /etc/os-release ] && . /etc/os-release case "${ID:-}" in ubuntu|debian) profile=vm ;; esac local flake="$HOME/dotfiles/nix#${profile}" [ -d "$HOME/.local/share/dotfiles/nix" ] && flake="$HOME/.local/share/dotfiles/nix#${profile}" nix run home-manager/master -- "$@" --flake "$flake" --impure } # LLVM / Clang tooling alias ncmake='cmake -G Ninja -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_C_FLAGS="$DEV_CFLAGS" -DCMAKE_CXX_FLAGS="$DEV_CFLAGS" -DCMAKE_INSTALL_PREFIX=build/install -DCMAKE_BUILD_TYPE=Debug -DBUILD_SHARED_LIBS=ON -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -B build' alias ircc='clang -S -emit-llvm -fno-discard-value-names -O0 -Xclang -disable-O0-optnone -o -' alias irfc='flang -S -emit-llvm -O0 -o -' alias astcc='clang -Xclang -ast-dump -fsyntax-only' alias astfc='flang -fc1 -fdebug-dump-parse-tree' alias symfc='flang -fc1 -fdebug-dump-symbols' alias gdbr='gdb -ex start --args' # GitHub Copilot CLI alias copilot='gh copilot --autopilot --enable-all-github-mcp-tools --yolo --resume' # ── Alias completions ───────────────────────────────────────────────────────── # Guard each compdef on the target's completion function actually being # loaded; otherwise zsh prints `compdef: unknown command or service: foo` # on every login (the binary being present isn't enough — the zsh # completion file `_foo` must be in fpath and registered by compinit). # Notably triggers on the remote-dev VM where nix-installed packages # ship completions into /home/.../share/zsh/site-functions but Ubuntu's # system zsh doesn't add that path to fpath by default. # Usage: _dot_compdef = [=...] _dot_compdef() { (( $+_comps[$1] )) && compdef "${@:2}" } _dot_compdef git g=git _dot_compdef just j=just _dot_compdef nvim n=nvim ndiff=nvim nd=nvim nview=nvim nv=nvim _dot_compdef systemctl sys=systemctl ssys=systemctl sysu=systemctl _dot_compdef lsd l=lsd la=lsd lt=lsd unfunction _dot_compdef # ── GPG agent ───────────────────────────────────────────────────────────────── # Set GPG_TTY to this shell's actual TTY (not the login console) and tell # the agent so pinentry prompts appear in the right terminal export GPG_TTY=$TTY gpg-connect-agent updatestartuptty /bye &>/dev/null # ── direnv (per-project env via .envrc; nix-direnv loaded from direnvrc) ───── command -v direnv >/dev/null && eval "$(direnv hook zsh)" # ── Zoxide (smart directory jumping) ────────────────────────────────────────── # z foo → jump to frecency-ranked dir matching "foo" # zi → interactive picker with fzf command -v zoxide >/dev/null && eval "$(zoxide init zsh)" # ── FZF ─────────────────────────────────────────────────────────────────────── command -v fzf >/dev/null && source <(fzf --zsh) # Ctrl-X Ctrl-R: search history with fzf and immediately execute fzf-history-widget-accept() { fzf-history-widget zle accept-line } zle -N fzf-history-widget-accept bindkey '^X^R' fzf-history-widget-accept _fzf_compgen_path() { fd --hidden --follow --exclude ".git" . "$1" } _fzf_compgen_dir() { fd --type d --hidden --follow --exclude ".git" . "$1" } # ── Plugins (must be sourced last) ──────────────────────────────────────────── # Plugin locations: ~/.nix-profile (Home-Manager) first, Arch system path as # fallback for un-bootstrapped states. _source_first() { local f for f in "$@"; do [[ -r $f ]] && { source "$f"; return 0; } done return 1 } # Highlight config must be set BEFORE sourcing the plugin ZSH_HIGHLIGHT_HIGHLIGHTERS=(main brackets pattern) typeset -A ZSH_HIGHLIGHT_STYLES ZSH_HIGHLIGHT_STYLES[comment]='fg=yellow' _source_first \ $HOME/.nix-profile/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh \ /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh _source_first \ $HOME/.nix-profile/share/zsh-autosuggestions/zsh-autosuggestions.zsh \ /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh bindkey '^[[Z' autosuggest-accept # Shift-Tab to accept suggestion _source_first \ $HOME/.nix-profile/share/zsh-history-substring-search/zsh-history-substring-search.zsh \ /usr/share/zsh/plugins/zsh-history-substring-search/zsh-history-substring-search.zsh [[ -n "${key[Up]}" ]] && bindkey -- "${key[Up]}" history-substring-search-up [[ -n "${key[Down]}" ]] && bindkey -- "${key[Down]}" history-substring-search-down unfunction _source_first