Add SelectNextTab / SelectPrevTab, which move to the neighbouring tab in
the order the strip or sidebar shows them, wrapping, with no popup.
Defaults: Cmd+Shift+] / Cmd+Shift+[ on macOS, Ctrl+PgDn / Ctrl+PgUp
elsewhere. The tmux preset's prefix n / p now bind these (tmux
next-window semantics) instead of the MRU switcher, which never
auto-committed there.
NextTab / PrevTab keep their config names and Ctrl+Tab, but are labelled
Recent Tab Switcher on the Keybindings page; the palette's Next/Previous
Tab entries now show the SelectNextTab chord they actually run.
The install guide said only releases up to 26.8.2 needed it, but 26.8.3
through 26.9.2 were built the same way and import VCRUNTIME140.dll too.
Also correct the CI comment: cc-built code follows +crt-static by itself;
what can bring the import back is a prebuilt native library or RUSTFLAGS.
Every Windows binary tty7 has ever shipped imports `VCRUNTIME140.dll`. That
file is not part of Windows; it arrives with the "Visual C++ 2015-2022
Redistributable", which Visual Studio, the GitHub runners and most developer
machines install as a side effect of something else. On a machine that has
never installed it the loader fails before `main` — no window, no log, no
crash report, just
The code execution cannot proceed because VCRUNTIME140.dll was not found.
which is how 26.8.2 failed winget's install validation
(microsoft/winget-pkgs#415841).
Confirmed against the installed 26.x `tty7-app.exe` with
`dumpbin /dependents`: `VCRUNTIME140.dll` plus nine `api-ms-win-crt-*`
entries, and the same in `tty7.exe` and `tty7-updater.exe`. The UCRT half is
in-box from Windows 10 on and never was the problem; VCRUNTIME140 is the one
piece that has to come from the redistributable. The bundled ConPTY pair is
already CRT-static, so it was never implicated — only our own three binaries
are.
`-C target-feature=+crt-static` for the MSVC targets, in `.cargo/config.toml`
rather than in the release workflow, so CI's Windows `build & test` job
compiles under the same flag a release does and a dependency that cannot link
statically fails a pull request instead of a tag. The alternative — declaring
`Microsoft.VCRedist.2015+.x64` in the winget manifest, or shipping the DLLs
beside the exe — was rejected: it leaves the portable zip, the GitHub release
and every non-winget install path broken, this repository publishes no winget
manifest to carry the declaration, and the winget PR shows the declared
dependency did not actually resolve the failure. Static linking removes the
requirement instead of documenting it.
The regression is invisible to everyone who could catch it, because every
machine that builds tty7 has the redistributable, so
`assert-no-vcruntime.ps1` reads the PE import and delay-load tables directly
(no `dumpbin`, which would re-introduce the same "my machine has Visual
Studio" assumption) and fails on any VC++ redistributable import. It runs in
CI on the Windows debug build and, via `verify-windows-package.ps1`, over
both shipped payloads in release and nightly.
Verified on Windows 11 x86_64 with MSVC 14.44: a full
`cargo build --release --locked --target x86_64-pc-windows-msvc` links
cleanly, and `dumpbin /dependents` on the resulting `tty7-app.exe` shows 29
imports, all in-box — no `VCRUNTIME140.dll` and no `api-ms-win-crt-*` at all.
Same for `tty7.exe` and the `--features updater` `tty7-updater.exe`.
`cargo test --release -p tty7-core` under the flag: 1223 passed, 4 failed,
those four being the `remote_link`/`router` tests that fail on a clean tree
on this machine too.
Fixes#902
Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
Alt+1..9 are vim's tab keys, and tty7 takes all nine for Go to Tab.
Two things stood between the reporter and getting them back.
**Nothing in the app could leave an action unbound.** Backspace on a
Keybindings row that has recorded nothing *reset* the row — dropped the
override so the action gets its shipped chord back. On a row nobody had
overridden, which is every row the first time it is looked at, that is a
no-op: pressing it over Alt+1 left Alt+1 sitting exactly where it was,
which reads as the default restoring itself. `config.json` has spelled
"no chord" as `[]` since #868, but no gesture wrote it.
Backspace now writes that empty list. The row falls to `—` and grows the
**Reset** button every overridden row has, which is the way back to the
default. The capture hint names the key, and the docs say what it is for.
**A keybinding line serde could not read failed the whole `Config`.**
`keybindings` is a hand-edited map and was strict, so `"ActivateTab1":
null` — or a number, or an object — quarantined `config.json` and started
the app on built-in defaults. Every rebinding in the file then read as
its shipped default, and the next settings write persisted those
defaults over what the user had written. It now reads one entry at a
time, like every other hand-edited nested key here: the lines that name
a shortcut bind, a line that does not is logged and skipped.
Tests, each failing on the unfixed code:
- `ui::app::keybinding_gpui_tests::backspace_on_a_row_unbinds_the_action_rather_than_restoring_its_default`
- `core::config::tests::a_keybinding_line_that_cannot_be_read_does_not_take_the_config_with_it`
and `ui::keymap::gpui_tests::alt_digits_can_be_moved_off_the_tab_actions_for_good`
pins the merge and a save/reload round trip: a list replaces the shipped
Alt+1, `[]` leaves nothing, and neither comes back after a restart.
Fixes#901
Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
A chord added in config wins a tie only against another app-wide action.
Copy, paste, find, clear scrollback and insert newline are bound in the
Terminal context, which is deeper, so they keep their chord while a
terminal is focused. Also tells people upgrading that a shortcut recorded
in Settings up to 26.9.2 was saved as a string. It now adds instead of
replacing, and a list brings back the old meaning.
Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
effective_bindings kept one chord per action and set_binding overwrote that
slot, so "NextTab": "cmd-shift-]" silently took Ctrl+Tab away.
A string in keybindings now adds a chord beside the action's default (or
preset) chord; "" still unbinds, as configs and the docs already rely on; a
list is the exact chord set, [] unbinds. Configured chords are installed after
every shipped one, so a chord the user names wins a tie with another action's
default. The Settings page lists every chord of an action, and recording a
shortcut writes the list shape (it sets the binding) and takes only the stolen
chord from the action that had it.
Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
Crush fires only PreToolUse, and it mapped to prompt-submit. With no Stop
behind it the pane stayed on Working until Crush exited: every close asked
whether to cut Crush's work short, the tray and dot stayed on working, and
fork warned mid-turn. Map it to tool-complete, which still records
session_id/cwd for resume and bumps activity without moving the status.
#731's `PortProbe` and #774's `TerminalModes` both landed on main while this was
open, each adding a field beside one this branch adds — `PaneProcs.probe` next to
`context`, `PaneState.modes` next to `remote_prompt_seen` — so every struct
literal for the two conflicted without either side being wrong. Both fields are
kept everywhere. `procinfo::snapshot` is #731's rewrite, with `context: None`
moved onto the `finish` helper that now builds the reply; the comment saying why
only the pane can fill it comes along.
Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
#711 landed on main while this was open and added two `ShellState` literals to
pane.rs's tests, which `mark_at_prompt` makes incomplete — the merge did not
compile on any target even though both sides did. The marks those two tests
stand up say "at a prompt", which is exactly the reading the new field carries,
so both get `mark_at_prompt: true` and the replay gate they exercise is
untouched. `cli_e2e`'s test table took both sides' new entries.
Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
The local remedy sent the reader to `config.json` for something the app has
a field for — **Settings → Terminal → Shell → Program**, with the
**Arguments** box beside it that turns the injection off when filled.
Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
The oh-my-zsh guard is not at the top of `lib/termsupport.zsh` — it fences
off the cwd reporter at the end of the file, and the title hooks above it
keep running over SSH. Say that instead.
The list under "the shell is one tty7 does not integrate" is the set it
*does* integrate, which read as its own opposite; turn the sentence around.
Nushell has had the integration since #637 (`ShellKind::Nushell`, a
`nu --config` wrapper), so give it a row in the shells table and stop
listing it among the shells that have none.
The per-profile toggle lives behind **Advanced**, as `docs/remote/ssh.mdx`
and the section below already say, and the panel is the **Files** panel
everywhere else in these docs.
Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
CI found both new end-to-end cases red on Linux and macOS while green on
Windows. One was a bad assumption in the test; the other was the test
telling me the fix is narrower than the first commit claimed.
The real finding is the resize case. On Unix a resize raises SIGWINCH and
the shell repaints its prompt, so the segment the resize opens is *not*
empty — it holds the repaint. Dropping byte-less segments therefore
leaves the newest non-empty segment being a bare prompt, with the pane's
output still stranded in the segment sealed behind it: on CI the default
form came back as 174 bytes of prompt escapes and no marker. On Windows
nothing answers the resize, the segment stays empty, and the fix reaches
past it to the output — which is why the assertion passed there. It was
asserting an accident of the platform.
So the fix stands but is smaller than "a resize no longer costs you the
pane's output": it makes the zero-byte answer impossible, and that is
all. It cannot do more. Nothing in the byte stream distinguishes a prompt
repaint from output the pane meant, so no client-side rule can tell which
side of the boundary the answer is on. The boundary is the flaw — the
default form's unit is the last resize, an event in the window rather
than in the pane — and moving it means redefining what the default
returns (the last screenful of the ring, say), which would shrink what
every caller with a never-resized pane gets today. Left alone, and said
plainly instead: in `what_was_asked_for`'s doc comment, and in a warning
in the CLI reference telling anyone reading a pane under the GUI to ask
for `--scrollback`.
The test now asserts what the fix actually guarantees on every platform —
the default form answers with bytes rather than with the resize's
placeholder, and it is the end of what `--scrollback` returns, which is
what would catch a fix reaching for the wrong segment. The marker is
pinned against `--scrollback`, the form that promises to hold it.
The `--tail` failure was a race in the test, not in `--tail`: the whole
and the tail were separate calls and the pane advanced between them, so
the tail carried a prompt line the whole capture had not caught up to.
Both cases now read the whole answer on either side of the others and
require the two readings to match before comparing anything, which is
what makes the comparison a statement about the code rather than about
the moment. Both also read `--scrollback` now, so neither depends on
where a segment boundary happens to fall, and neither pins exact pane
content — the macOS runner prints a zsh banner into the pane.
Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
`pane_is_free` read the pane's local process tree and folded two very
different answers into one `false`. On a pane that is only the near end
of a connection that tree describes the tunnel: a pane routed to a
remote daemon has no local pty at all, so `DaemonPane::procs` returned
`Default::default()` and the tree came back empty; a pane whose shell is
running `ssh` has a tree whose depth-1 process is the `ssh` itself, busy
for exactly as long as you are logged in. Either way `free` was
unreachable structurally, `seen_busy` was set on every poll — which
suppressed the one hint that would have pointed at the gap — and the
wait rode the whole `--timeout` before recommending `--until free`, the
flag that had just failed.
Freeness is now three-valued: free, busy, or "nothing here can answer",
and the last one carries its reason.
On a remote pane freeness is the far shell's own OSC 133 prompt marks.
That is sound because the near shell cannot be at a prompt while the
connection owns its pty, so a prompt mark on such a pane can only have
come from the far side. Two things had to change for the daemon to be
able to say it. The reader suppresses relayed prompt marks so a
foreground program cannot engage the local line editor — right for the
editor, and precisely wrong here — so `ShellState` now keeps the mark's
own unsuppressed reading beside the editor's. And "not at a prompt" on a
remote pane means nothing until the far shell has proved it reports at
all, since the newest mark is otherwise the near shell's own "I started
`ssh`", which nothing will ever supersede; a latch records the first
prompt mark that arrives while the pane is remote, and is cleared on
every hop. `PaneProcs` carries all of this to the CLI in a new optional
`context` — remote target, whether this machine holds the pty, the
mark's reading, the latch — so the answer still costs one request per
poll and an older server, which omits the field, keeps today's
tree-only behaviour.
When the far host has no shell integration the honest answer is that
this machine cannot tell an idle remote prompt from a running remote
command. `wait` says so — exit 1, `status: unknown`, a `free_unknown`
string naming the host — after one poll of grace for a handshake still
in flight, and only when `free` was the only state that could still
answer, so `--until done,free` keeps waiting on `done`. Without that it
would hang forever on a wait with no `--timeout`. The `no-agent` timeout
hint now fires only for a caller who did not already pass `--until
free`, and the reason freeness never resolved is printed and put in the
JSON in its place.
Deliberately left alone: on a local pane the process tree still holds
the verdict. A prompt mark can only turn a busy tree into free — which
is what fixes a plain `ssh` pane on Windows, where the daemon has no way
to name the pane as remote — never the other way round, so no pane that
reads busy today can start reading free because an integration went
quiet. `free` therefore now means "will take input" rather than strictly
"back to the bare shell": a pane sitting at a nested shell's prompt is
free, and the reference says so. The handoff record is unchanged, so a
pane mid-`ssh` that survives an exec comes back reporting "cannot
determine" until the far side's next prompt rather than guessing.
Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
Every "how did the last command end?" query went through `| tail -n 5`,
which ships the whole grid down a pipe to throw most of it away — and on
Windows, where `capture` is just as useful, there is no `tail(1)` to pipe
to. `--tail N` keeps the last N lines of the answer instead.
It is a third independent choice beside `--scrollback` (how much of the
ring) and `--plain` (in what form), and it composes with both. The trim
runs last, after `--plain` has decided what a line is: a shell line the
pane wrapped over three rows is one line to the grid and three to a byte
counter, so `--plain --tail 1` hands back the whole of the last line
rather than its final row. The raw form counts the same way `tail` does,
splitting on the newline and leaving the CR of a CRLF attached to the
line it ended, so the bytes stay the pane's own. A trailing newline
terminates the last line rather than opening an empty one, which is the
difference between `--tail 1` answering the last line and answering
nothing.
`N` must be at least 1: a tail of zero lines would print an empty result
that reads exactly like the blank-pane ambiguity this issue is about, so
it is a usage error (exit 2) instead. `--json` reports the tail in `text`
but leaves `bytes` the size of the whole replay, so the pair still says
"this was trimmed" rather than "this came back short".
Left alone deliberately: the daemon still replays the entire ring on
every observe, so the saving here is the pipe, not the wire. Bounding
what crosses the wire means teaching `ClientMsg::Observe` a limit and
versioning the protocol for it, which is a much larger change than the
papercut warrants — and the default (newest-segment) form has always
received the whole ring and discarded most of it, so this adds no new
cost. The docs say so rather than implying otherwise.
Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
`tty7 capture %n` answered a live pane with zero bytes and exit 0, which
is byte-identical to a pane that had genuinely never printed. The report
guessed at the replay grid. It is not the grid — it is the segment the
grid was handed.
The daemon's replay ring splits on resize: `ReplayRing::resize` seals the
segment holding the output and pushes an empty one at the new geometry,
and `ReplayRing::replay` sends every segment it holds, empty tail
included. `RealBackend::capture` then kept "the newest segment" for the
default (non-`--scrollback`) form, and for any pane resized since it last
printed, the newest segment is that empty placeholder. A pane restored
from disk lands in the same state, because seeding the ring ends in a
resize too.
Measured end to end against a real daemon before the fix: a pane holding
954 bytes of scrollback answered `capture` and `capture --plain` with 0
bytes and exit 0 while `capture --scrollback` returned all 954. The fix
drops byte-less segments before choosing the newest one — they carry
nothing in either form, so `--scrollback` drops them too and both forms
describe the same bytes. The daemon still sends them: its trailing `Size`
is how an attaching client learns the pane's current geometry, and that
is not the CLI's to take away.
What this does not explain is the reporter's other half — that
`--scrollback` came back empty in the same episode. Filtering empty
segments cannot cause that, and neither could I reproduce it: 60 rounds
of `capture` against a pane spewing 4000 escape-laden lines produced no
empty result, and an 8 MiB ring (the cap) replayed whole in ~400 ms, well
inside the 300 ms per-frame settle window that was the other suspect. So
the silent-empty class is left distinguishable rather than declared
closed: `--json` now carries `bytes`, the size of the replay counted
before anything renders or trims it, and the one case where an empty
answer is not an empty replay — bytes in, no text out — says so in a line
on stderr. Zero bytes is a pane that printed nothing; bytes with no text
is a screen whose content did not survive the grid. Deliberately no retry
loop and no warning on a condition that has not been established.
Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
A pane can come out from under its tab with its shell still running — an
interrupted `tty7 run`, a `ws rm` that could not hang everything up, or a client
that closed nineteen tabs whose shells were all alive (#716). `pane ls --all`
has been able to *show* those for a while, but everything the CLI offered to do
about one was to kill it: `pane close %<id>`, or `pane close --orphans` for the
lot. The shells were fine. There was simply no verb that put one back on screen,
so recovering meant recreating tabs by hand and reaping the originals.
`tab new` grows a `--pane` that builds the tab around a pane that is already
running instead of spawning a shell for it. Nothing new had to be invented on
the wire: `ControlRequest::TabCreate` has always taken a `PaneSeed` with a pane
id in it, which is how `run --keep` files its pane into a tab.
The part that needed designing is where the seed comes from. `tab_close`
retains the orphaned panes out of `m.panes` at the same moment it drops the
tab, so by the time anyone wants a pane back the tree has already forgotten its
record — cwd, title, shell. Reading the seed off the tree would therefore work
for an interrupted `run` and fail for exactly the case this verb exists for. It
is rebuilt from the live pane registry instead, which still has the pane
because the pane is still running, and which is the same list `pane ls --all`
walks. That does mean `ssh_spec`, `agent` and `shell` are not recovered — the
registry never carried them. They cost nothing while the shell lives, since the
tab is a view onto a pty that is already there, and only matter if the pane
later dies and something tries to restore it from the seed. Reconstructing them
from a running pty is a different problem; a tab you can see beats a shell
nobody can reach.
Two refusals rather than one guess: a pane the server is not running cannot be
re-homed, and neither can one a tab already holds — that is what `pane split`
is for, and accepting it would put a single pane in two places in the tree. With
no workspace named the pane goes back to the one it was spawned for, which is
the `owner` that `pane ls --all` already prints; `$TTY7_WS` cannot help here,
because a shell recovering from this is by definition not inside tty7.
The `pane ls --all` footer now names the way back as well as the two ways to
kill, since the listing is where an orphan is found and so is where the recovery
has to be written down.
Deliberately not done here: the switcher's orphan rows still carry only a Close
button. Adopting from the GUI is not the same one-line change — the row lists
orphans machine-wide while a window speaks for one workspace, and a pane may
only be attached by the workspace that owns it, so the button has to decide
where the tab goes before it can build one. That is its own piece of work.
Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
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
A remote workspace's ports were never listed. The pane lives in the
peer's registry and QueryProcs asks this machine's daemon, which has
never heard of it, so the answer was an empty list — indistinguishable
on screen from a pane serving nothing. Add a control request so the peer
answers instead, gated on a feature so an older server says "I cannot
tell you" rather than "nothing is listening".
With the ports visible, the forward becomes something the user should
not have to think about: a port opens on a click, and a new one is
forwarded unasked, at the same number where that number is free here.
The watch runs with the panel shut, which is when a port appearing is
most worth saying something about.
Ports and Forwards were two sections that never mentioned each other; a
row is now a port, and the forward is where that row says it comes out.
Adding one by hand asks for one number instead of five fields, with the
rest of the ssh -L grammar one disclosure away.
Claude-Session: https://claude.ai/code/session_01TPXrptp2rCGKjXaz4xE3Lq
Refs #710; does not close it (the request was a jump list opening a chosen existing workspace).
Registers NewWindow globally as well as on the render root, since with the tray icon on (the default) closing the last window retires to the tray and leaves no window to dispatch it.
26.9.0's tray-retire model made closing the last window the "keep the daemon,
drop the UI process weight" gesture, but that path was reachable only from the
OS red close button: no action, no palette entry, nothing to bind. `⌘W` closes
a pane or tab, `⌘H` hides the window but keeps it in memory, and `⌘Q` stops the
server. There was nothing that closed the window and left the shells running.
`CloseWindow` is that action, with no default key — the slot is left free.
The logic that decided what a window close means (detach the workspace, and
on the last window retire to the tray if an icon is actually up, otherwise
quit) moves out of `on_window_should_close` into `prepare_window_close`, so
the button and the action share one decision instead of two that can drift.
Notably not routed through `close_window_for`: that one recycles the last
window onto a fresh workspace, which is what deleting a workspace wants and
not what closing a window wants.
It reaches the command palette, the Keybindings UI, and the reference table
of actions with no default key. In the palette it sits beside Quit, because
that pair is the whole point of the action: both end the window in front of
you and only one takes your shells with it. Their subtitles now say which —
including Quit's, which had been promising "shells keep running" while
calling `daemon::spawn::stop()`.
Co-authored-by: bytehello <bytehello@users.noreply.github.com>
https://claude.ai/code/session_01LKMZVh6mUBxXAn6v7P6JC6
* feat(ssh): allow remote image clipboard writes
* fix(ssh): keep a profile's clipboard grant across a re-attach
A native ssh pane's OSC 5522 permission is decided by the spec that
dialled the host, and the daemon is the only side that holds it. A window
reopening onto a pane that outlived it attaches by pane id, has no spec
to read, and sends `allow_remote_clipboard_write: false` — which the
daemon took as the new answer and the pane's own view took as a refusal.
Both sides then said no, so the first restart after switching the
permission on turned every copy into an `EPERM` with the switch still
reading "on".
Pin the spec's answer in the pane and route both attach and detach
through one decision point, so a pane that carries a spec keeps that
spec's answer whatever an attaching client claims, and a pane without one
— everything on a remote `tty7-server` — is exactly as permitted as its
controller says. On the client side, refuse only what the pane can see is
forbidden and leave the verdict to the daemon otherwise.
Also: release a failed transfer's buffered bytes instead of parking up to
`MAX_CLIPBOARD_BYTES` per pane until the next request, and answer the
capability probe with the permission actually in force rather than a
constant that always reads as "off".
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
* feat(settings): add interface font family configuration in appearance typography
* fix(settings): let the interface font go back to the system face
Three things the new **Interface font family** row spelled once and needed
twice.
`apply_theme` only wrote `Theme.font_family` when the setting was `Some`,
and `Theme::change` never puts it back — it rewrites the field only when a
theme config names a face, and none of ours does. So picking a font worked,
and picking **Default** back saved `None`, redrew every window in the font
the user had just cleared, and only came true at the next launch: a setting
that looked like it had applied instantly and had not. The face is now
assigned in both directions, against the stock value read once before
anything overrode it.
The dropdown's first row borrowed the bold/italic label, "Default (match
primary)" — which promises the *terminal's* primary family. The interface
falls back to the system UI font instead, so the row said the chrome would
come out in Hack while the description beside it said the opposite. It gets
its own label in all three locales.
`ui_font_family` was also the one key in `config.json` that disappeared when
unset; every other optional key is written as `null`. Dropped the
`skip_serializing_if` so the file still lists it, and documented the key in
the two tables that enumerate the typography settings.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
* docs(skill): restructure around a delegation playbook
SKILL.md becomes a slim routing layer: a what-are-you-here-to-do section up
front, the pane/run/wait primitives, and four delegation rules that survive
even when the reference is skipped. Everything specific to running another
agent moves to references/delegation.md, which adds what the old text never
had: per-worker git worktree isolation, a delivery contract collected through
git instead of screen scraping, a launch-verification checklist, a babysit
loop, and a fan-out harvest with short per-worker timeouts so one stuck
worker cannot stall the round. Also replaces the last remaining 'claude -p'
example (the fan-out one #699 missed) and keeps every snippet valid under
both bash 3.2 and zsh.
* docs(skill): un-deadlock the fan-out harvest loop
Fresh read of SKILL.md and references/delegation.md. Every internal anchor
resolves and the two files agree on the primitives; three things did not
hold up:
- The harvest loop passed `--changed`, which cannot work there. `wait`
compares against the state standing when *that* wait began, so a worker
that reached `done` while you were waiting on a different one is already
in `done` when its own turn in the round comes up — refused, every round,
forever. Each pane runs one turn, so a standing `done` is this turn's;
drop the flag and note the one thing it was buying (a just-answered
`waiting` worker needs to leave that state before it is requeued).
- The same loop folded `wait`'s exit 1 into its 124 branch, so a pane that
died got requeued instead of reported — and requeued at full speed,
since a dead pane answers immediately. Split the three codes.
- SKILL.md described `--plain` unwrapping "a line the shell wrapped at
column 249" while two other passages state a pane is 120 columns.
Say "at the pane's width", as references/commands.md already does.
No typos or grammar slips found. Every bash block in both files parses
under bash 3.2 and zsh.
* docs(skill): two failure modes from the playbook's first live run
Dogfooded the delegation playbook end to end (worker reviewing this very
file). Two failures it hit that the text did not cover:
- A turn aborted by an API error emits no turn boundary, so the status
stands at 'working' forever and wait sleeps through it. Diagnose from
the screen's error line; recover by telling the still-alive interactive
session to continue.
- A short capture tail cuts off the spinner line and shows only the TUI's
always-present input box, which reads as idle. Tail 15+ lines and read
for the spinner; 'bottom looks like a prompt' is only evidence on a
shell pane.
* feat(agents): wire hooks, resume and detection for Kimi Code
Kimi Code CLI takes its hooks as [[hooks]] entries in the same
config.toml that holds the user's providers and models, so this adds a
third install strategy — a format-preserving TOML merge on toml_edit —
beside the JSON map merge and the owned files. Like Qwen it reports
permission requests first-class, so it gets no Notification hook.
Resume rides `kimi --session <id>`; fork stays unwired, Kimi
documents none.
Closes#693
Signed-off-by: Austin Spraggins <spragginsdesigns@gmail.com>
* fix(agents): harden the Kimi Code TOML hook merge and its resume flags
The TOML merge strategy the Kimi wiring introduces round-trips a shared
config.toml cleanly, but three gaps sat behind it.
`hooks_state` counted only the marked entries that still named an event,
so a hand-edit that dropped the key off one of nine entries left the
remaining eight matching the roster exactly and the file reported
Installed with a broken entry in it. Every marked entry now counts,
which is what the JSON merge already did and what `refresh_hooks` needs
to see.
A `hooks = []` spelled as an empty inline array made install fail
outright -- toml_edit keeps an empty array and an array of tables apart,
but the two say the same thing and neither carries any configuration. It
is now promoted rather than refused. Every other wrong-shaped `hooks`
key -- a string, a table, a non-empty inline array -- still refuses with
the file left byte-for-byte alone.
`Stop` is not the only way a Kimi turn ends: its own event reference says
`Stop` does not fire on interrupts and `Interrupt` fires instead, and a
turn that dies on an error reports `StopFailure`. Without those two an
Esc or a failed turn left the pane on "working" for good and `tty7 wait`
could only ever time out. Both are observation-only events and report
the same end of turn `Stop` does.
On resume, `--agent` and `--agent-file` join the stale flags: Kimi
rejects either next to `--session` at startup, and resuming rebinds the
session agent by itself, so replaying them turned a working resume into
a launch error.
Tests cover the wrong-shaped `hooks` keys, a config.toml that does not
parse on both install and uninstall, a file that does not exist yet, a
second install being byte-for-byte the first, mangled and surplus marked
entries, an uninstall threading between the user's own entries and the
tables after them, and the `--session=<id>`, bare `--session`,
`--continue` and `--agent` spellings on the resume path.
---------
Signed-off-by: Austin Spraggins <spragginsdesigns@gmail.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
* feat(window): dock the code panel and the diff overlay beside the terminal (#625)
Opening a file covered the workspace. The terminal underneath kept
running and was neither visible nor typeable, so reading a file while an
agent talked was a toggle loop: open it, close it to read the reply, open
it again. The Files tree already docks; the two surfaces you go to *from*
it did not.
They dock now, as a flex sibling of the terminal column rather than a
narrower overlay — that distinction is the feature. `set_grid_size` is
driven by the terminal element's laid-out bounds, so a column takes width
away from the grid and the PTY reflows into what is left; a card painted
over half the workspace would have left the grid full width with half of
it hidden.
`overlay_top` stops ordering a pair and starts choosing between them: a
column has one child, and two `flex_1` siblings would split it and fight.
Fill mode keeps the old vector, the old opaque paint and the old platform
hoist untouched, so nothing about today's overlay changes for anyone who
picks it.
- Half the terminal column by default; drag the divider, double-click it
to cycle a third / half / two thirds, or use the palette commands. Two
thirds deliberately runs past the half-window cap the side panels obey
— only the terminal's floor binds it.
- `DOCUMENT_MIN_W` joins the width budget: both side panels reserve it
the way they already reserve each other, and the column is derived from
the *live* sidebar and panel widths rather than their floors, so a
panel someone dragged wider is width the terminal keeps.
- A window too narrow to seat both fills for that frame. The fallback is
derived at render time and never stored, so widening re-docks on the
next frame with nothing to undo.
- Fill or dock is per tab, on the header's context menu. Reading a long
file over the whole window in one tab while an agent keeps half of
another is the normal case, and one global switch made each of those
flip the other. A tab that has not been told reads `document_layout`
from the config, which is what a fresh tab starts as — and which the
menu therefore does not write, since every untold tab is reading it.
- Everywhere but macOS the title bar spans the workspace, which left a
bar's height of nothing above the column. The header is drawn into it,
and behaves like the title bar it now sits in. With the detail panel
closed the column reaches the window's right edge, so the header stops
short of the trailing chrome through a width the tab strip's own
reservation shares.
- The docked headers drop the traffic-light inset they never had to
clear, and the diff header's branch name becomes the thing that yields
so the view toggle and the close tile survive a column's width.
New in `config.json`: `document_ratio`, and `document_layout` for what a
fresh tab starts as. Four new actions, bindable and unbound by default.
* fix(window): hold the docked column to widths the strip and the file agree on
Three defects in the document column, each with a guard test that fails
without its fix.
The tab strip did not know a column had taken width off it. On macOS the
strip lives inside the terminal column and sizes itself to the window less
the detail panel, so a docked document left it 340 points wider than the
column it sits in and the chips ran on under the column — the same overrun
the panel's own reservation was added for. Everywhere else the strip spans
the workspace and the column's hoisted header is drawn over its trailing
end with no fill of its own, so a chip left under it showed through the
file name and stayed clickable through it. The column's width now comes off
`strip_w` on macOS and off `corner_w` elsewhere, which is where the panel's
already goes.
The divider wrote widths the file would not keep. `Config::sanitize` holds
`document_ratio` to 0.2..=0.8; the drag clamped in pixels only, so a column
pushed against either edge of a wide window was saved outside that band and
reopened somewhere else — on a 2560-point body, 232 points from where it
was dropped. The band is a pair of shared constants now and the drag clamps
to it, the way the font size and its stepper were made to agree in #550.
The palette named the config's layout rather than the tab's. Fill is per
tab, so a tab told to fill was still offered "Document: Fill Window" — a
row that named the state it was already in and did the opposite. It reads
the active tab through `ChromeState` now.
Also: `document_layout`'s doc comment still described the global switch an
earlier draft had, three lines after the field became a per-tab default.
* fix(input): give the PTY back the Ctrl chords tty7 was eating
Follow-up to #682, which handed Ctrl+V to a full-screen program but left
three neighbouring holes of the same shape: a key the terminal answers
without the keymap ever seeing it.
The C0 table was half a table. `input.rs` mapped the alphabet, `[ \ ]`
and Ctrl+2, and nothing else — so `Ctrl-^` (Ctrl+6, vim's alternate
file), `Ctrl-_` (readline's undo, typed as Ctrl+/ or Ctrl+Shift+-) and
Ctrl+3..8 produced no bytes at all. They were not mis-encoded, they were
silent: gpui filters control characters out of `key_char` on all three
backends, so the text fallback had nothing to offer either. The table is
now the VT-220 one, each digit beside the punctuation that shares its
key, because every platform hands Ctrl+Shift+6 over as `^` with the
Shift already spent. Ctrl+/ is xterm's addition rather than VT-220's and
is spelled out with the reason. The twenty-six letters fold to `& 0x1f`.
`on_key_down` swallowed plain Ctrl+1..9 off macOS with a bare `return`,
left over from when tabs lived on ctrl-digits — they have been on
Alt+1..9 for a long time, so nothing claimed those chords and the block
only deleted keys. It also sat before `keystroke_to_bytes`, so not even
the kitty protocol got through it. Gone.
Ctrl+V is now a binding. `AlternatePaste` carries `ctrl-v` off macOS in
a `Terminal && !alt_screen` context, and the pane declares `alt_screen`
whenever a full-screen program owns the grid, so the behaviour #682
settled on is unchanged — paste at a prompt, SYN inside vim — while the
keymap can finally express it, the Keybindings page lists it, and the
user gets a say: `"AlternatePaste": ""` hands Ctrl+V to the shell
everywhere, including readline's `quoted-insert`, and
`"PasteText": "ctrl-v"` pastes on every screen the way Windows Terminal
does. That cohort is real — Warp keeps Ctrl+V pasting on Windows on
purpose, as a removable binding, for exactly this reason. The hardcoded
arm in `handle_cmd_shortcut` now answers Cmd+V alone, which is macOS's
only paste chord and carries no control code to lose.
Last, the rule about control codes is one function instead of an
assertion buried in a test. `steals_a_control_code` plus a commented
`control_code_binding_allowed` back both the defaults test and a new
runtime warning, so a hand-edited config.json that takes EOF away from
every shell says so in the log. It warns rather than refuses: a chord
the user asked for by name is theirs to spend, the way the tmux preset
spends Ctrl+B. The invariant that still fails a build is that no
*default* spends one silently.
Tests: `cargo test --bin tty7-app` 1360 passed, 1 known flake
(`a_routed_auth_prompt_carries_the_machine_that_raised_it`, green on a
rerun and on a clean tree). New: the whole VT-220 table asserted byte by
byte, with Ctrl+- held out; `ctrl_6_reaches_the_pty_as_rs`,
`ctrl_v_pastes_at_a_prompt` and `ctrl_v_reaches_a_full_screen_program_as_syn`
drive the real keymap through `simulate_keystrokes` rather than calling
into the view; the keymap tests cover both escape hatches and the
context that withholds the binding. #682's two `handle_cmd_shortcut`
tests are replaced by those three, which assert the same behaviour at
the layer that now decides it; its end-to-end SYN test stands unchanged.
The gpui tests are unix-only, so CI is what runs them.
* fix(input): ask the grid, not the last frame, before Ctrl+V pastes
`AlternatePaste` carries `Terminal && !alt_screen`, but gpui matches a
keystroke against the frame it last painted, so the context outlives the
switch: a full-screen program that took the screen after that paint is
still "at a prompt" as far as the keymap is concerned, and the clipboard
lands in it. In vim's normal mode that runs as commands. The action now
re-reads the terminal mode and propagates instead, which hands the chord
to `on_key_down` and encodes it as the SYN the program is waiting for.
Also:
- the two escape-hatch assertions in
`paste_ships_both_terminal_chords_off_macos_and_retires_together`
built a one-entry binding table instead of the default one, so both
passed without the hatch working — an emptied `AlternatePaste` cannot
dispatch anything when it is the only entry in the table. They now
apply the config line on top of the whole default table, and the
`PasteText: ctrl-v` case checks both screens;
- the keyboard-shortcuts page claimed every other Ctrl chord reaches the
program, which Ctrl+Tab and the Windows/Linux font-size chords do not;
- `steals_a_control_code` documents `@` and the backtick, which are in
the set it walks but were not in the list beside it.
In vim or neovim on Windows and Linux, Ctrl+V pasted the clipboard where
the editor expected blockwise Visual mode. Windows Terminal (with its
ctrl+v binding removed), WezTerm and Alacritty all send the key; macOS
was never affected, since Cmd+V is the paste chord there.
Ctrl+V was not a keybinding at all. `on_key_down` hands plain Ctrl+C, V
and X to `handle_cmd_shortcut` off macOS, and of the three the "v" arm
was the only unconditional one: Ctrl+C copies with a selection and
otherwise falls through to SIGINT, Ctrl+X falls through outside the
editor, but Ctrl+V always consumed, so SYN never reached the PTY --
`input.rs` had the byte, unreachably -- and an empty clipboard turned the
key into nothing at all. #270 set the rule that off macOS ctrl-<letter>
belongs to the terminal and anything sitting on one must fall through;
Ctrl+V was the exception that had escaped it.
The arm is now contextual like its neighbours. On the alternate screen
it falls through, and `keystroke_to_bytes` sends 0x16, or the CSI u form
when the program has the kitty protocol on; off it Ctrl+V pastes exactly
as before, and Cmd+V on macOS is untouched. The alternate screen is the
gate rather than `input_active` because the editor is inactive whenever
shell integration is missing or the prompt editor is off, and gating on
that would take paste away from every such user; a program that has
switched screens is precisely the case reported. Inside such a program
paste is Ctrl+Shift+V, Shift+Insert or the right-click menu, all of
which still stage a clipboard image for an agent.
The same block did not exclude Shift, so Ctrl+Shift+C/V/X reached the
hardcoded path whenever the keymap had nothing on them -- exactly the
state rebinding Paste leaves behind, which #271 promised would retire
Ctrl+Shift+V, but it went on pasting behind the user's back. Only
unshifted chords enter the block now; the shifted ones are the keymap's
alone.
The right-click menu advertised Ctrl+C, Ctrl+X and Ctrl+V off macOS as
though they were the bindings, next to a Select All row that already
showed its hint on macOS only. The three rows take the same treatment,
which is also what the command palette does.
Three view tests pin the split -- Ctrl+V falls through on the alternate
screen while Cmd+V still pastes there, Ctrl+V pastes off it, and a key
down on the alternate screen arrives at the PTY as SYN and nothing else
-- and the keymap's paste test now asserts that no default claims ctrl-v
in the Terminal context. The shortcuts reference notes where plain
Ctrl+V pastes and where it is the program's.
Fixes#677.
Cmd-scroll zoomed the font with no way to move it or switch it off, so a
thumb left on Cmd resized the terminal mid-scroll (#668). The modifier is
now a setting: the platform modifier by default, or Ctrl, Alt, or none.
Stored as the choice rather than the resolved key, so one config file
still means the same thing on a Mac and on a Linux box. Settings ->
Terminal -> Mouse carries the picker; off macOS Ctrl and the platform
modifier are the same key, so it shows one cell for them.
* refactor(i18n): drop the About page shell primer and trim the long copy
The About page carried a "How shells work" section explaining that shells
live in a background server. Nothing linked to it and the Updates and
Server sections below already say what happens to those shells, so it was
a paragraph of prose the page did not need. Remove it, its search index
entry, and its three L10nKeys.
Then cut the padding out of 48 strings across settings rows, dialogs and
notices. Two patterns accounted for most of it: the restart-server
dialogs stated "your shells keep running" up to four times each in
different words, and the config.json failure notices packed three
subordinate clauses into every sentence.
Nothing is dropped but repetition and clauses the reader can infer —
every consequence a dialog asks the user to weigh is still spelled out.
en, zh and ja stay in sync.
* feat(themes): add Catppuccin Mocha, Gruvbox Dark, Nord and Tokyo Night
Four more dark built-ins, taking the set from nine to thirteen. The docs
table and description are updated to match.
* fix(themes): give Catppuccin Mocha its rosewater caret, refresh a stale builtin count
A click on a file in the SSH Files panel used to start a download; the
only way to change a remote file was download, edit, re-upload. Now a
click opens it in the built-in editor and Cmd-S saves straight back over
the pane's own SFTP channel, matching what the Files panel already does
locally and over a remote workspace.
- protocol: SftpOp::ReadFile/WriteFile and SftpOpResult::File, bytes as
base64; the reply carries the body plus the stat it was read under
- daemon: ReadFile enforces the caller's size ceiling before and during
the read; WriteFile rewrites in place (truncate, not temp-and-rename)
so the file keeps its mode and ownership
- SftpHost: a Host over the pane's SFTP route, so the editor's existing
open/save path works unchanged; git/search/watch honestly Unsupported
- editor: an open buffer holds the host it was read from, and
save/reload/dedup/watch key on (host, path) instead of the active host
- panel: single click opens (dirs navigate, text files edit), the same
gesture as the local tree; binary or oversized files get the local
tree's toast, and Download moves to the context menu
Review follow-ups, in this PR: the SFTP host stays out of HostRegistry,
which means "a machine this window has a link to" and is swept as such —
filing the pane's channel there made Cmd-S return silently once a
workspace deletion took it back out. The cursor-jump lookup, the status
bar's path, and the SCM panel's repository all key on the buffer's own
host now. Closes#656.
* feat(tabs): drag a tab in as a pane, and a pane out as a tab
A tab dragged by its chip or its sidebar row can be dropped over the
panes to become one of them, and a pane dragged by its grip can be
dropped on the strip or the sidebar to become a tab of its own. Both
carry the panes across as they are: nothing is spawned and nothing is
killed, so a shell mid-command, an SSH session or an agent mid-turn
keeps running.
The landing is read the way a pane drag's already is, minus the middle:
an arriving tab has nothing here to trade places with, so a pane's core
means "split it the way it is longest". A tab that was itself split
arrives with its own shape intact and takes one share of the row or
column it joined. A pane on its way out is offered a caret between two
tabs, and the last pane in a tab is offered nothing, being a tab of its
own already.
Picking a tab up no longer switches to it: the strip and the sidebar
now activate on the click rather than on the press. Without that the
merge cannot be expressed at all — pressing the tab to drag it would
put it on screen, leaving no other tab to drop it into.
Two things in the machine tree had to follow:
* Panes that change tabs are told as PaneMove, one at a time, rather
than as a tab closing and another being rebuilt around them.
* The tabs the machine already has are reconciled before new ones are
created, so a pane leaving for a tab of its own is given up by the
old tab before the new one asks to register it. The machine refuses a
pane that is in two tabs at once, and the refusal desynced the window.
Closes#621
* test(tree-sync): a tab grafted above a whole layout still converges
* fix(tabs): keep a click on the close button from switching tabs
Switching on the release rather than the press means every click inside
a chip or a sidebar row now reaches the row itself, and gpui-component's
`Button` does not stop propagation on a click it handled. So one click on
a tab's close button ran `close_tab(i)` and then `activate(i)` — with `i`
by then naming whichever tab had slid into that slot, which moved the
active tab somewhere nobody asked for. A click into the rename field did
the same: it switched away from the tab whose name was being typed, and
took the focus out of the field with it.
Both now hold the click where they handled it, the way they already held
the press.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
`sidebar_grouping` gains a third, opt-in mode, `repo-or-directory`: group by repository home as before, and when the repo probe has landed and answered "not a repo", group under the cwd itself instead of filing every such tab under Scratch. A probe that has not run yet resolves to no decision, so a tab keeps the group it already has rather than bouncing through Scratch mid-probe. The decision lives in one `resolved_group` free function shared by the per-frame key derivation and spawn-time seeding.
The default (`repo`) and flat modes behave exactly as before, and an unknown value in an existing config still degrades to `repo`.
Knock-on: `machine_mirror::subject_path_of` names a window after its most common group, so in the new mode a window of plain shells takes its name from the most common directory rather than from the first pane's cwd.
Closes#620.
The zsh and fish arms of `shell_integration::setup` never checked `has_custom_args`, so a shell the user launched with their own arguments was injected anyway — fish had `-C <script>` appended to its argv, zsh had its ZDOTDIR swapped. Both arms now sit behind the same gate bash, PowerShell and WSL already used, hoisted to a single early return ahead of the dispatch so a new ShellKind cannot silently reintroduce the bug.
Docs now describe what the code does: the `shell` row's own `{"program": "fish", "args": ["-l"]}` example loses integration under this rule, and the shell-integration note distinguishes user-written arguments from the ones detection supplies (Git Bash, WSL).
Part of #624; the native-input-mode half is separate.
Closes#624
tty7's inline editor takes the prompt the moment OSC 133 reports one, and
until now the only way to keep it off was to hide the shell's own name
from tty7 so integration never armed — which costs the prompt boundaries,
cwd and exit codes as well. Someone who binds `history-beginning-search-
backward-end` to Up in their zshrc had no way to reach it, and the local
history the editor walks instead is per-view: a command run in one pane is
not in another's list, so the shell's shared history looked broken too.
The new `prompt_editor` switch (Settings -> Input -> Prompt, on by
default) hands the line back. Off, every key at the prompt goes to the
PTY, so ZLE / readline / fish do the editing and what the user bound
behaves as written. Shell integration is untouched by it.
The gate is one line in `input_inactive_reason`, which every path that
could take the prompt from the shell already asks: keys, IME commits,
paste, Tab, the completion and reverse-search menus, the input bar. That
is what makes this a mode rather than a special case per key.
`shell_owns_prompt` learns the flag too, and that half matters more than
it looks: the gap hold and the typeahead record both exist to feed the
local editor, and `flush_typeahead` sends ^U to erase the line before
moving it there — on a line only ZLE is editing, that erases the user's
work. Ctrl-R landing on the PTY also stops raising the missing-integration
notice: the shell owning it is what was asked for.
Turning it off mid-line hands what is typed to the shell the way an
unknown chord does, so the text is still on the prompt to finish. Live
panes follow the switch, including a hand edit of config.json in another
window.
Tab completion and history search are menus tty7 opens inside that editor,
so the page greys them out and says why while it is off. Only their text
dims — a switch already draws its thumb at 35% when disabled, and dimming
the row on top of that leaves a pill with nothing visible in it. Their
stored values are left alone and come back with the editor.
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Found by driving a dev instance and measuring what an idle window costs.
Two of them were frame loops that never stopped. A `Head` diff overlay
calls itself stale when the cached git status disagrees with the snapshot
on screen, and every landed probe wakes that check by touching the cache
— but the read published its counts and left the branch behind, so a
branch switched outside tty7 made the disagreement permanent: two `git`
processes a lap, forever, with `refreshing…` pinned to the header and an
idle window at 7% of a core. And the home page asked for a frame sixty
times a second to change one glyph's opacity twice, which made a window
with nothing open in it eight times more expensive than one running a
shell. Both now settle: the diff read publishes the branch it found, and
the home cursor flips a bool on a timer the way the terminal's own does.
The rest:
- The tab sidebar and the right panel each capped themselves at half the
window and knew nothing about the other, so together they could take
all of it — 260 points of terminal on a 720-point window. Both now cap
at whichever binds harder: half the window, or what is left after the
terminal's floor and the other panel's floor. The same cap bounds the
drag, so a panel dragged to its limit stays where it was dropped.
- Only a HEAD diff may correct the sidebar's counts. Those numbers mean
`git diff --numstat HEAD`; an unstaged or staged patch answers a
smaller question, so opening an untracked file from the Source Control
panel took the staged lines off the total on the click.
- An untracked row in the diff overlay had no click target, and once
focused could not be left — the breadcrumb looks the path up in
`files`, where an untracked file has no entry. Both ends fixed.
- A new pane keeps the name its directory was reached by. `cwd()` alone
loses it: the shell falls back to `getcwd()`, so `/tmp/x` became
`/private/tmp/x` in every tab opened from the first. `PWD` carries it,
and POSIX has the shell discard a `PWD` that names the wrong
directory, so this can correct the name and cannot invent one.
- The settings search now sees into the Keybindings page, which is
generated from the binding table rather than the static index — so
searching for a feature finds its shortcut, and the page filters to
the matches. Closes#444.
- The settings reading column is centred rather than pinned to the nav:
on a window as wide as the display it was made for, 640 points of
settings sat beside 1600 points of nothing.
- `New Workspace…` takes the ellipsis its three sibling actions already
carry — it opens a form asking for a name and a host.
Every fix has a test. The re-probe loop is pinned end-to-end with
`render_probe::draws() == 0` against a real repository, confirmed to
fail on the old behaviour before it was kept.