# The Modern Ubuntu Bash Terminal Setup

*June 19, 2026*
 — by Flaviu Vlaicu

> Turn Ubuntu's default bash into a fast, beautiful terminal: ble.sh, Oh My Posh, fzf, atuin, yazi, uv + direnv — plus the ordering gotchas that aren't in any README.



This is a long-form, opinionated guide to setting up a terminal that's both **pretty** (syntax-highlighted, themed, autosuggesting) and **productive** (fuzzy everything, smart history, per-project Python envs, modern replacements for the classic Unix tools). It's everything I wish I'd known before assembling the stack — including the half-dozen subtle ordering and key-binding issues that ate a couple of evenings of my life.

Target: **Ubuntu 22.04 or newer, with bash as your shell**. No zsh, no fish — bash all the way. The reason: it's the default, it's everywhere, and with `ble.sh` it gets ~95% of zsh's quality-of-life features.

By the end you'll have:

- Syntax highlighting and fish-style autosuggestions in bash
- A two-line Catppuccin Frappé prompt via Oh My Posh
- `fzf` everywhere — fuzzy file picking, fuzzy directory cd, fuzzy command history (via Atuin)
- Modern replacements for `cat`, `ls`, `cd`, `find`, `grep`, `du`, `df`, `top`, `ps`, `dig`, `mtr`
- TUI file management (yazi), git (lazygit), docker (lazydocker)
- LazyVim
- Auto-activating Python virtual environments via `uv` + `direnv`

The final `.bashrc` and `.blerc` are at the bottom — feel free to skip ahead.

## The mental model

A terminal session has **layers**. Getting them in the right order is the whole game:

1. **Shell** — bash itself.
2. **Line editor** — `ble.sh` replaces GNU Readline with a richer editor that does syntax highlighting, autosuggestions, completion menus, exit-status markers, vi mode, etc.
3. **Prompt** — `oh-my-posh` generates the `PS1` (and updates it per-command via `PROMPT_COMMAND`).
4. **History** — `atuin` replaces `~/.bash_history` with a context-rich SQLite database, hooks `Ctrl-R`.
5. **Directory hooks** — `zoxide` (frecency-based `cd`), `direnv` (per-project env activation).
6. **Pickers and TUIs** — `fzf` with `bat`/`eza` previews; `yazi`, `lazygit`, `lazydocker`.
7. **CLI tools** — modern rewrites that you call directly: `rg`, `fd`, `dust`, `duf`, `procs`, `bat`, `eza`, `glow`, `tldr`, etc.

Layers 2–5 all want to wrap `PROMPT_COMMAND` and/or bind keys. Their order in `~/.bashrc` matters, and getting it wrong leads to silent failures. There's also a key-binding turf war over `Ctrl-T` and `Ctrl-R` between fzf, atuin, and television. We'll come back to both.

## Prerequisites

```bash
sudo apt update
sudo apt install -y git curl wget build-essential
```

Ubuntu's default `~/.profile` adds `~/.local/bin` to `PATH` if the directory exists. Make sure it does, and force the line in `~/.bashrc` as belt-and-suspenders:

```bash
mkdir -p ~/.local/bin
```

We'll add the explicit `PATH` line during the `.bashrc` assembly later.

## Install the tools

A pile of installs, mostly one-liners. Some need symlinks because Ubuntu renamed binaries to avoid package conflicts; I'll flag those.

### Line editor — ble.sh

![Ble Line Editor](/images/2026-06/ble-line-editor.jpg)

The single biggest QoL upgrade. Build from source — apt doesn't ship it:

```bash
sudo apt install -y gawk
git clone --recursive --depth 1 --shallow-submodules \
    https://github.com/akinomyoga/ble.sh.git ~/ble.sh
make -C ~/ble.sh install PREFIX=~/.local
```

`gawk` (not Ubuntu's default `mawk`) is required for the build. The compiled files end up in `~/.local/share/blesh/`; the source clone at `~/ble.sh` is kept around so you can `git pull && make install` later to update.

### Fuzzy finder — fzf

![Fuzzy Finder](/images/2026-06/fzf.jpg)

**Critical**: apt's `fzf` is often too old to support the `--bash` flag we need (it landed in 0.48 / Dec 2023). Install from source:

```bash
git clone --depth 1 https://github.com/junegunn/fzf.git ~/.fzf
~/.fzf/install --all --no-update-rc
ln -sf ~/.fzf/bin/fzf ~/.local/bin/fzf
```

Answer `y / y / n` to the installer's three prompts — yes to completion, yes to key bindings, **no** to "update shell config" (we'll wire it up ourselves). The symlink puts the new binary on `~/.local/bin` which is first on `PATH`, so it shadows any apt fzf you may have. Don't bother uninstalling the apt one — it's harmless.

### Cat replacement — bat

![Better than cat](/images/2026-06/cat-replacement.jpg)

