From 55a8abc1b8599b7ccedca188109ed69701d3f0c7 Mon Sep 17 00:00:00 2001 From: sha1n Date: Sun, 4 Oct 2026 17:30:54 +0300 Subject: [PATCH] feat(prompt): load the starship prompt load.zsh now initialises Starship through include/prompt. STARSHIP_CONFIG points at config/starship.toml in the repo, so edits apply at the next prompt without a link. Init is skipped when starship is not on PATH or the terminal is dumb. The git modules skip submodules, so they stay within Starship's command_timeout. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 7 ++-- config/starship.toml | 69 ++++++++++++++++++++++++++++++++++++++ include/exports | 3 ++ include/prompt | 9 +++++ load.zsh | 1 + tests/env.test.sh | 80 ++++++++++++++++++++++++++++++++++++++++---- 6 files changed, 160 insertions(+), 9 deletions(-) create mode 100644 config/starship.toml create mode 100644 include/prompt diff --git a/AGENTS.md b/AGENTS.md index ca8effd..e8eb133 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,9 +14,10 @@ Personal Zsh configuration repository: shell environment, dotfiles, aliases, fun 1. `~/.zshrc` sources `load.zsh` (added by `install.sh`) 2. `load.zsh` orchestrates everything: - Locale, zsh options, `path-ethic` plugin (synchronous — needed at startup) - - Sources all `include/` files: exports, mise, aliases, functions, keybindings, completions, history + - Sources all `include/` files: exports, mise, aliases, functions, keybindings, completions, history, prompt - **`include/mise` runs synchronously right after `include/exports`** — runtime `PATH` must be correct at the first prompt, so never defer it - **fzf shell integration loads at the end of `include/keybindings`**, before `fzf-tab` — `fzf-tab` must be the last plugin to bind `^I` + - **`include/prompt` runs synchronously after `include/history`** so the first prompt is Starship's; it is skipped when `starship` is not on `PATH` or `TERM` is `dumb`. Starship's `PROMPT` is a `$(…)` expansion, so `setopt prompt_subst` in `load.zsh` is required - **Deferred plugins** (via `zsh-defer`, interactive sessions only): `zsh-history-substring-search`, `zsh-autosuggestions`, `fzf-tab`, `zsh-syntax-highlighting` — these load after the prompt appears - **Non-interactive fallback**: deferred plugins load synchronously when `[[ ! -o interactive ]]` (e.g., tests) - **`zsh-syntax-highlighting` MUST be loaded LAST** (after all other plugins and keybindings, even when deferred) @@ -31,7 +32,7 @@ Personal Zsh configuration repository: shell environment, dotfiles, aliases, fun - **`dotfiles/`** — Symlinked to `$HOME` during install (not copied — changes are live). The repo has no `.gitignore` of its own — ignore rules go in `dotfiles/.gitignore_global` - **`agents/`** — Global Agent instructions (`AGENTS.md`). `install.sh` symlinks agent configuration files (e.g. `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`) to this file. If a target already exists, the script prompts `[n/Y]` before replacing it (default Yes, also at end of input); an existing correct link is left untouched - **`brew/Brewfile`** — The only Brewfile: every Homebrew package of the three machine profiles (`essentials`, `dev`, `workstation`), one line each, each inside its `if profiles.include?("")` block. The profiles come from `HOMEBREW_PROFILE_INSTALL_PROFILES` (unset or empty means every profile, so that a plain `brew bundle cleanup` never treats a profile's packages as unlisted; `profile install` always sets it; the profiles form one chain in the order of `known`, so applying `dev` also applies `essentials`, and applying `workstation` also applies `dev` and `essentials`); the `HOMEBREW_` prefix is required because the `brew` wrapper passes no other variables to the Brewfile. `include/exports` exports `HOMEBREW_BUNDLE_FILE` with this file's path, so a plain `brew bundle` reads it from any directory; `scripts/profile` still passes `--file` on every `brew bundle` call, so it does not depend on the shell environment. Keep the file to Ruby logic that Homebrew documents for Brewfiles (no processes, no loading of other files). No language runtimes here — mise owns them -- **`config/`** — Config files with nested targets (`install.sh` links dotfiles by basename only, so they cannot live in `dotfiles/`). `config/mise/profile.toml` holds the runtime versions and mise settings; `install.sh` links it to `~/.config/mise/conf.d/profile.toml` (not `config.toml`, which `mise use -g` writes to). `config/nvim/` is the nvim config (`init.lua` and the committed `lazy-lock.json`); `install.sh` links the directory as a whole to `~/.config/nvim`, so lazy.nvim writes the lockfile into the repo, and moves an old-layout `~/.config/nvim` (a directory whose `init.lua` links into the repo) to `~/.config/nvim.bak.` first +- **`config/`** — Config files with nested targets (`install.sh` links dotfiles by basename only, so they cannot live in `dotfiles/`). `config/mise/profile.toml` holds the runtime versions and mise settings; `install.sh` links it to `~/.config/mise/conf.d/profile.toml` (not `config.toml`, which `mise use -g` writes to). `config/starship.toml` is the Starship prompt config; `include/exports` points `STARSHIP_CONFIG` at it, so it needs no link. `config/nvim/` is the nvim config (`init.lua` and the committed `lazy-lock.json`); `install.sh` links the directory as a whole to `~/.config/nvim`, so lazy.nvim writes the lockfile into the repo, and moves an old-layout `~/.config/nvim` (a directory whose `init.lua` links into the repo) to `~/.config/nvim.bak.` first - **`bootstrap.sh`** — One-command new-Mac setup (`curl … | zsh -s -- [essentials|dev|workstation]`): Homebrew, clone, `install.sh`, then `scripts/profile install`. At the repo root, not in `scripts/`, so it is not a command on `PATH`. Add no install logic here; it belongs in `scripts/profile` or `install.sh` - **`zsh-plugins/`** — Git submodules - **`tests/`** — Test suite using `zsh-scriptest` submodule @@ -71,7 +72,7 @@ Tests use the `zsh-scriptest` framework (submodule in `tests/`). Test files must - Test functions are called sequentially at the bottom of the file (not auto-discovered). - Test files must be executable — the runner silently skips a non-executable file. - Call `cleanup` before `finish_tests`: `finish_tests` exits on failure, and a skipped cleanup leaves the fingerprint behind for the next file. -- Run child shells with `env -i HOME="$HOME" PATH=… TERM=dumb` so variables exported by the developer's own shell cannot mask what `load.zsh` exports. +- Run child shells with `env -i HOME="$HOME" PATH=… TERM=dumb` so variables exported by the developer's own shell cannot mask what `load.zsh` exports. Only a test of `include/prompt` sets another `TERM`, because the prompt is skipped on a dumb terminal. - See `tests/sanity.test.sh` for the canonical pattern. CI runs on **ubuntu-latest** and **macos-latest** (GitHub Actions, `.github/workflows/ci.yml`). diff --git a/config/starship.toml b/config/starship.toml new file mode 100644 index 0000000..a2b1e43 --- /dev/null +++ b/config/starship.toml @@ -0,0 +1,69 @@ +"$schema" = 'https://starship.rs/config-schema.json' + +# Each git module after the branch starts with its own space, so the group ends with one space only in a repo. +format = """ +$username$hostname$directory\ +($git_branch$git_commit$git_state${custom.git_files}$git_metrics$git_status )\ +$all""" + +palette = 'git' + +[palettes.git] +branch = '#b8c084' +muted = '#8b98a0' +added = '#8fa83c' +deleted = '#d9473f' + +[aws] +disabled = true + +[gcloud] +disabled = true + +[git_branch] +symbol = ' ' +style = 'fg:branch' +format = '[$symbol$branch(:$remote_branch)]($style)' + +[git_commit] +style = 'fg:branch' +format = '[ \($hash$tag\)]($style)' + +[git_state] +style = 'fg:yellow' +format = '[ \($state( $progress_current/$progress_total)\)]($style)' + +# Starship has no variable for the total number of changed files. +# --ignore-submodules=dirty skips the scan of every submodule's working tree, which +# can push the module past command_timeout; a moved submodule commit still counts. +[custom.git_files] +description = 'Number of changed files, staged, unstaged or untracked' +when = true +require_repo = true +shell = ['sh'] +command = 'n=$(git status --porcelain --ignore-submodules=dirty | wc -l | tr -d " "); [ "$n" -eq 0 ] || echo "$n"' +style = 'fg:muted' +format = '([ ± $output]($style))' + +# Untracked files count in ± N but add no lines here: git_metrics diffs tracked files against HEAD. +[git_metrics] +disabled = false +ignore_submodules = true +format = '( [•](fg:muted)( [+$added](fg:added))( [-$deleted](fg:deleted)))' + +# File counts come from custom.git_files; the stash, conflict and remote state left here needs no submodule scan. +[git_status] +ignore_submodules = true +format = '($all_status$ahead_behind)' +conflicted = '[ =${count}](bold fg:red)' +stashed = '[ ≡${count}](fg:purple)' +deleted = '' +renamed = '' +modified = '' +staged = '' +typechanged = '' +untracked = '' +ahead = '[ ⇡${count}](fg:added)' +behind = '[ ⇣${count}](fg:deleted)' +diverged = '[ ⇡${ahead_count}⇣${behind_count}](bold fg:yellow)' +up_to_date = '' diff --git a/include/exports b/include/exports index 6e5f0db..552ac07 100644 --- a/include/exports +++ b/include/exports @@ -16,6 +16,9 @@ PATH="$PATH:$HOME/go/bin" # Homebrew: a plain `brew bundle` reads the repo Brewfile from any directory. export HOMEBREW_BUNDLE_FILE="$SHA1N_PROFILE_HOME/brew/Brewfile" +# Starship reads the repo config directly, so no ~/.config/starship.toml link is needed. +export STARSHIP_CONFIG="$SHA1N_PROFILE_HOME/config/starship.toml" + # Backup export BACKUP_DST_PATH="/Volumes/Backup/latest" export BACKUP_SRC_PATH="/Volumes/Content" diff --git a/include/prompt b/include/prompt new file mode 100644 index 0000000..5b4c577 --- /dev/null +++ b/include/prompt @@ -0,0 +1,9 @@ +#!/usr/bin/env zsh + +# Starship errors on a dumb terminal at init and at every prompt, which no stderr redirect here can cover. +if (( $+commands[starship] )) && [[ $TERM != dumb ]]; then + () { + local starship_init + starship_init="$(starship init zsh 2>/dev/null)" && eval "$starship_init" + } +fi diff --git a/load.zsh b/load.zsh index d14261d..6fc5ef2 100644 --- a/load.zsh +++ b/load.zsh @@ -30,6 +30,7 @@ source $SHA1N_PROFILE_HOME/include/functions source $SHA1N_PROFILE_HOME/include/keybindings source $SHA1N_PROFILE_HOME/include/completions source $SHA1N_PROFILE_HOME/include/history +source $SHA1N_PROFILE_HOME/include/prompt # fzf-tab: after compinit (include/completions), before zsh-syntax-highlighting if [[ -o interactive ]]; then diff --git a/tests/env.test.sh b/tests/env.test.sh index f7a74bd..ec43988 100755 --- a/tests/env.test.sh +++ b/tests/env.test.sh @@ -19,14 +19,14 @@ load_and_eval() { } eval_load_eval() { - local search_path="$1" before="$2" after="$3" - env -i HOME="$HOME" PATH="$search_path" TERM=dumb \ + local search_path="$1" before="$2" after="$3" term="${4:-dumb}" + env -i HOME="$HOME" PATH="$search_path" TERM="$term" \ "$zsh_bin" -f -c 'eval "$2"; source "$1"; eval "$3"' _ "$load_script" "$before" "$after" } load_stderr() { - local search_path="$1" - env -i HOME="$HOME" PATH="$search_path" TERM=dumb \ + local search_path="$1" term="${2:-dumb}" + env -i HOME="$HOME" PATH="$search_path" TERM="$term" \ "$zsh_bin" -f -c 'source "$1" 2>&1 >/dev/null' _ "$load_script" } @@ -34,6 +34,18 @@ new_stub_dir() { mktemp -d "${TMPDIR:-/tmp}/env_test_stubs.XXXXXX" } +write_starship_stub() { + local stub_dir="$1" init_status="$2" + cat >"$stub_dir/starship" < " +} + +function test_starship_skipped_on_dumb_terminal() { + test_case_title + + local stub_dir="$(new_stub_dir)" + write_starship_stub "$stub_dir" 0 + + local prompt="$(eval_load_eval "$stub_dir:$system_path" "PROMPT='sentinel> '" 'print -r -- "$PROMPT"' dumb)" + + rm -rf "$stub_dir" + + assert_equal "$prompt" "sentinel> " +} + +function test_starship_init_failure_is_silent() { + test_case_title + + local stub_dir="$(new_stub_dir)" + write_starship_stub "$stub_dir" 1 + + local prompt="$(eval_load_eval "$stub_dir:$system_path" "PROMPT='sentinel> '" 'print -r -- "$PROMPT"' xterm-256color)" + local err="$(load_stderr "$stub_dir:$system_path" xterm-256color)" + + rm -rf "$stub_dir" + + assert_equal "$prompt" "sentinel> " + assert_empty "$err" +} + function test_no_theme_functions() { test_case_title @@ -140,11 +204,15 @@ function test_no_theme_functions() { setup run_test test_pip_require_virtualenv run_test test_homebrew_bundle_file +run_test test_starship_config run_test test_go_bin_on_path run_test test_mise_activated run_test test_mise_absent_is_silent run_test test_fzf_without_zsh_flag_is_silent -run_test test_prompt_not_set +run_test test_prompt_not_set_without_starship +run_test test_starship_init_sets_prompt +run_test test_starship_skipped_on_dumb_terminal +run_test test_starship_init_failure_is_silent run_test test_no_theme_functions cleanup finish_tests