AGENTS.md #
This file provides guidance to agents when working with code in this repository.
What this is #
Tide is an asynchronous prompt for the Fish shell, installed as a Fisher plugin. This is plttn/tide, a fork of IlanCosman/tide. All work here targets plttn/tide — never open PRs against upstream IlanCosman/tide.
Everything is written in Fish script (.fish); there is no build step or compiled artifact.
Commands #
All tasks are run via mise (use mise, not make):
mise run fmt— format all.fishfiles withfish_indentmise run lint— syntax-check all.fishfiles viafish --no-executemise run install— install Tide + test deps (fisher, clownfish) into the current Fish configmise run test [FILE...]— run the littlecheck suite, or only the given test files (scripts/test.fish)mise run test-clean— wipe the cached local testHOME(scripts/test_clean.fish) if the test env gets into a bad state
Running a single test #
Tests are littlecheck .fish files under tests/, each a self-contained script with # CHECK: comments asserting stdout. Pass one or more to mise run test to run only those:
mise run test tests/_tide_item_node.test.fish
Never run python3 littlecheck.py on a test file yourself. Test files set universal variables (set -U tide_left_prompt_items ...) and funcsave helpers, so outside the test HOME they overwrite the real fish config and break the user's prompt. mise run install also writes to the real config, so don't use it to verify changes either.
A local run reuses a persistent HOME ($XDG_CACHE_HOME/tide-test-home or ~/.cache/tide-test-home) so fisher/clownfish aren't reinstalled every run; each run still installs the working copy into it. In CI (GITHUB_ACTIONS=true) this caching is skipped and everything runs in a clean environment.
Run the suite as a non-root user. tests/_tide_item_pwd.test.fish expects / to be unwritable (so it gets the unwritable-directory icon), and that check fails as root, e.g. in some containers and sandboxes.
Test files mock external commands (e.g. node, git) via clownfish's mock function — see tests/_tide_item_node.test.fish for the pattern. tests/test_setup.fish defines _tide_decolor (strips ANSI codes for assertions) and sets _tide_side for right-prompt items.
Debugging the async render path #
Timing bugs in the render path — a stale prompt, a missed repaint, two renders racing — usually can't be seen by calling fish_prompt from a script, since the bug is in when fish renders rather than in what one render prints. scripts/prompt_probe/probe.py drives a real interactive fish in a pty (isolated HOME, keystrokes, window resizes) and can log every render, dispatch and signal handler run. It is a manual tool, not part of mise run test; see scripts/prompt_probe/README.md.
Architecture #
Item-based prompt composition #
The prompt is built from items — one function per prompt segment, named _tide_item_<name>.fish in functions/. Each item function:
- Reads config from user-exposed
tide_<item>_*variables (colors, icons, thresholds) - Detects whether it's relevant (e.g.
_tide_item_nodechecks$_tide_parent_dirsforpackage.jsonand thatnodeis on PATH) and prints nothing if not - Calls
_tide_print_item <name> <content...>(functions/_tide_print_item.fish) to emit its segment, which handles background/foreground coloring and separators between adjacent items based ontide_<side>_prompt_items
Which items appear and in what order is controlled by the universal variables tide_left_prompt_items / tide_right_prompt_items, set by the configuration wizard or by hand.
Rendering pipeline #
functions/fish_prompt.fish is the entry point Fish calls to render a prompt. On each render it:
- Kicks off a background job (a second
fish -c ...subprocess) that computes the actual prompt content by calling_tide_1_line_promptor_tide_2_line_prompt(chosen at load time based on whethernewlineis in$_tide_left_items), renders it into a private scratch tmpfile named after its own pid, atomically renames it into place, then signals the parent withSIGUSR1 - Immediately returns a (possibly stale) previous render so the shell never blocks
_tide_refresh_prompt(an--on-signal SIGUSR1handler) reads the tmpfile into a session-local global variable (_tide_prompt_$fish_pid) and callscommandline -f repaint, which re-rendersfish_prompt/fish_right_promptwith the fresh content
If SIGUSR1 delivery can't be confirmed at shell init (some sandboxes/containers swallow signals — checked once via a SIGUSR2 self-test), rendering falls back to fully synchronous instead of spawning a background job that could never notify completion.
This async design is why Tide can afford expensive checks (e.g. full git status with untracked/modified/deleted counts) that would make a synchronous prompt feel slow. fish_prompt.fish itself is built with a large eval "..." block assembled once at shell-init time (varies by one-line vs two-line, frame-enabled vs not) rather than branching on every render, since load time matters more than per-render branching cost here.
_tide_1_line_prompt.fish / _tide_2_line_prompt.fish iterate the configured items and concatenate their _tide_print_item output per side.
VCS item internals #
_tide_item_vcs.fish dispatches between git and jj (Jujutsu) backends:
_tide_internal_jj_git.fishwalks up from$PWDlooking for a.jjdirectory to decide if this is a jj repo (and whether.disable-jj-promptopts back out)- If in a jj repo and the
jjbinary is available,_tide_internal_vcs_jj.fishrenders; otherwise_tide_internal_vcs_git.fishhandles git (and jj-repos-backed-by-git without the jj CLI)
The git backend minimizes subprocess spawns (currently 3 git invocations) since each is a real fork/exec cost inside an already-forked background job.
Configuration wizard #
tide configure (functions/_tide_sub_configure.fish) is a self-contained interactive TUI, not part of the render path. It walks a decision tree defined by .fish files under functions/tide/configure/choices/{all,lean,classic,powerline,rainbow}/** and functions/tide/configure/functions/, previewing changes live using fake_columns/fake_lines and no-op stand-ins for every _tide_item_* function. See ARCHITECTURE.md for the full choice flowchart. Chosen values are written out via functions/tide/configure/configs/.
Subcommands #
tide (functions/tide.fish) dispatches to _tide_sub_<name> functions (configure, reload, bug-report) — adding a subcommand means adding a new _tide_sub_<name>.fish.
Init / lifecycle hooks #
conf.d/_tide_init.fish wires up Fisher's _tide_init_install / _tide_init_update / _tide_init_uninstall events — first-install onboarding, version-migration shims (e.g. _tide_migrate_vcs_prompt_items), and cleanup of all tide_*/_tide_* functions and universal variables on uninstall.
Conventions #
(Full detail in CONTRIBUTING.md — key points below.)
- Style: prefer
testover[ ]; prefer&&/||overand/orfor simple conditionals,if/else/else iffor anything more complex; prefer piping over command substitution when it doesn't require extra commands. - Naming: everything
snake_case. User-facing names (variables, functions, files) are prefixedtide_; internal-only names are prefixed_tide_. Items live in_tide_item_<name>.fish; subcommands in_tide_sub_<name>.fish. - PR titles must follow Conventional Commits (
<type>[(scope)][!]: <description>) — PRs are squash-merged, so the PR title becomes the commit message onmain, and release-please reads it to generate the changelog. This is CI-enforced. - Breaking changes (
!) should be very rare, since they bump the major version. A new default look is afeatwith a callout in the PR description. Where possible, give new defaults only to new installs: set them infunctions/tide/configure/configs/and keep the old behavior when the variable is unset, so updates don't change existing setups. - Releases are fully automated by release-please; don't hand-edit
functions/tide.fish's version string or the floatingvN/vN.Mtags.