```bash
sudo apt install -y bat
ln -sf $(which batcat) ~/.local/bin/bat
```

The symlink is needed because Ubuntu installs the binary as `batcat` to avoid clashing with an old utility named `bat`.

### Find replacement — fd

```bash
sudo apt install -y fd-find
ln -sf $(which fdfind) ~/.local/bin/fd
```

Same symlink story.

### ls replacement — eza

![Better than ls](/images/2026-06/eza.jpg)

Not in apt. Add the maintainer's repo:

```bash
sudo mkdir -p /etc/apt/keyrings
wget -qO- https://raw.githubusercontent.com/eza-community/eza/main/deb.asc \
    | sudo gpg --dearmor -o /etc/apt/keyrings/gierens.gpg
echo "deb [signed-by=/etc/apt/keyrings/gierens.gpg] http://deb.gierens.de stable main" \
    | sudo tee /etc/apt/sources.list.d/gierens.list
sudo chmod 644 /etc/apt/keyrings/gierens.gpg /etc/apt/sources.list.d/gierens.list
sudo apt update
sudo apt install -y eza
```

### cd replacement — zoxide

![Zoxide](/images/2026-06/zoxide.jpg)

```bash
sudo apt install -y zoxide
```

### grep replacement — ripgrep

![Better than grep](/images/2026-06/ripgrep.jpg)

```bash
sudo apt install -y ripgrep
```

### Better classics — modern CLI replacements

A bunch of one-liners. Each is a drop-in upgrade for its classic counterpart:

```bash
sudo apt install -y ncdu duf du-dust procs ipcalc
```

That covers `ncdu` (interactive disk usage), `duf` (pretty `df`), `du-dust` (visual `du`, binary is `dust`), `procs` (modern `ps`), `ipcalc` (subnet math).

### Markdown viewer — glow (Charm)

```bash
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/charm.gpg
echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list
sudo apt update
sudo apt install -y glow
```

### Television (`tv`) — fuzzy finder with channels

![Television](/images/2026-06/television.jpg)

A newer Rust-based fuzzy picker with built-in "channels" (files, text contents, git repos, env vars, history).

```bash
curl -fsSL https://alexpasmantier.github.io/television/install.sh | bash
```

**Important — don't load its shell integration.** Television's `tv init bash` binds **both** `Ctrl-T` (stealing it from fzf) and `Ctrl-R` (stealing it from atuin). Since we want fzf on `Ctrl-T` and atuin on `Ctrl-R`, we use television purely as a command — no `init` line in the bashrc. You still get all its power on demand:

```bash
tv              # fuzzy file picker
tv text         # fuzzy content search
tv git-repos    # jump to any git repo on disk
tv env          # search environment variables
```

(If you actually prefer television's history/file pickers over atuin and fzf, that's a valid choice — see the gotchas section for how to wire it up without the conflict.)

### Auto-correct typos — thefuck

```bash
sudo apt install -y python3-dev python3-pip python3-setuptools thefuck
```

### Password generator — diceware

A Python tool, install via `pipx` so it doesn't pollute system Python:

```bash
sudo apt install -y pipx
pipx ensurepath
pipx install diceware
```

Usage: `diceware -n 6` for a 6-word passphrase.

### Better mtr — trippy

```bash
sudo add-apt-repository -y ppa:fujiapple/trippy
sudo apt update
sudo apt install -y trippy
sudo setcap CAP_NET_RAW+p $(which trip)
```

The binary is `trip`. The `setcap` grants raw-socket capability **once**, so you don't need `sudo` per invocation. We'll alias `mtr` to `trip` in the bashrc.

### DNS lookups — doggo

```bash
curl -fsSL https://raw.githubusercontent.com/mr-karan/doggo/main/install.sh | sh
```

### Better history — atuin

![Better history](/images/2026-06/atuin.jpg)

```bash
bash <(curl --proto '=https' --tlsv1.2 -sSf https://setup.atuin.sh)
atuin import bash      # pull in your existing ~/.bash_history
```

Optional: register a free account for end-to-end-encrypted history sync across machines (`atuin register` then `atuin sync`). Local-only works fine if you skip it.

### Editor — Neovim + LazyVim

![Neovim + Lazyvim](/images/2026-06/neovim-lazyvim.jpg)

Apt's neovim is too old for current LazyVim. Snap gives us a guaranteed-recent build:

```bash
sudo apt remove -y neovim
sudo snap install nvim --classic
hash -r
```

If you previously had any nvim config, back it up; then install LazyVim's starter:

```bash
mv ~/.config/nvim{,.bak} 2>/dev/null
mv ~/.local/share/nvim{,.bak} 2>/dev/null
mv ~/.local/state/nvim{,.bak} 2>/dev/null
mv ~/.cache/nvim{,.bak} 2>/dev/null
git clone https://github.com/LazyVim/starter ~/.config/nvim
rm -rf ~/.config/nvim/.git
nvim    # first launch installs all plugins — let it finish
```

