Files
dotfiles/docs/shell/bash-zsh.mdx
T

161 lines
13 KiB
Plaintext

---
title: bash and zsh
description: Secondary shells that share fish's aliases, starship prompt, and cd-style helper functions; zsh also runs under iris like fish.
---
bash and zsh are set up so a shell other than fish feels the same: the same aliases, the same starship prompt, ports of the fish helpers that have to change the shell's own directory (`zsh/functions/` also ports `czs` and `y`), and the same picker key bindings. zsh matches fish most closely: it runs under [iris](/shell/iris), activates mise and zoxide, and has the <kbd>ctrl-z</kbd> job picker. bash keeps atuin for history.
| Source (repo) | Target |
| --- | --- |
| `dot_bashrc` | `~/.bashrc` |
| `dot_zshrc` | `~/.zshrc` |
| `dot_config/zsh/keybinds.zsh` | `~/.config/zsh/keybinds.zsh` |
| `dot_config/bash/keybinds.bash` | `~/.config/bash/keybinds.bash` |
| `dot_config/shell/aliases.sh.tmpl` | `~/.config/shell/aliases.sh` (template) |
| `dot_config/bash/functions/*.bash` | `~/.config/bash/functions/` |
| `dot_config/zsh/functions/*.zsh` | `~/.config/zsh/functions/` |
`aliases.sh.tmpl` is the only template. It renders the alias list from `.chezmoidata/aliases.yaml` ([chezmoi data](/chezmoi/data)). The other files apply as-is on every role.
## Installation
bash is part of the base system. zsh itself is not listed in `.chezmoidata/packages.yaml`, but `zsh-syntax-highlighting`, `starship`, `atuin`, `eza`, and `zoxide` are, for both pacman and Homebrew. `suvadu` (the `suv` history tool) is declared under `packages.shared.cargo`, but none of the apply scripts install cargo packages, so install it manually if you want it.
## ~/.bashrc
Interactive shells only (`[[ $- != *i* ]] && return`). In order:
1. **PATH.** Prepends `~/.local/bin` if missing, sources `~/.cargo/env` if present, and appends `~/.maestro/bin` if it exists. Exports `GHQ_ROOT` and `CODE_ROOT` as `~/Code`.
2. **Go.** `GOPATH=~/.local/share/go`, `GOBIN=$GOPATH/bin`, and `GOBIN` prepended to `PATH`. mise provides `GOROOT`.
3. **Android SDK.** `ANDROID_HOME` and `ANDROID_SDK_ROOT` set to `~/.local/share/android`. `platform-tools`, `cmdline-tools/latest/bin`, and `emulator` are appended if they exist. Exports `GITHUB_TOKEN` from `gh auth token` unless it is already set.
4. **Aliases.** Sources `~/.config/shell/aliases.sh`.
5. **Functions.** Sources every `~/.config/bash/functions/*.bash`.
6. **Flyline.** If `~/.local/lib/libflyline.so` exists, loads it with `enable -f … flyline` for richer line editing.
7. Sources `~/.config/envman/load.sh` if it is non-empty.
8. **Prompt.** `eval "$(starship init bash)"` if starship is installed.
9. **television.** `eval "$(tv init bash)"` if `tv` is installed (the `_tv` completion), then sources `~/.config/bash/keybinds.bash`.
10. **atuin.** Sources `~/.atuin/bin/env`, then `eval "$(atuin init bash)"`. atuin comes after tv, so it keeps <kbd>ctrl-r</kbd> and <kbd>up</kbd>.
## ~/.zshrc
1. **History.** `HISTFILE=~/.zsh_history`, `HISTSIZE=10000`, `SAVEHIST=10000`, `setopt SHARE_HISTORY HIST_IGNORE_DUPS HIST_IGNORE_SPACE`. `HIST_IGNORE_SPACE` keeps lines that start with a space out of history, as fish does; the <kbd>ctrl-z</kbd> binding's hidden ` __jobs_bind_fg` relies on it.
2. **Completion.** `autoload -Uz compinit && compinit`.
3. The same PATH, Go, Android, and `GITHUB_TOKEN` blocks as `.bashrc`, including `GHQ_ROOT` and `CODE_ROOT`, plus `eval "$(mise activate zsh)"` if mise is installed (fish activates mise in `40-tools.fish`; the Go block's `GOROOT` comes from it).
4. Sources `~/.config/shell/aliases.sh`.
5. Sources every `~/.config/zsh/functions/*.zsh`.
6. Sources `~/.config/zsh/keybinds.zsh`.
7. `eval "$(suv init zsh)"` if suvadu is installed.
8. Sources `~/.config/envman/load.sh` if it is non-empty.
9. `eval "$(starship init zsh)"` if starship is installed.
10. Prints quickshell's wallpaper-generated terminal color sequences (`~/.local/state/quickshell/user/generated/terminal/sequences.txt`) if present, as fish does.
11. `eval "$(zoxide init zsh)"` if zoxide is installed (`z`, `zi`), alongside the zoxide fallback in `functions/cd.zsh`.
12. `eval "$(tv init zsh)"` if `tv` is installed (<kbd>ctrl-t</kbd> smart autocomplete).
13. **iris.** If iris is installed, `eval "$(iris init zsh)"`: in an interactive zsh on a tty it `exec`s iris, which starts a second zsh in its pty that runs `.zshrc` again; there the init installs iris's line, cwd, and command hooks. Inside iris (`IRIS_PID` set) it also runs `unsetopt PROMPT_SP`: zsh's partial-line marker pads the line with spaces and a carriage return before each prompt, and iris read that echo as a typed space, so every prompt opened with a stale query and inserted a stray first suggestion on the next key. `IRIS_RESCUE=1 zsh` starts a plain zsh. See [iris](/shell/iris).
14. **Syntax highlighting.** Sources the first `zsh-syntax-highlighting.zsh` found in `/usr/share/zsh/plugins/zsh-syntax-highlighting/`, `/opt/homebrew/share/zsh-syntax-highlighting/`, or `/usr/local/share/zsh-syntax-highlighting/`. It stays last, because it wraps every zle widget defined before it.
atuin is not used in zsh: under iris it fought for <kbd>up</kbd> and <kbd>ctrl-r</kbd>, as in fish.
## zsh key bindings
`keybinds.zsh` ports `fish_user_key_bindings.fish` to zle. Unlike fish, zsh uses the **emacs** keymap: `bindkey -e` pins it so that `$EDITOR=nvim` does not switch zsh into vi mode. `unsetopt flow_control` frees <kbd>ctrl-q</kbd> and <kbd>ctrl-s</kbd> from the terminal.
A generic widget `_zle-run` is registered as `run-<cmd>` for `purple`, `clip-last`, `omp-session`, `yy`, and `lazygit`. It clears the pending display (`zle -I`), runs the command with the tty on stdin, then `zle reset-prompt`. Widgets run in the shell process, so directory changes made by `gho` and `yy` persist.
Widgets never fire `preexec`/`precmd`, so every widget that runs a program wraps it in `_iris_busy start` / `_iris_busy stop`, which send iris the same `IRIS_CMD_START` / `IRIS_CMD_STOP` messages its own hooks do (a no-op outside iris). Without them iris kept handling keys as if the prompt were idle while tv, yazi, or lazygit owned the screen, swallowing arrows and <kbd>tab</kbd>. See [fish](/shell/fish#key-bindings) for the same helper there.
The tv pickers have their own widgets, because `tv --inline` opens on the line below the prompt and leaves the cursor there on exit. `zle -I` followed by `zle reset-prompt` redraws the prompt where zle last drew it, wiping anything printed below; `zle -I` alone draws a fresh prompt under the output.
| Widget | Runs | Then |
| --- | --- | --- |
| `tv-git-files` | `tv git-files --inline` | `\033[A`, `reset-prompt` |
| `gho-inline` | `gho` | cwd unchanged: `\033[A`, `reset-prompt`. After a cd: nothing, so the new prompt lands below onefetch |
| `pj-inline` | `_zle-pj`: `pj` with `PJ_STATE` set to a temp file | `open` (cancelled): `\033[A`, `reset-prompt`. `ran`: nothing, so the task output stays. Empty (no task sources): `reset-prompt` |
| `jobs-fg` | `_zle-jobs`: with one job, that job; with several, `tv jobs --inline` with `TV_JOBS` set to a temp file holding the job table built from `$jobstates`/`$jobtexts` | Cancelled: `\033[A`, `reset-prompt`. Picked: `zle push-input`, then the line ` __jobs_bind_fg <id>` is accepted; `__jobs_bind_fg` erases that echoed line and runs `fg` quietly. Typed text returns at the next prompt |
| Key | Action |
| --- | --- |
| <kbd>ctrl-e</kbd> | `tv-git-files` (pick a file, open it in nvim) |
| <kbd>ctrl-f</kbd> | `yy` |
| <kbd>ctrl-b</kbd> | `purple` (an external command; this repo does not provide it) |
| <kbd>ctrl-y</kbd> | `clip-last` |
| <kbd>ctrl-o</kbd> | `gho-inline` (`ghq-repos` cable, cd) |
| <kbd>ctrl-p</kbd> | `pj-inline` (`pj` cable) |
| <kbd>ctrl-t</kbd> | tv smart autocomplete (from `tv init zsh`) |
| <kbd>ctrl-z</kbd> | `jobs-fg`: `fg` the only background job, or pick one from the `jobs` cable ([television](/tools/television#jobs)); nothing with no jobs. While a job runs, ctrl-z is the tty's suspend, so the key toggles |
| <kbd>ctrl-a</kbd> | `omp-session`: resume or start omp for the current repo ([Scripts](/shell/scripts#omp-session)) |
| <kbd>ctrl-g</kbd> | `lazygit` |
| <kbd>ctrl-q</kbd> | `exit` |
| <kbd>ctrl-c</kbd> | At the prompt: kill the buffer, clear the screen and kitty scrollback (`ESC[2J ESC[3J`), reset the prompt |
<kbd>ctrl-c</kbd> is the tty interrupt character, so `bindkey '^C'` would never fire. The binding is a `TRAPINT` function instead. At the prompt it runs `zle .kill-buffer`, prints the clear sequence, and runs `zle .reset-prompt`. While a command is running, SIGINT keeps its normal behavior (returns `128+SIGINT`).
<kbd>ctrl-h/j/k/l</kbd> belong to kitty ([kitty](/terminal/kitty)) and are not bound here. Under iris, <kbd>tab</kbd>, <kbd>shift-tab</kbd>, <kbd>ctrl-r</kbd>, <kbd>up</kbd>, and <kbd>down</kbd> belong to iris; <kbd>up</kbd>/<kbd>down</kbd> keep zsh's defaults outside it. The cables are described on the [television](/tools/television) page.
## bash key bindings
`keybinds.bash` gives bash the same picker keys as fish and zsh. bash keeps readline's default emacs keymap. Every picker runs `tv --inline`; when nothing was printed below the prompt, `_kb_up` (`\033[A` on `/dev/tty`) steps back so readline redraws the prompt in place after `bind -x`.
| Key | Action |
| --- | --- |
| <kbd>ctrl-o</kbd> | Macro `\C-x1\C-x2\C-x3\C-x0` (below): pick from the `ghq-repos` cable and cd |
| <kbd>ctrl-e</kbd> | `_kb_git_files`: `tv git-files --inline`, then `_kb_up` |
| <kbd>ctrl-p</kbd> | `_kb_pj`: `pj` with `PJ_STATE` set to a temp file; `_kb_up` only when the state is `open` (cancelled) |
| <kbd>ctrl-t</kbd> | `_kb_tv_autocomplete`: runs `tv --autocomplete-prompt … --inline` on the text left of the cursor, `_kb_up`, and inserts the picks there, each `printf %q`-quoted |
`bind -x` runs in the shell process, so a `cd` persists, but readline keeps drawing the prompt it started with. <kbd>ctrl-o</kbd> is therefore a macro:
1. `\C-x1` runs `_kb_gho`: stashes `READLINE_LINE`, runs `tv ghq-repos --inline`, `_kb_up`, and cds into the pick. It does not call the `gho` function. After a cd it empties the line and rebinds `\C-x2` to `accept-line` and `\C-x3` to `bind -x _kb_gho_show`; when cancelled it rebinds both to `redraw-current-line`.
2. `\C-x2` accepts the empty line, so bash prints a fresh prompt for the new cwd. Nothing reaches history.
3. `\C-x3` runs `_kb_gho_show` (`clear`, then `onefetch` in git repos); readline redraws the prompt below the output.
4. `\C-x0` runs `_kb_restore`, which puts the typed text back.
## Shared aliases: aliases.sh
`~/.config/shell/aliases.sh` is rendered from `.chezmoidata/aliases.yaml`. Each YAML `section` becomes a comment, and each item becomes `alias <name>='<cmd>'`. The template then appends shell-specific extras that are not in the YAML:
```sh ~/.config/shell/aliases.sh
# Clipboard
if command -v wl-copy >/dev/null 2>&1; then
alias cpy='wl-copy'
alias pst='wl-paste'
elif command -v pbcopy >/dev/null 2>&1; then
alias cpy='pbcopy'
alias pst='pbpaste'
fi
q() {
if [ -n "$YAZI_ID" ] && command -v ya >/dev/null 2>&1; then
ya emit quit 2>/dev/null
fi
exit
}
alias celar='clear'
alias claer='clear'
if command -v eza >/dev/null 2>&1; then
alias ls='eza --icons'
fi
```
- **Clipboard detection.** `cpy`/`pst` map to Wayland's `wl-copy`/`wl-paste` when available, else macOS `pbcopy`/`pbpaste`.
- **`q`.** Exits the shell. If the shell was spawned from yazi's own `q` binding (`$YAZI_ID` set), it runs `ya emit quit` first so the parent yazi closes too instead of bouncing back.
- **Typo guards and eza.** `celar`/`claer` → `clear`, and `ls` → `eza --icons` when eza is installed. The file is sourced only by interactive shells, so scripts keep the real `ls`.
The full alias table, including the differences from fish, is on the [Aliases](/shell/aliases) page.
## Per-shell functions
Each directory holds one function per file. bash and zsh source every file at startup; the bash and zsh versions have identical bodies.
| Function | Behavior |
| --- | --- |
| `c [dir]` | `clear` with no argument, `cd "$1"` with one |
| `cd [dir]` | No argument: `$HOME`. `-`: previous dir. Otherwise `builtin cd`, falling back to `zoxide query -- "$1"`. Records the result with `zoxide add "$PWD"` and preserves cd's exit status |
| `clip-last` | Re-runs the previous history entry (`fc -ln -1 -1`, skipping itself if the line editor already recorded `clip-last`), pipes the output to `wl-copy` or `pbcopy`, and sends a `notify-send -a shell` notification if available |
| `ghg <repo>` | `ghg-clone "$@"`, cd into the printed path, `clear && onefetch` |
| `ghn <name>` | `command ghn "$@"`, cd into the printed path |
| `gho` | `dir=$(tv ghq-repos --inline)`, return if empty, cd into it, `clear`, and `onefetch` if it is a git repo. No cursor handling; bash <kbd>ctrl-o</kbd> does not call it |
| `yy [args]` | `yazi --cwd-file=<tmp>`, then cd to the directory written there if it changed |
The `ghg` helper script is named `ghg-clone` because an unrelated cargo binary already uses the name `ghg`. The [Scripts](/shell/scripts#why-some-helpers-are-shell-functions) page explains why these helpers are shell functions and not scripts.