Files
tty7/docs/reference/shell-integration.mdx
T
l0ng-ai 4af4452900 docs(shell-integration): explain why a nested shell stops reporting its cwd (#698)
tty7 injects shell integration into the shell it launches for a pane, and only
that one. A shell the user starts by hand afterwards -- `zsh` typed at a bash
prompt -- is a new process nobody injected into, so it emits no OSC 7 and no
OSC 133, and the pane keeps showing whatever directory the outer shell reported
last.

On a local machine that is invisible, because oh-my-zsh reports OSC 7 itself
from lib/termsupport.zsh. Over SSH it is not: that file returns early when
SSH_CLIENT or SSH_TTY is set, which sshd always sets, so on a remote host
neither oh-my-zsh nor tty7 reports the directory of a hand-started zsh. That
combination is the whole of #698, and it is why the reporter sees it work over
SSH under bash and stop under zsh.

Documented in two places: a "Shells you start yourself" section in the shell
integration reference, and a troubleshooting entry that names the symptom the
way a user would ("the directory stopped following my shell"). Both give the
two remedies -- `chsh -s /bin/zsh` on the remote host, which is what moves the
integration into the shell tty7 bootstraps, and, when the login shell cannot be
changed, a four-line precmd hook that reports OSC 7 by hand. The entry also
says why a login script or `exec zsh` from .bashrc is not a fix: both run after
(or instead of) the shell tty7 set up.

The same pages now describe the process-inspection fallback honestly. It was
one parenthetical -- "tty7 falls back to inspecting the process" -- which
overpromises: it exists only where the daemon can see the pane's processes, so
a local macOS/Linux pane or a remote-workspace pane gets it and an SSH pane or
a Windows pane does not, and it is a poll driven by pane output at most twice a
second, not a report, so it trails a `cd`. That poll is the mechanism behind
the issue's second, unconfirmed half: with tty7-server on the remote host the
daemon is on that host, so a nested zsh's cwd does get picked up out of
/proc -- late, and only when the pane writes something.

Deliberately no code. Propagating the integration into a shell the user starts
by hand would mean exporting ZDOTDIR (or rewriting the user's startup files)
from every pane, which leaks into every zsh in the session including scripts,
and the remote bootstrap deletes its throwaway ZDOTDIR at the first prompt
precisely because an SSH session has no reliable exit hook -- a directory that
outlives its deletion is exactly what would break a nested shell rather than
help it. Tightening the cwd poll for uninstrumented panes is a real
possibility, but it is a Linux-only daemon path that cannot be exercised from
here, for a symptom nobody has yet reproduced; the mechanism is written down
instead.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-09 17:59:29 +08:00

116 lines
6.0 KiB
Plaintext

---
title: "Shell integration"
description: "What tty7 injects into your shell, and what it buys you."
---
A terminal that only sees bytes cannot tell a prompt from output, or a finished
command from a hung one. tty7's shell integration closes that gap: the shell
reports where prompts begin, what was submitted, what it exited with, and where
it is.
**You do not install it.** It is injected when the pane's shell starts, and
removes itself from the equation if you run the same shell elsewhere.
## Which shells
| Shell | How it is injected |
|---|---|
| **zsh** | A throwaway `ZDOTDIR` whose files source yours first, then tty7's. Your `TTY7_USER_ZDOTDIR` is preserved. |
| **bash** | An rcfile that sources your own first. |
| **fish** | A `-C` init command. |
| **PowerShell** | An encoded init command that wraps your existing `prompt` function and PSReadLine's line reader. |
| **WSL** *(Windows)* | The distro's shell is bootstrapped with the same scripts. |
| **Remote panes** | The same three POSIX shells, bootstrapped over the SSH connection. Toggle per profile with **Settings → SSH → Session → Shell integration**. |
`TTY7_SHELL_INTEGRATION` is set once it is active, and guards against a second
injection when shells nest. It does not put the integration *into* a nested
shell — [shells you start yourself](#shells-you-start-yourself) have none.
<Note>
A shell launched with arguments *you* wrote — `shell.args`, or a
`custom_shells` entry — is left alone, because tty7's injection would
conflict with the flags you chose. Arguments tty7's own detection supplied
(Git Bash's `-i -l`, a WSL row's `--distribution`) do not count, so those
rows are still integrated.
</Note>
## What it reports
| Signal | Sequence | Used for |
|---|---|---|
| Prompt begins / input begins | `OSC 133;A`, `133;B` | The [prompt layer](/terminal/prompt): suggestions, completion, multi-line editing |
| Command submitted | `OSC 133;C` | Knowing a command is running; agent detection on Windows, where ConPTY exposes no foreground process group |
| Command finished, with exit code | `OSC 133;D` | The "finished after 42s" notification, failure marks in [history](/terminal/history) |
| Working directory | `OSC 7` | New tabs and splits opening in the right place, the sidebar's repo grouping, the git branch readout |
| Editing mode (vi / emacs) | `OSC 133;V` | Matching tty7's key handling to your shell's mode |
| Window title | `OSC 0` | Tab labels. Only PowerShell is given this — zsh, bash, and fish already set a title of their own, and tty7 reads whatever they emit |
## What turns off without it
Run a shell tty7 does not integrate with, and everything below still works —
it just falls back to less precise sources:
- Ghost suggestions, the completion menu, and <kbd>⌃ R</kbd>'s fuzzy history
- "Command finished" notifications and the failure marks in history search
- Exact working-directory tracking
Panes, splits, scrollback, search, SSH, and the CLI are unaffected.
The working-directory fallback is worth knowing in detail, because it is not
available everywhere. Where tty7 can see the pane's processes it reads the
foreground one's own working directory instead — a local pane on macOS or
Linux, or a pane in a remote workspace, where the `tty7-server` on that host
does the reading. It is a poll, not a report: it is sampled while the pane is
producing output, at most twice a second, so it can trail a `cd` until the
pane writes something again. A local pane on **Windows** and a pane connected
to an **SSH host** have no fallback at all — on the far end of an `ssh` there
is no process table to read, so a shell that does not report its directory
leaves the pane showing the last directory that did.
## Shells you start yourself
The injection happens when the pane's shell is *launched*. Type `zsh` at a
bash prompt — or `bash`, or `sh`, or `docker exec … sh` — and that second
shell is a new process, launched by your shell rather than by tty7. It emits no
prompt marks and no `OSC 7`, and the pane keeps showing the directory the
outer shell last reported until you exit back to it.
On your own machine this is usually invisible, because prompt frameworks
report the directory themselves: oh-my-zsh does it from
`omz_termsupport_cwd` in `lib/termsupport.zsh`. **Over SSH it is not.** That
file returns early when `SSH_CLIENT` or `SSH_TTY` is set, so on a remote host
a zsh you started by hand reports nothing at all, and neither does tty7.
To get integration into that shell, make it the shell tty7 starts:
- **A remote host.** tty7 asks the host for `$SHELL` when it connects and
bootstraps that shell, so `chsh -s /bin/zsh` (then reconnect) is what
switches it. A login script under **Advanced → Session** that runs `zsh`
does *not* — those lines are typed into the session after the shell is
already up, so they nest like anything else. Neither does `exec zsh` from
your `.bashrc`: it replaces the process tty7 set up.
- **A local pane.** Set `shell` in [config](/reference/configuration), and
leave `args` out — arguments you write turn the injection off.
[If the file panel stopped following a nested shell →](/reference/troubleshooting#the-directory-stopped-following-my-shell)
## Per-pane history
When `per_pane_history` is on, the integration is also what makes it work. It
runs *after* your own rc file — which is the only reason it can: `$HISTFILE` is
yours to set, wherever you like, and nothing outside the shell knew where it
pointed until then.
The sequence is: seed the pane's private file from your real history so it does
not start blank, record how much was seeded, repoint `$HISTFILE`, and merge
everything past that mark back when the pane closes.
[More about history →](/terminal/history#one-history-or-one-per-pane)
## Remote shells
For a remote workspace or an SSH pane, the same scripts are sent over the
connection at login, so a remote pane reports its cwd, exit codes, and prompt
marks exactly like a local one. Turn it off for a particular host under that
profile's **Advanced → Session**.