### Git TUI — lazygit

![Lazygit](/images/2026-06/lazygit.jpg)

Not in apt. Install the latest release binary:

```bash
LAZYGIT_VERSION=$(curl -s "https://api.github.com/repos/jesseduffield/lazygit/releases/latest" | grep -Po '"tag_name": "v\K[^"]*')
curl -Lo /tmp/lazygit.tar.gz "https://github.com/jesseduffield/lazygit/releases/latest/download/lazygit_${LAZYGIT_VERSION}_Linux_x86_64.tar.gz"
tar xf /tmp/lazygit.tar.gz -C /tmp lazygit
install /tmp/lazygit ~/.local/bin/
rm /tmp/lazygit /tmp/lazygit.tar.gz
```

(Replace `x86_64` with `arm64` if you're on ARM.)

### Docker TUI — lazydocker

![Lazydocker](/images/2026-06/lazydocker.jpg)

```bash
curl https://raw.githubusercontent.com/jesseduffield/lazydocker/master/scripts/install_update_linux.sh | bash
```

Installs to `~/.local/bin/lazydocker`.

### File manager — yazi

![Yazi](/images/2026-06/yazy.jpg)

Best installed via snap on Ubuntu:

```bash
sudo snap install yazi --classic
# optional preview deps
sudo apt install -y ffmpeg p7zip-full poppler-utils imagemagick
```

### Roaming-friendly SSH — mosh

```bash
sudo apt install -y mosh
```

Install on **both** ends — your client and the remote machine. UDP ports 60000–61000 need to be open on the server.

### Python envs — uv + direnv

```bash
sudo apt install -y direnv
curl -LsSf https://astral.sh/uv/install.sh | sh

# Global direnv helper
mkdir -p ~/.config/direnv
cat > ~/.config/direnv/direnvrc <<'EOF'
use_uv() {
    if [ ! -d .venv ]; then
        uv venv
    fi
    source .venv/bin/activate
}
EOF

# Keep .envrc out of all repos by default
mkdir -p ~/.config/git
echo ".envrc" >> ~/.config/git/ignore
git config --global core.excludesfile ~/.config/git/ignore
```

The convenience alias `uvenv` goes in bashrc — see below.

### Prompt — Oh My Posh + Nerd Font

The prompt and the font that powers it:

```bash
curl -s https://ohmyposh.dev/install.sh | bash -s
mkdir -p ~/.config/ohmyposh
cp ~/.cache/oh-my-posh/themes/catppuccin_frappe.omp.json \
   ~/.config/ohmyposh/catppuccin_frappe.omp.json

mkdir -p ~/.local/share/fonts
curl -fLo /tmp/JetBrainsMono.zip \
    https://github.com/ryanoasis/nerd-fonts/releases/latest/download/JetBrainsMono.zip
unzip -o /tmp/JetBrainsMono.zip -d ~/.local/share/fonts/JetBrainsMono
fc-cache -f
```

**Manual step**: open your terminal's preferences and set the font to **JetBrainsMono Nerd Font**. Without a Nerd Font, the prompt renders as squares and question marks.

## The `.bashrc` — and why ordering matters

Here's where the rubber meets the road. We have **four** systems that all want to hook `PROMPT_COMMAND`: Oh My Posh, bash-preexec (via Atuin), direnv, and ble.sh. They'll chain together correctly if loaded in the right order, and silently break if not.

### The ordering rules

After much trial and error, these are the rules:

1. **ble.sh source** goes near the top, with `--noattach`. This registers ble.sh's machinery without taking over yet.
2. **The Ubuntu default PS1 block** can stay where it is — Oh My Posh will override it.
3. **Atuin** must load before Oh My Posh, because Atuin pulls in `bash-preexec` which aggressively reorganizes `PROMPT_COMMAND`. If Oh My Posh's hook function exists when bash-preexec loads, bash-preexec swallows it.
4. **Oh My Posh** init goes **after** atuin and **before** the final `ble-attach`. This is the lesson that cost me an evening: with Oh My Posh at the top of `.bashrc` (the obvious place), bash-preexec eats its `_omp_hook` function during atuin init, and you end up with the bash default prompt.
5. **direnv** hook also goes before `ble-attach`. If it's after, it loads but never fires.
6. **`ble-attach`** is the **absolute last line**. Anything after it runs in a context where ble.sh is already managing the prompt cycle, and most things will silently fail.

### Secrets pattern

Don't put API tokens, HF tokens, or anything sensitive in `~/.bashrc`. It's easy to accidentally `cat` it during a screen share or commit it via a dotfiles repo. Use a sourced sidecar:

```bash
touch ~/.bash_secrets
chmod 600 ~/.bash_secrets
# Put `export FOO=bar` lines in there
```

If you keep your dotfiles in a git repo (you should), add the sidecar to your global gitignore — the direnv setup above already wired up `~/.config/git/ignore`, so one line covers it:

```bash
echo ".bash_secrets" >> ~/.config/git/ignore
```

The bashrc sources `~/.bash_secrets` if present, so secrets live one file over from everything else and are easy to keep out of version control.

### Project-specific aliases

Keep machine- or project-specific aliases (docker container shortcuts, work tooling, etc.) out of `~/.bashrc` and in `~/.bash_aliases`, which the bashrc sources automatically. This keeps your portable config clean and your one-off stuff separate. For example, a `~/.bash_aliases` with some container helpers:

```bash
# ~/.bash_aliases
alias dc='docker compose'
alias dps='docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"'
alias logs='docker logs -f --tail 50'
```

### The complete `~/.bashrc`

```bash
# ~/.bashrc — bash configuration for interactive Ubuntu shells

# If not running interactively, don't do anything
case $- in
    *i*) ;;
      *) return ;;
esac

# ─── ble.sh ── load early, attach last ───────────────────────────────
[ -f "$HOME/.local/share/blesh/ble.sh" ] && \
    source -- "$HOME/.local/share/blesh/ble.sh" --noattach

# ─── PATH ────────────────────────────────────────────────────────────
export PATH="$HOME/.local/bin:$PATH"

# ─── History ─────────────────────────────────────────────────────────
HISTCONTROL=ignoreboth
shopt -s histappend
HISTSIZE=1000
HISTFILESIZE=2000
shopt -s checkwinsize

# ─── Lesspipe & chroot indicator ─────────────────────────────────────
[ -x /usr/bin/lesspipe ] && eval "$(SHELL=/bin/sh lesspipe)"
if [ -z "${debian_chroot:-}" ] && [ -r /etc/debian_chroot ]; then
    debian_chroot=$(cat /etc/debian_chroot)
fi

# ─── Fallback PS1 (oh-my-posh overrides this when present) ───────────
case "$TERM" in
    xterm-color|*-256color) color_prompt=yes ;;
esac
if [ "$color_prompt" = yes ]; then
    PS1='${debian_chroot:+($debian_chroot)}\[\033[01;32m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ '
else
    PS1='${debian_chroot:+($debian_chroot)}\u@\h:\w\$ '
fi
unset color_prompt
case "$TERM" in
    xterm*|rxvt*) PS1="\[\e]0;${debian_chroot:+($debian_chroot)}\u@\h: \w\a\]$PS1" ;;
esac

# ─── Colors & aliases ────────────────────────────────────────────────
if [ -x /usr/bin/dircolors ]; then
    test -r ~/.dircolors && eval "$(dircolors -b ~/.dircolors)" || eval "$(dircolors -b)"
    alias ls='ls --color=auto'
    alias grep='grep --color=auto'
    alias fgrep='fgrep --color=auto'
    alias egrep='egrep --color=auto'
fi
alias ll='ls -alF'
alias la='ls -A'
alias l='ls -CF'
alias lsa='eza -alh'

alias alert='notify-send --urgency=low -i "$([ $? = 0 ] && echo terminal || echo error)" "$(history|tail -n1|sed -e '\''s/^\s*[0-9]\+\s*//;s/[;&|]\s*alert$//'\'')"'

# Project / machine-specific aliases live here, not in this file
[ -f ~/.bash_aliases ] && . ~/.bash_aliases

# ─── Programmable completion ─────────────────────────────────────────
if ! shopt -oq posix; then
    if [ -f /usr/share/bash-completion/bash_completion ]; then
        . /usr/share/bash-completion/bash_completion
    elif [ -f /etc/bash_completion ]; then
        . /etc/bash_completion
    fi
fi

# ─── Public env + secrets ────────────────────────────────────────────
# Public env vars only — secrets live in ~/.bash_secrets (chmod 600)
[ -f ~/.bash_secrets ] && source ~/.bash_secrets

# ─── fzf ─────────────────────────────────────────────────────────────
if command -v fzf &>/dev/null; then
    eval "$(fzf --bash)"

    if command -v fd &>/dev/null; then
        export FZF_DEFAULT_COMMAND="fd --hidden --strip-cwd-prefix --exclude .git"
        export FZF_CTRL_T_COMMAND="$FZF_DEFAULT_COMMAND"
        export FZF_ALT_C_COMMAND="fd --type=d --hidden --strip-cwd-prefix --exclude .git"
        _fzf_compgen_path() { fd --hidden --exclude .git . "$1"; }
        _fzf_compgen_dir()  { fd --type=d --hidden --exclude .git . "$1"; }
    fi

    # Catppuccin Frappé-friendly fzf colors
    export FZF_DEFAULT_OPTS="--color=fg:#c6d0f5,bg:#303446,hl:#ca9ee6,fg+:#c6d0f5,bg+:#414559,hl+:#ca9ee6,info:#8caaee,prompt:#81c8be,pointer:#81c8be,marker:#81c8be,spinner:#81c8be,header:#81c8be"

    if command -v bat &>/dev/null && command -v eza &>/dev/null; then
        show_file_or_dir_preview="if [ -d {} ]; then eza --tree --color=always {} | head -200; else bat -n --color=always --line-range :500 {}; fi"
        export FZF_CTRL_T_OPTS="--preview '$show_file_or_dir_preview'"
        export FZF_ALT_C_OPTS="--preview 'eza --tree --color=always {} | head -200'"

        # Customize fzf for specific commands
        _fzf_comprun() {
            local command=$1
            shift
            case "$command" in
                cd)            fzf --preview 'eza --tree --color=always {} | head -200' "$@" ;;
                export|unset)  fzf --preview "eval 'echo \$'{}" "$@" ;;
                ssh)           fzf --preview 'dig {}' "$@" ;;
                *)             fzf --preview "$show_file_or_dir_preview" "$@" ;;
            esac
        }
    fi
fi

# ─── television (tv) ─────────────────────────────────────────────────
# NOTE: `tv init bash` is intentionally NOT loaded — it rebinds both
# Ctrl-T (away from fzf) and Ctrl-R (away from atuin). Use `tv` as a
# command instead: `tv`, `tv text`, `tv git-repos`, `tv env`.

# ─── trippy (mtr replacement) ────────────────────────────────────────
command -v trip &>/dev/null && alias mtr='trip'

# ─── yazi — function `y` cds into the directory you ended up in ──────
if command -v yazi &>/dev/null; then
    function y() {
        local tmp cwd
        tmp="$(mktemp -t "yazi-cwd.XXXXXX")"
        yazi "$@" --cwd-file="$tmp"
        if cwd="$(command cat -- "$tmp")" && [ -n "$cwd" ] && [ "$cwd" != "$PWD" ]; then
            builtin cd -- "$cwd"
        fi
        rm -f -- "$tmp"
    }
fi

# ─── thefuck ─────────────────────────────────────────────────────────
command -v thefuck &>/dev/null && eval "$(thefuck --alias)"

# ─── zoxide ──────────────────────────────────────────────────────────
if command -v zoxide &>/dev/null; then
    eval "$(zoxide init bash)"
    alias cd="z"
fi

# ─── direnv + uv helper ──────────────────────────────────────────────
if command -v direnv &>/dev/null; then
    eval "$(direnv hook bash)"
    alias uvenv='echo "use_uv" > .envrc && direnv allow'
fi

# ─── atuin — MUST load before oh-my-posh ─────────────────────────────
[ -f "$HOME/.atuin/bin/env" ] && . "$HOME/.atuin/bin/env"
[ -f ~/.bash-preexec.sh ] && source ~/.bash-preexec.sh
command -v atuin &>/dev/null && eval "$(atuin init bash --disable-up-arrow)"

# ─── oh-my-posh — MUST load AFTER atuin so bash-preexec doesn't ──────
#                  swallow the _omp_hook function
if command -v oh-my-posh &>/dev/null; then
    eval "$(oh-my-posh init bash --config "$HOME/.config/ohmyposh/catppuccin_frappe.omp.json")"
fi

# ─── ble.sh — quiet the exit marker, then attach last ────────────────
bleopt exec_errexit_mark=
[[ ${BLE_VERSION-} ]] && ble-attach
```

The `--disable-up-arrow` on atuin keeps **Up Arrow** doing bash's normal previous-command behavior (ble.sh makes this a per-prefix search, which is genuinely useful), while Ctrl-R goes to atuin's full TUI.

## Theming ble.sh — `.blerc`

Oh My Posh themes the prompt. **ble.sh themes everything else you type** — and its defaults are jarringly out-of-step with Catppuccin. Saving the following as `~/.blerc` makes it match. ble.sh auto-sources it.

```bash
# ~/.blerc — Catppuccin Frappé theme for ble.sh

# ─── Commands as you type them ──────────────────────────────────────
ble-face -s command_builtin       fg=#8caaee,bold       # cd, echo
ble-face -s command_alias         fg=#81c8be            # your aliases
ble-face -s command_function      fg=#ca9ee6            # functions
ble-face -s command_file          fg=#a6d189            # executables on PATH
ble-face -s command_directory     fg=#f4b8e4            # ./dir
ble-face -s command_keyword       fg=#f4b8e4,bold       # if, then, while
ble-face -s command_jobs          fg=#e5c890            # %1
ble-face -s command_suffix        fg=#ef9f76
ble-face -s disabled              fg=#737994

# ─── Arguments / options ────────────────────────────────────────────
ble-face -s argument_error        fg=#e78284,bold
ble-face -s argument_option       fg=#e5c890,italic     # -v, --foo

# ─── Filenames as arguments ─────────────────────────────────────────
ble-face -s filename_directory         fg=#8caaee,underline
ble-face -s filename_directory_sticky  fg=#8caaee,bg=#414559,underline
ble-face -s filename_executable        fg=#a6d189,underline
ble-face -s filename_link              fg=#85c1dc,underline
ble-face -s filename_other             fg=#c6d0f5
ble-face -s filename_socket            fg=#f4b8e4,underline
ble-face -s filename_pipe              fg=#ef9f76,underline
ble-face -s filename_character         fg=#ef9f76
ble-face -s filename_block             fg=#ef9f76,bold
ble-face -s filename_warning           fg=#e78284,underline
ble-face -s filename_orphan            fg=#e78284,bold
ble-face -s filename_setuid            fg=#232634,bg=#e78284
ble-face -s filename_setgid            fg=#232634,bg=#e5c890
ble-face -s filename_url               fg=#85c1dc,underline
ble-face -s filename_ls_colors         underline

# ─── Variables ──────────────────────────────────────────────────────
ble-face -s varname_array         fg=#ca9ee6,bold
ble-face -s varname_empty         fg=#737994
ble-face -s varname_export        fg=#f4b8e4,bold
ble-face -s varname_expr          fg=#85c1dc
ble-face -s varname_hash          fg=#ca9ee6
ble-face -s varname_number        fg=#ef9f76
ble-face -s varname_readonly      fg=#e78284,bold
ble-face -s varname_transform     fg=#e5c890,italic
ble-face -s varname_unset         fg=#737994,italic
ble-face -s varname_new           fg=#81c8be            # newly assigned

# ─── Syntax (quoting, comments, expansions) ─────────────────────────
ble-face -s syntax_default            fg=#c6d0f5
ble-face -s syntax_command            fg=#8caaee
ble-face -s syntax_delimiter          fg=#a5adce
ble-face -s syntax_quoted             fg=#a6d189
ble-face -s syntax_quotation          fg=#a6d189,bold
ble-face -s syntax_escape             fg=#ef9f76
ble-face -s syntax_expr               fg=#85c1dc
ble-face -s syntax_comment            fg=#737994,italic
ble-face -s syntax_glob               fg=#e5c890
ble-face -s syntax_brace              fg=#f4b8e4
ble-face -s syntax_tilde              fg=#f4b8e4
ble-face -s syntax_function_name      fg=#ca9ee6,bold
ble-face -s syntax_document           fg=#a6d189
ble-face -s syntax_document_begin     fg=#a6d189,bold
ble-face -s syntax_error              fg=#e78284,bold,underline
ble-face -s syntax_varname            fg=#ef9f76        # $foo
ble-face -s syntax_param_expansion    fg=#85c1dc        # ${foo:-bar}
ble-face -s syntax_history_expansion  fg=#e5c890,bold   # !! !$

# ─── Editor UI ──────────────────────────────────────────────────────
ble-face -s auto_complete         fg=#737994,italic     # grey ghost suggestion
ble-face -s region                bg=#414559
ble-face -s region_target         bg=#51576d
ble-face -s region_match          bg=#626880
ble-face -s region_insert         fg=#85c1dc,bg=#414559
ble-face -s overwrite_mode        fg=#303446,bg=#ef9f76
ble-face -s vbell                 fg=#303446,bg=#e5c890
ble-face -s prompt_status_line    fg=#c6d0f5,bg=#414559
ble-face -s cmdinfo_cd_cdpath     fg=#85c1dc,underline
```

Different version of ble.sh? Check what's available with:

```bash
ble-face | grep <prefix>
```

and remove any lines for faces that don't exist in your build.

## Customizing the Oh My Posh prompt

The Catppuccin Frappé preset is a good starting point. To customize, edit your **local copy** at `~/.config/ohmyposh/catppuccin_frappe.omp.json` — never the one in `~/.cache/`, which gets overwritten on update.

The `session` segment controls the `user@host` part. Its `template` is a Go-template string, and the `<#hexcolor>...</>` syntax applies inline color. A few useful moves:

**Color the `@` and hostname distinctly** (e.g. machine branding):

```json
"template": "{{ .UserName }}<#FFD700>@</><#76B900>{{ .HostName }}</>",
```

Gold `@`, green hostname.

**Show only the hostname** (drop the username and `@`):

```json
"template": "<#76B900>{{ .HostName }}</> ",
```

**Capitalize the displayed hostname** without changing the real one — pipe through `title`:

```json
"template": "<#76B900>{{ .HostName | title }}</> ",
```

This turns `spark` into `Spark` purely for display. The actual system hostname stays lowercase, so SSH, `/etc/hosts`, networking, and scripts are all unaffected. (Changing the real hostname with `hostnamectl` would touch all of those and is conventionally kept lowercase — so the `title` trick is the safe way to get a capitalized look.)

**Add a delimiter between host and path** — append a visible glyph (not a space) to the template:

```json
"template": "<#76B900>{{ .HostName | title }}</> <#838ba7>·</> ",
```

Gotcha worth knowing: spacing comes from **both** the session template's trailing whitespace **and** the path segment's leading whitespace. If your gap looks too wide, you've probably got a space on both sides — trim one. Inspect both with:

```bash
python3 -c 'import json,pathlib; d=json.loads((pathlib.Path.home()/".config/ohmyposh/catppuccin_frappe.omp.json").read_text()); [print(s["type"],"->",repr(s.get("template"))) for b in d["blocks"] for s in b["segments"] if s["type"] in ("session","path")]'
```

## The gotchas (a.k.a. things that cost me hours)

Saving you the debugging time:

### 1. Oh My Posh's prompt randomly disappears after reboot

**Symptom**: prompt reverts to Ubuntu default `flaviu@spark:~$`; `type _omp_hook` says "not defined."

**Cause**: Oh My Posh init runs before Atuin's bash-preexec init. When bash-preexec loads, it reorganizes `PROMPT_COMMAND` and discards the hook function.

**Fix**: Oh My Posh init goes **after** Atuin, **before** `ble-attach`. See ordering in the bashrc above.

### 2. `direnv: loading` message never appears

**Symptom**: You `cd` into a directory with `.envrc` but nothing happens. `type _direnv_hook` may even show the function.

**Cause**: The `eval "$(direnv hook bash)"` line is after `ble-attach`. By that point ble.sh has wrapped `PROMPT_COMMAND` in its own machinery, and direnv's hook silently fails to register.

**Fix**: Move direnv before `ble-attach`. (Same rule applies to anything that touches `PROMPT_COMMAND`.)

### 3. `Ctrl-R` opens the wrong history search (television, not atuin)

**Symptom**: `Ctrl-R` shows a TUI labeled `bash-history` with channel-style panes — that's **television**, not atuin's richer columnar view.

**Cause**: `tv init bash` binds both `Ctrl-T` and `Ctrl-R`, stomping fzf and atuin respectively.

**Fix**: Don't load `tv init bash` at all (the bashrc above omits it on purpose). Use `tv` as a command. If you genuinely prefer television's pickers, keep its init and instead disable the others' bindings: `eval "$(atuin init bash --disable-up-arrow --disable-ctrl-r)"` and remove fzf's `Ctrl-T` binding. Pick one owner per key.

### 4. `unknown option: --bash` on shell startup

**Cause**: Apt's `fzf` is too old (pre-0.48) and doesn't support the `--bash` flag.

**Fix**: Install fzf from source per the instructions above. Your `~/.local/bin/fzf` will shadow `/usr/bin/fzf` automatically because of PATH order.

### 5. `[ble: exit N]` after every failed command is too noisy

**Cause**: ble.sh's `exec_errexit_mark` is set by default to highlight non-zero exits.

**Fix**: `bleopt exec_errexit_mark=` to silence it (note: it's `errexit`, not `exit` — a confusing distinction that cost me 15 minutes alone). The bashrc above includes this line. To customize instead of silencing: `bleopt exec_errexit_mark='↳ %d'` or similar.

### 6. `-- MULTILINE --` mode appears unexpectedly

**Symptom**: After hitting Enter, the prompt shows `-- MULTILINE --` and a hint about `RET` and `C-j`. Bash is waiting for more input.

**Cause**: Your last typed command has something unbalanced — an unclosed quote, paren, brace, or a trailing `\`. Bash and ble.sh enter multi-line input mode until the construct is closed. This isn't a bug; it's the same behavior as vanilla bash's `>` continuation prompt, just dressed up.

**Fix**: Press **Ctrl-C** to abandon the input and get a fresh prompt. Or finish the construct (add the closing quote / paren / etc.) and press **Ctrl-J** to execute.

### 7. `bat: command not found` (or `fd: command not found`) inside fzf previews

**Cause**: The Ubuntu binaries are named `batcat` and `fdfind`, but our config references `bat` and `fd`.

**Fix**: The symlinks during install. Verify with `command -v bat` and `command -v fd`.

### 8. Oh My Posh shows squares and question marks

**Cause**: Your terminal isn't using a Nerd Font.

**Fix**: Install a Nerd Font (instructions above) and set your terminal's font preference. This is a manual step in your terminal app — Oh My Posh can't do it for you. (If it worked before and broke after a reboot, your terminal's font setting reverted — just re-select the Nerd Font.)

### 9. There's no NVIDIA glyph in Nerd Fonts

This came up when I wanted an NVIDIA logo next to my hostname. Material Design Icons (a Nerd Fonts source) deprecated most brand logos in v5, including NVIDIA. The path forward is to patch your own font with the SVG via FontForge (legal for personal use; do not redistribute). Or just use a green `●` — it's instantly recognizable as the NVIDIA aesthetic and works in any font.

### 10. After `apt remove neovim && snap install nvim`: `bash: /usr/bin/nvim: No such file or directory`

**Cause**: Bash's command hash cached the old path.

**Fix**: `hash -r` (or `exec bash`).

## Discovering ble.sh options

ble.sh has dozens of options that change behavior, none of which you'll guess at first try. The discovery loop:

```bash
bleopt | grep -i <keyword>     # find options matching a topic
bleopt name                    # show current value
bleopt name=value              # set (session-local; put in ~/.bashrc to persist)
```

Useful examples:

```bash
bleopt | grep -i complete      # all completion-related options
bleopt | grep -i prompt        # prompt rendering
bleopt | grep -i history       # history sharing/format
bleopt history_share=on        # share history live across all open shells
```

The full option reference lives at `~/ble.sh/note.md` (if you kept the source clone — see Maintenance below) or online at the [akinomyoga/ble.sh repo](https://github.com/akinomyoga/ble.sh/blob/master/note.md).

## Day-to-day commands

Once it's all wired up, the things you'll do most:

| Action                              | Command                          |
| ----------------------------------- | -------------------------------- |
| Fuzzy file picker                   | `Ctrl-T`                         |
| Fuzzy directory cd                  | `Alt-C`                          |
| Atuin history search                | `Ctrl-R`                         |
| Jump to a recent dir                | `z <fragment>`                   |
| Open file manager (cd on quit)      | `y`                              |
| Television file/text/repo search    | `tv` / `tv text` / `tv git-repos`|
| Open git TUI                        | `lazygit`                        |
| Open docker TUI                     | `lazydocker`                     |
| Render a markdown file              | `glow file.md`                   |
| Recursive content search            | `rg <pattern>`                   |
| Find files                          | `fd <pattern>`                   |
| View a file with syntax highlight   | `bat file.py`                    |
| Disk usage TUI                      | `ncdu /`                         |
| Pretty `df`                         | `duf`                            |
| Visual `du`                         | `dust`                           |
| Modern `ps`                         | `procs <pattern>`                |
| Mtr replacement                     | `mtr 1.1.1.1`  (aliased to trip) |
| DNS lookup                          | `doggo example.com`              |
| Generate passphrase                 | `diceware -n 6`                  |
| Auto-create + activate uv venv      | `cd project && uvenv && uv add …`|
| Auto-correct last command           | `fuck`                           |

## Maintenance

A few periodic things:

- **ble.sh updates**: `cd ~/ble.sh && git pull && make install PREFIX=~/.local`. The `~/ble.sh` directory is the **source clone** — the actual runtime files live at `~/.local/share/blesh/`. You can delete `~/ble.sh` and ble.sh keeps working, but keep it around for fast updates (~5 seconds vs. re-cloning) and offline access to docs.
- **fzf updates**: `cd ~/.fzf && git pull && ./install --all --no-update-rc`
- **LazyVim updates**: just run `:Lazy update` inside nvim
- **uv self-update**: `uv self update`
- **Oh My Posh**: `oh-my-posh upgrade` (or just rerun the install.sh)

Anything from apt updates with `sudo apt upgrade` like normal.

## Worth doing eventually

- Move your dotfiles (`.bashrc`, `.blerc`, `.bash_aliases`, `~/.config/ohmyposh/`, `~/.config/direnv/direnvrc`) into a tracked repo. [chezmoi](https://www.chezmoi.io/) or [stow](https://www.gnu.org/software/stow/) both work well — chezmoi is more featureful, stow is just symlinks. Saves you a weekend the next time you provision a machine. Just remember to gitignore `~/.bash_secrets`.
- Install [tmux](https://github.com/tmux/tmux) for persistent sessions. Pairs perfectly with `mosh` — sessions survive both network drops and laptop sleeps.
- If you ever flirt with switching: [Helix](https://helix-editor.com/) for an editor that takes Vim's modal editing in a new direction; [Zellij](https://zellij.dev/) for a more discoverable multiplexer than tmux. Both work fine alongside everything here.

## Closing thought

The whole point of investing in a setup like this isn't that the tools are individually life-changing — it's that the **friction between them** disappears. You stop typing paths because `Ctrl-T` is faster. You stop guessing which command you ran two weeks ago because Atuin filters your history by directory. You stop accidentally polluting your global Python because `direnv` activates the right venv the moment you `cd`. Each piece is small. The compounding effect is huge.

The one recurring theme in the gotchas above is **ownership** — one tool per `PROMPT_COMMAND` slot, one tool per keybinding, loaded in a deterministic order. Get that right and the whole stack is stable across reboots. Get it wrong and you'll chase ghosts.

Pretty matters too. You'll spend thousands of hours in this terminal. Make it nice to look at.


---
*Source: [https://vlaicu.io/posts/ubuntu-terminal-setup/](https://vlaicu.io/posts/ubuntu-terminal-setup/)*
