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.
macOS stamps every event of a momentum tail with the modifiers held at
delivery, so grabbing ⌘ while a two-finger flick was still coasting turned
the rest of a plain scroll into a zoom — dozens of font steps in a few
frames, down to FONT_SIZE_MIN and saved to config.json, which is why it
outlived a relaunch and read as the font randomly shrinking.
A trackpad gesture now answers "scroll or zoom?" once, at its first event,
and holds that answer until the stream dies. A wheel has no gesture to
belong to and still decides notch by notch. The latch is symmetric: a zoom
gesture that outlives its modifier keeps zooming rather than dumping its
tail into the scrollback.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The right-click menu and the File menu sit next to Split / New Tab items,
so the surrounding context already says what closes; plain "Close" (⌘W)
matches the macOS convention. The palette and Keybindings page keep the
full name, where there is no surrounding menu to disambiguate.
A tab went on reading "✳ fixing the switcher" long after Claude Code had
quit and the pane was back at its own prompt in a real directory. An OSC
0/2 had no owner and no end — only another OSC 0/2 ever replaced it — so
the last title any program wrote in a pane outlived it forever, and the
`Osc` rung of the label ladder kept outranking the `Cwd` below it with a
name for a session that no longer existed.
The shell integration already says when a command starts and stops. A new
`core::osc::TitleLifetime` reads OSC 133;C / 133;D alongside the titles:
a title set *between* them belongs to that command and is retired by its
`D`; a title set at a prompt — the shell's own, or one pinned by hand —
belongs to nobody in particular and is left alone; a pane with no shell
integration sees neither mark and keeps every title, exactly as before.
Both readers run it over the same bytes, in stream order, so the tab strip
and the switcher can never disagree about whether a title is still current:
- the daemon's `OscSniffer` turns a retirement into the reset it already
understood, clearing `PaneRecord::osc_title` for the switcher and CLI;
- the window's pane reader sends `AlacEvent::ResetTitle` after the chunk
the emulator just parsed, so its own terminal's title goes back to the
pane's default and `stated_title` says nothing.
Stream order is what keeps a re-titling shell whole: tty7's zsh helper
prepends the `D` emitter to precmd and the PowerShell one titles inside
its prompt function, so a shell's own OSC 0/2 lands after the `D` and is
simply the last word rather than something to undo.
Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
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
ConPTY translates bare LF into Ctrl+Enter, so native Codex drops the
legacy Shift+Enter fallback and Ctrl+J. Preserve Ctrl+J for local
console readers while retaining raw PTY and Kitty input behavior.
Refs l0ng-ai/tty7#894
With both fixes on main, the replayed status adopted as a baseline made
`status == last_agent_status` return before the read-mark lookup ran, so
every rebuild (workspace switch, tray reopen, both of which reattach)
skipped #888: an unread badge was dropped and a turn that finished while
away never badged.
A replay is now only a baseline when the pane has no mark, i.e. this app
never watched an agent in it (the restart case #890 is about). Marks are
kept for every status, not just Done, so a turn that was running when the
old view went still badges, and the restore path checks the mark was a
finished turn. Also adds `turns` to #890's unix-only remote.rs tests,
which no longer compiled against #888's field.
Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
A relink keeps the view and the status it saw before the link dropped, so
that is already the baseline. Marking the relink's replay as stored state
replaced a `Working` baseline with the daemon's `Done` and swallowed the
turn that finished while the link was down: no badge, no notification.
Only a cold attach, where the view has nothing to go on, adopts the replay.
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
Restarting the app leaves the daemon and every agent in it running, so
each restored tab attaches to a pane whose turn ended long ago. The
daemon replays that pane's stored agent status as an ordinary
`AgentStatus` frame, and nothing on the wire said it was stored state —
so the view read `None -> Done` as a turn finishing right then and put an
unread badge on every agent tab that was not the focused one, for results
the reader had already read. A relink did the same on every dropped link.
Keep the distinction where the value lives: the shared slot now carries
`replayed` alongside the status, under the same lock, so a reader cannot
observe one without the other. The reader thread marks the first status
frame on an attached link as the replay; the first frame of live output
closes that window, which is what a pane with no session to replay needs
(its next agent's first report is live, and must stay an edge). The view
takes that mark once and adopts the status as its baseline instead of an
edge.
The notification path already required the previous status to be
Working/Waiting, so only the badge was wrong.
Claude-Session: https://claude.ai/code/session_01FG2s9mbZu6LbjjmU54X7kt
The issue reads `CursorShape::Hidden` as reachable only through DECSCUSR,
but alacritty derives it from `TermMode::SHOW_CURSOR`, so `?25l` already
suppresses the painted caret: focused bar/underline, the focused block's
reverse-video cell, and the unfocused outline. Claude Code itself ends each
frame with `?25h` at its input point, which is the caret the reporter saw.
Route the paint decision through `GridSnapshot::painted_cursor` and pin it
with a test that paints real frames for the reporter's stream shape (alt
screen, 69 hides / 75 shows ending hidden): focused, unfocused, `?25h`
restoring it, and a re-attach replay. With the hidden filter disabled the
test fails with `Some((4, 12, Block))`.
Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
Faint text was painted as its ink at 66% alpha over the cell. On a light
background that fixed fade collapses the WCAG ratio: Catppuccin Latte's
foreground fell from 7.06:1 to 3.18:1, Rose Pine Dawn's to 3.08:1, and
every bright-black the palette rescue had lifted to 4.5:1 fell back to
~2.5:1 on all four light builtins (#858).
On a light cell the fade is now walked back toward the ink until it clears
the 4.5:1 text floor, capped at the ink's own ratio. Dark cells, and the
legible-palette switch turned off, keep the plain fade byte-for-byte.
Fixes#858
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
Switching workspaces or reopening a window from the tray throws a pane's
TerminalView away and builds a new one over the same daemon pane. The new
view starts with no last status, so an agent that was already Done arrives
as None -> Done, which poll_agent_status cannot tell from a turn finishing
live, and every unfocused rebuilt pane got its unread badge back.
The daemon now counts finished turns per agent session (turns, bumped on
entering Done), and views leave an app-lifetime mark per (host, pane) of the
session, turn count and badge the reader was last shown. A rebuilt view's
first sight of Done takes the badge back from a matching mark instead of
raising a new one; a turn that finished while the view was gone has no mark
or a lower count, and still badges.
Fixes#870
Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
Regression tests for issue #868: a chord added in config.json must join the
action's default chord rather than replace it, an empty string or list must
still unbind, a list replaces the chord set, the tmux preset composes, a user
chord wins a tie with another action's default, and Settings recordings write
the exact-set shape.
Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
Every visual bell armed its own 150 ms clear timer, so when bells arrived
faster than that - holding Backspace on an empty bash prompt, or Tab with
nothing to complete, rings at key-repeat rate - an older bell's timer blanked
the flash a newer bell had just lit, and the pane strobed for as long as the
key was held. Only the timer armed by the latest bell clears the flash now.
Fixes#874
Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
Hiding the whole title bar took the tab strip with it: with tabs on top
every chip, the New Tab tile and the panel/menu tiles vanished in
fullscreen, the docked document header (drawn only over the spanning bar)
disappeared, and the strip's drop band kept claiming a row that was now
terminal.
What is actually dead in fullscreen is minimize/maximize/close. The row now
stays; in fullscreen off macOS the strip goes into a plain row of the same
geometry instead of `TitleBar`, which always draws those buttons, and the
room reserved for them (strip width, chrome band over the panel, document
header padding) comes back. The notice text says the window buttons are
hidden rather than the title bar, and the keymap test whose premise was the
bar disappearing is replaced by one pinning the controls width.
Readline, PSReadLine, a cooked tty and tty7's own editor all treat Ctrl-D
as end of input only when the line is empty; with text on it Ctrl-D is an
edit. Discarding a record that still holds unsubmitted text dropped the
owed wipe, so text typed during a gap followed by Ctrl-D stayed on the
shell's line and was glued to the front of the next command. Ctrl-D now
discards only a record with no unsubmitted text, and taints otherwise.
The passphrase box checks which key file is on this machine with
std::fs::metadata, which the host-boundary guard rejected; allowlist it
beside the existing std::fs::read entry for the same client-side key.
An empty key field now resolves to the ~/.ssh defaults build_spec_inner
offers, so a default encrypted key can be given a passphrase from the
form. The key is also re-resolved when host or user change, since they
fill %h/%r in the path. Drop the unused SettingsForget string.
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.
ui_prompt_end sent prompt-submit unconditionally, but Pi also opens
prompts while idle (/model, a command's select). Closing one left a
finished or fresh pane reading "working" with no agent_end to clear it.
Remember the last turn event and re-emit that instead.
Closing the last window with the tray icon on retires tty7 to the tray:
process alive, Dock icon up, nothing on screen. That state had no way back
through the icon. macOS relaunching an already-running app arrives as
`applicationShouldHandleReopen:hasVisibleWindows:`, gpui's delegate forwards
it to a callback registered with `Application::on_reopen`, and tty7
registered none — so the click was a no-op, and the only ways back in were
`⌘N`, the tray's "Show tty7", or quitting and relaunching.
`windows::reopen` takes that callback, in the two shapes the state has: a
window still registered is activated rather than doubled, and no window at
all goes through the pathless-launch restore (`restore_target` + `open_at` +
`announce_detached_at_launch`) — the same path the tray's windowless branch
takes, so the workspace that retired is the one that returns and not a blank
one beside it. `reopen_with` is the seam the tests drive, so a reopen that
opens a second window beside the one on screen cannot pass.
`Application::on_reopen` is registered beside `on_open_urls` in `main`,
because it has to exist before `run` — `keymap::init` runs inside the loop —
and the callback defers to the loop with `cx.spawn` rather than opening
windows on AppKit's delegate stack, the shape `on_open_urls` already uses.
`activate_window` is `makeKeyAndOrderFront:` on macOS.
Reported from a macOS machine where a lid close and wake left the process
frontmost with no window: `launchservicesd SETFRONT` at 23:51:40 with the
process still reported `running-active-NotVisible`, and the layout only back
after a quit and relaunch. The window itself being lost across display
sleep → wake is not explained by this change and carries no guess-fix here:
nothing in tty7 or in the pinned gpui hangs off display sleep or wake.
cargo fmt --check; cargo check --locked -p tty7 --tests; cargo test --locked
-p tty7 --bin tty7-app -- 1888 passed, 0 failed.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
`with_terminal` builds the chain once, out of `font_family` and
`font_fallbacks`, and from then on it is only ever copied: `set_font_family`
took it off the font it was replacing, and `alt_font` takes it off the
regular face when it builds bold and italic. Nothing reread it.
So a `font_fallbacks` edit had no live path at all — only panes opened
afterwards saw it. Changing `font_family` and changing it back did not help
either, because that path cloned the chain too.
Carrying the chain across a family change is also wrong on its own terms.
`fallback_chain` decides the pins from the family it is handed: it skips
pinning a last-resort face that the family already is, and pins the bundled
Hack otherwise. The chain built for `Hack` therefore has no Hack in it, and
reusing it after a switch away from Hack leaves the anchor missing.
`set_font_family` now rebuilds from the config, `reload_from_config` watches
`font_fallbacks` and pushes a rebuild into every open pane, and the rebuild
writes all three faces rather than the regular one alone — bold and italic
carry no chain of their own, so skipping them would strand two thirds of the
text on the old one.
The shared Pi / Oh My Pi bridge only subscribes to four lifecycle events, so
a pane running Pi never shows "waiting for you" while a dialog is open —
`ask_user_question`, permission gates and any other extension prompt all run
under the hood with the status dot still reading "working".
tty7 already has the vocabulary for this (`question-asked`,
`permission-request`), and `AgentEventKind::QuestionAsked` /
`PermissionRequest` already map to `AgentStatus::Waiting` in
`cli_agent.rs`. The Pi extension seam to feed them is
`pi.on("ui_prompt_start")`, which fires around every blocking user-facing
prompt with `event.kind` telling select / confirm / input / editor / custom
apart.
Two handlers, each guarded on its own so an Oh My Pi fork that does not
expose the hook loses only that event rather than the whole bridge:
- `ui_prompt_start` → `permission-request` for `kind === "confirm"` (a
permission or destructive-action gate), `question-asked` otherwise.
- `ui_prompt_end` → `prompt-submit` so the status returns to working once the
dialog closes. Without it the pane would stay on "waiting" until the next
stop, which is wrong for the model's continued work after an answer.
Deliberately not included: `tool-complete`. The Pi bridge emits with
`spawnSync`, so one event per tool call would block the extension host for
the duration of a process spawn on every read/grep/edit.
The test that asserts the bridge's subscriptions now covers both new event
names.
Verified: the `format!` template still compiles and renders both new
handlers, and the bridge contains every string the test asserts.
A host could be described in full in Settings and still not be
connectable from there: there was no password box anywhere on the form.
The only way to store a password was to connect, wait to be asked, and
tick "remember" — and the only way to correct a wrong one was to connect
again and fail first. The key file lived two disclosure triangles deep
under Advanced, as a textarea of paths with nothing to pick one.
The form now carries the credential half of a connection, in an
Authentication block between the address and the collapsed sections:
- A password box, masked with a reveal toggle, seeded from the system
keychain so a stored password can be read back, corrected or cleared
without dialling anything. Clearing it and saving is how a saved
password is let go of.
- Identity files, moved up out of Advanced, with a Browse button that
opens the system picker and writes the path back as `~/.ssh/...`
rather than the absolute path the dialog hands over.
- A key passphrase box beside it, stored against the contents of the key
it unlocks — the same account the connect-time prompt uses. It follows
whichever key the field names, and says so when there is no readable
key to store one against.
Which boxes appear follows the method, the way every other SSH client
does it. The split is `build_spec_inner`'s: a password for Auto and
Password, key passphrases for Auto and Key, and nothing for Agent,
GSSAPI or 2FA — a box outside that would collect a secret, store it in
the keychain, and never offer it to anybody.
Nothing secret reaches the config file. That is also why Save could not
see a typed password: the dirty check compares profiles, and no profile
holds one. It now folds the two secrets in, so Save lights up for a
password the way it does for a port.
Saving moves a password with the address it is filed under — the
keychain accounts by endpoint, not by profile — and leaves nothing
behind under the old one, unless another host still dials it. A
passphrase belongs to its key rather than to this profile, so pointing a
host at a different key never touches the first key's entry.
Test dials with what is on screen rather than only with what is stored,
so it stops reporting a failure the form could not explain.
The layout is the other half of the report. These rows were built out of
the settings rows the rest of the page uses, which push their control to
the far right edge: right for a list of independent switches, wrong for
a form, and it left a hand's width of nothing between the word "Host"
and the box a hostname goes in. Labels now sit right-aligned against
their fields, descriptions and errors moved under the field they are
about, and the three that only restated their label became hints inside
the box.
Two bugs the new shape turned up: a percentage-width control inside a
flex-grown wrapper has no definite parent to resolve against, so the
host and key fields collapsed to one character and the method dropdown
clipped its own menu to "GSSAP"; and "Needs a host" appeared in red on a
form nobody had typed in, because the untouched check counted a port
field that opens on 22 and is never empty.
Claude-Session: https://claude.ai/code/session_01LAqfzqELnoDWU56LBXS1Nh