Tracking the working directory¶
Three places in Roost care what directory a tab is "in":
- the cwd a new tab inherits on
Alt-T/Cmd-T(and the command launcher), - the header subtitle under the project name,
- the tab label (until you rename the tab or the running program sets its own title).
What works out of the box¶
New tabs open where you are. On Alt-T / Cmd-T, Roost reads the active
tab's working directory directly — natively, via proc_pidinfo on macOS and
/proc on Linux — and spawns the new tab there. No shell configuration
required; works for any shell, including stock /bin/bash.
The read is of the tab's foreground job: the leader of the process group
running in the terminal's foreground. While the shell Roost started sits at its
prompt, that is the shell itself. Inside a nested interactive shell (bash,
nix develop, …) it is that shell, so a cd there is seen. A foreground program that changes its own
directory moves it too: git running under its pager reads as the
repository's root. tmux is the exception: the job is the tmux client, whose
directory is where you started it, so a cd inside a tmux pane still isn't
seen.
When the job's directory can't be read, or is no longer a directory — a job run
under sudo -s, say — Roost reads the shell it started instead. When that
gives no usable directory either, Roost uses the cwd the shell last reported
over OSC 7 (see the shell integration below).
Tabs on a session follow the same rule. For a tab on a roost-session —
Roost-Iced's local session, the fresh-install default, or a saved host — the
session reads the directory on its own machine. A session started by an older
Roost, still running after an upgrade, can't; until it restarts, a new tab
opens in the active tab's tracked cwd — where OSC 7 last put it, else where the
tab was opened (or at the host's home directory, if that path isn't there).
One caveat: a new tab's shell starts on the same machine as the active tab's —
this one for a local tab, the host's for a tab on a host — so if the active tab
has ssh'd somewhere else, the new tab opens in the directory ssh was run
from, not the remote one. The shell integration below still tracks the remote
cwd in the header and tab label: the remote shell emits OSC 7 over the
connection.
What the shell integration adds¶
Sourcing Roost's integration makes the shell emit OSC 7 on every prompt, which adds:
- the header subtitle following
cdlive, - the tab label following
cdlive, - remote (SSH) cwd tracking,
plus (optionally) a tidy default prompt when you don't already have one.
Fish emits OSC 7 natively, so it needs nothing.
How it loads¶
For zsh and modern bash (≥ 4.4) Roost loads the integration
automatically — no rc edit. It points the shell at the shipped script
(ZDOTDIR for zsh; --posix + ENV for bash), runs your normal startup files
first, then layers the integration on top, so your config still wins. Your real
startup files (aliases, prompt, PROMPT_COMMAND hooks) load exactly as they do
outside Roost.
Login vs. non-login shell (this decides which startup files run). Roost
follows the same platform split as Ghostty: a default tab is a login shell on
macOS (sources /etc/profile then the first of .bash_profile / .bash_login
/ .profile; .zprofile / .zlogin for zsh) and a non-login interactive
shell on Linux (sources /etc/bash.bashrc then ~/.bashrc; ~/.zshrc for
zsh). macOS GUI apps don't inherit the login PATH and the macOS world keeps
config in .bash_profile, so a login shell is expected there; on Linux the
desktop session already exports the login PATH, and config conventionally
lives in ~/.bashrc, which only a non-login shell sources. If you keep bash
config in ~/.bashrc but also have a ~/.bash_profile (some tool installers
drop one in), note that on macOS the login shell stops at .bash_profile and
never reads .bashrc unless .bash_profile sources it — the standard fix is to
add [ -f ~/.bashrc ] && . ~/.bashrc to ~/.bash_profile.
Two cases auto-loading can't reach, where you add one line to your rc instead:
- Apple's
/bin/bash(3.2) on macOS — itsENV/POSIX startup path is patched out, so Roost can't inject. Homebrew bash (or any bash ≥ 4.4) auto-loads, but only when it's your login shell: Roost reads$SHELL, not$PATH. See Switching macOS default to Homebrew bash below ifwhich bashshows Homebrew but default tabs still spawn Apple's. - zsh with a system
/etc/zshenvthat hard-setsZDOTDIR— it runs before Roost's shim and overrides it.
The manual line, safe in a shared dotfile (the $ROOST_TAB_ID guard makes it a
no-op outside Roost, and the script is idempotent if auto-loading already ran):
bash (~/.bashrc):
[ -n "$ROOST_TAB_ID" ] && [ -r "$ROOST_RESOURCES_DIR/shell-integration/roost.bash" ] \
&& source "$ROOST_RESOURCES_DIR/shell-integration/roost.bash"
zsh (~/.zshrc):
[[ -n "$ROOST_TAB_ID" && -r "$ROOST_RESOURCES_DIR/shell-integration/roost.zsh" ]] \
&& source "$ROOST_RESOURCES_DIR/shell-integration/roost.zsh"
Roost ships the scripts inside the app and points $ROOST_RESOURCES_DIR at them.
Switching macOS default to Homebrew bash¶
A common confusion: you've run brew install bash, and which bash returns
/opt/homebrew/bin/bash (5.x) — but a default Cmd-T tab still spawns Apple's
3.2 and falls back to the manual-source path above. The two commands answer
different questions:
which bashwalks$PATHand reports the first executable namedbash— Homebrew puts/opt/homebrew/binahead of/binviabrew shellenv, so this finds the modern one. It's "if I typebash, what runs?".$SHELLis your registered login shell — the account property thatchshsets, stored in macOS's directory service (dscl . -read /Users/$USER UserShell). It's "what shell does this user prefer?", which is what Roost, Terminal.app,cron,sudo -s, IDE terminals, and everything else asks when they need to spawn a shell. It doesn't follow$PATH.
Until you chsh, $SHELL stays at the registered value (Apple /bin/bash on
many older or migrated macOS accounts), so default tabs use Apple bash even
though which bash shows Homebrew. To make modern bash your login shell so
every default tab auto-bootstraps:
# 1. Allow it as a login shell (macOS keeps an allow-list).
grep -qx /opt/homebrew/bin/bash /etc/shells \
|| echo /opt/homebrew/bin/bash | sudo tee -a /etc/shells
# 2. Switch (prompts for your account password).
chsh -s /opt/homebrew/bin/bash
Then fully quit and relaunch Roost so the GUI process inherits the new
$SHELL from its parent environment. Verify in a fresh tab:
If you'd rather not switch your account default, the manual-source line above
works on Apple /bin/bash, and you can always open a single Homebrew-bash tab
on demand via roostctl tab open --argv /opt/homebrew/bin/bash --argv -l
(handy as a saved command-launcher entry).
Unsure whether a given tab's shell can even emit the OSC 133 mark?
roostctl doctor's shell.marks_capability check names it directly —
ok for zsh and bash ≥ 4.4, warn for Apple's bash 3.2 or any shell
with no shipped integration, which is exactly why the running dot won't light.
The scripts are gated on $ROOST_TAB_ID, idempotent, and interactive-only. They
emit OSC 7 (cwd) and OSC 0 (a ~-abbreviated path as the tab title), and set a
default prompt only when PS1 is unset or the shell's stock default.
Feature flags¶
$ROOST_SHELL_FEATURES is a comma list; prefix a feature with no- to disable
it. Default: cwd,title,marks,prompt,ssh-env.
cwd— emit OSC 7 (the working directory).title— set the tab title to the cwd.marks— emit OSC 133 command marks (these drive the tab's run-state dot).prompt— set a default prompt (only when you haven't set one).ssh-env— wrapsshso it adds-o "SendEnv COLORTERM TERM_PROGRAM TERM_PROGRAM_VERSION FORCE_HYPERLINK"to every invocation. Without this, macOS's defaultssh_configonly forwardsLANG LC_*—COLORTERMis silently dropped at the SSH boundary and modern TUIs (opencode, neovim with truecolor themes) fall back to 256-color and look washed out on the remote host. Equivalent to Ghostty'sshell-integration-features.ssh-env. Whether the remote accepts the forwarded vars depends on itssshd_config::AcceptEnvsetting; if the server rejects them, SendEnv is a silent no-op (no worse than current behavior). Scoped to baressh—scp,rsync, andgit pushuse their own binaries and aren't wrapped.
The flags are opt-out: every feature is on unless its no- form is present.
So to keep your own title and prompt, set
ROOST_SHELL_FEATURES=no-title,no-prompt in your rc — auto-loading re-sources
your rc first, so the override is picked up before Roost's hooks apply. The
same opt-out works for ssh-env: ROOST_SHELL_FEATURES=...,no-ssh-env to
disable.
Overriding a function instead of a flag (Roost-Iced)¶
On Roost-Iced, if you already define __roost_osc7, __roost_title,
__roost_marks (bash) or __roost_osc7, __roost_title, __roost_mark_c,
__roost_mark_d (zsh) yourself — say, sourced from your own rc before
Roost's integration loads — your definition wins and Roost's own is never
defined; the hook registration still calls the name, so your function is
what runs. This ignores no-title/no-marks etc. (the feature switches
live inside Roost's functions, which yours has replaced). On bash, if you
also add your function to PROMPT_COMMAND yourself, it runs twice — once
from your own registration, once from Roost's prepend; zsh's add-zsh-hook
is idempotent per function name, so a zsh hook you registered yourself does
not get a second entry. Use a name
Roost doesn't own (like __roost_fancy_title below) unless you mean to
replace the feature outright. The Swift Roost.app still defines its own
functions unconditionally and has no such override.
The environment Roost injects¶
Every shell Roost spawns sees:
| Variable | Meaning |
|---|---|
ROOST_TAB_ID |
the tab's id — gate your integration on this |
ROOST_SOCKET |
the IPC socket path (roostctl auto-detects it) |
ROOST_RESOURCES_DIR |
where the shipped scripts live (…/shell-integration/) |
ROOST_SHELL_INTEGRATION |
1 |
ROOST_SHELL_FEATURES |
feature flags (above) |
TERM_PROGRAM |
Roost (plus TERM_PROGRAM_VERSION) |
TERM |
xterm-256color |
COLORTERM |
truecolor — signals 24-bit color to TUIs (forwarded over SSH via ssh-env) |
FORCE_HYPERLINK |
1 — advertises OSC 8 hyperlink support so supports-hyperlinks-gated CLIs (Claude Code et al.) emit clickable links (forwarded over SSH via ssh-env) |
You don't have to set ROOST_SOCKET, ROOST_TAB_ID, or ROOST_RESOURCES_DIR —
Roost injects them. The full authoritative table is in
docs/reference/paths.md,
which also covers the internal-bootstrap vars (ZDOTDIR, ENV,
ROOST_BASH_*) that you shouldn't depend on from user code.
Fancier: a git-aware title with a 🐓¶
Want the tab label to show a status icon + branch instead of just the path? Set
ROOST_SHELL_FEATURES=no-title (so the shipped title stays out of its way) and
add this to your rc — its __roost_fancy_title becomes the only thing setting
the title. 🐓 = clean tree or outside a repo, 🐣 = dirty tree.
__roost_fancy_title() {
[ -n "$ROOST_TAB_ID" ] || return
local icon="🐓" branch title
case "$PWD" in
"$HOME") title="~" ;;
"$HOME"/*) title="~${PWD#"$HOME"}" ;;
*) title="$PWD" ;;
esac
if branch=$(git symbolic-ref --short HEAD 2>/dev/null); then
[ -n "$(git status --porcelain 2>/dev/null)" ] && icon="🐣"
title+=" (${branch})"
fi
printf '\033]0;%s\033\\' "${icon} ${title}"
}
PROMPT_COMMAND="__roost_fancy_title;${PROMPT_COMMAND}"
The $HOME → ~ abbreviation uses a case + prefix-strip rather than the
tempting ${PWD/#$HOME/~}, because that one-liner isn't portable: macOS's
/bin/bash (3.2) treats the replacement ~ as a literal, but bash ≥ 5.2
tilde-expands it back to $HOME — a silent no-op that shows the full
/home/you/... path. Escaping it (\~) flips the breakage to the other
version. The case form is correct on both and leaves non-home paths untouched.
The git status --porcelain runs once per prompt; on very large repos with a
slow disk that's worth knowing about.
What lights up¶
| Surface | Behavior |
|---|---|
| New-tab cwd | Follows the active tab's dir (native read), local or session — integration or not. |
| Header subtitle | Follows cd once OSC 7 is flowing (shell integration). |
| Tab label | Same — unless you renamed the tab or the running program set its own title (it wins). |
If the program in a tab sets its own title (vim, ssh, claude), that shows and the subtitle still tracks the raw cwd. A manual rename sticks regardless.