`install_hooks`/`uninstall_hooks` returned the sentence to show, built in
`tty7-core` — which cannot reach `src/ui/i18n`, where every user-visible
string lives. So the note under Settings → Agents reported "Installed",
"Removed" and "Nothing installed; nothing to remove" in English no
matter the locale, next to a panel translated around it.
Return a `HookOutcome` instead and word it at the call site, where the
other five agent-hook strings already are. The match is exhaustive, so a
new outcome cannot reach the UI without wording in all three locales.
The failure half said only "Failed: {error}" while all sixteen sibling
errors name their action; it now says which of install or removal did
not happen.
Not driven in a dev instance: exercising this panel writes to the real
agent config under $HOME, including the Claude hooks this session runs
on. Covered by the core tests and by exhaustiveness instead.
A first terminal that fails to spawn logged the reason and showed the
home screen — the same screen you get after closing everything. On a
first launch with a bad shell configured, tty7 opened, said nothing, and
every ⌘T after it failed the same way.
The home screen now carries the reason, and a failed New Tab leaves it
there rather than only in a toast that goes away.
The reason itself was worth fixing too. A missing shell arrived wrapped
four deep:
Could not open a terminal: daemon refused Spawn: spawn failed: Unable
to spawn /usr/local/bin/x because it doesn't exist on the filesystem or
is not executable (ENOENT: No such file or directory)
The daemon now checks a shell given as a path before anything tries to
run it, and the two internal prefixes are gone:
Could not open a terminal: no such shell on this machine:
/usr/local/bin/x
A bare program name is left alone — the OS resolves it through PATH, and
guessing there would be worse than saying nothing.
The auth loop seeded its reason with "authentication failed" and only
replaced it when some method actually reported one. So a round where
every method was skipped — no key on disk, no agent, or a connection
pinned to a method this server does not offer — came out as a failure,
which sends people looking for a wrong password that was never sent.
That case now says what it is and names what the server would accept:
"no authentication method could be tried; the server offers publickey".
The disconnected strip also gained the two things it was missing. It
reported only that the connection had ended, so a rejected key and a
dropped network read identically and the reason scrolled away with the
pane's own output; it now shows the reason in the danger ink. And for a
pane spawned from a saved connection it offers "Edit connection…"
beside Reconnect — until now the only button was Try Again, on the one
error class where trying again unchanged never helps.
Tested: unit cover for the no-attempt message (names the offered
methods, never says "failed", and does not trail an empty list when the
server offered nothing). The strip itself was not seen on screen — this
machine has no sshd to fail against, and I did not want to authenticate
against someone else's.
A re-transmitting sender like terminal-browser sends a fresh full-window
frame per rendered frame — ~26 MiB of RGBA at Retina resolution. On the
client that buffer was copied twice for no reason on the way to the atlas:
`decode_frame` allocated a new Vec for the payload tail behind the 30-byte
header, and the uncompressed path of `to_rgba8` then cloned it again before
the in-place BGRA swap.
Thread ownership through instead:
- `Image::decode_frame_owned` consumes the frame Vec the reader already owns
off the socket and drains the header off the front, reusing that allocation
as the pixel buffer rather than allocating and copying a fresh one.
- `Image::take_rgba8` moves the pixel buffer out on the uncompressed fast
path (the shm/file transport hands us pixels already in `f=32` layout), so
`decode` swaps R<->B in place with no clone. The compressed inflate, the
PNG guard, and the declared-dimension inflate bound are unchanged; `f=24`
still repacks because RGB->RGBA changes the length.
Removes two ~26 MiB per-frame touches on the client hot path. On a 3216x2160
frame the decode+normalize step drops from ~1.78 ms to ~0.89 ms — ~0.9 ms
saved per frame, ~53 ms/s at 60fps. This does not touch the wire frame layout
or the daemon-side transfer; it is a pure client-side allocation cut.
* feat(terminal): animate wheel scrolling instead of jumping a notch at once
Sub-line scroll positions were already in place — the view keeps a
fractional remainder and paints the grid shifted by it — but the position
was a function of the event, not of time. A notch arrived and the whole
distance was applied at once, so whether it looked smooth came down to how
fine-grained the platform's deltas happened to be.
A macOS trackpad reports pixels, so it did. A wheel on Windows reports
whole lines (gpui multiplies the notch by the system's scroll-lines
setting, three by default), the fraction came out zero every time, and the
view jumped three lines per notch. The sub-line machinery was present and
never engaged.
A 120-step notch is one discrete pulse; no arithmetic on the delta recovers
a continuous gesture from it. So make position a function of time: a notch
adds to a remaining distance and each frame consumes a share of what is
left, ~120ms to land, exponential, with a sub-pixel remainder snapped
rather than approached since every frame of it costs a repaint.
Line deltas are discrete and get animated; pixel deltas are continuous and
do not — putting an animation between a trackpad and the grid would only
add lag. Mouse reporting and alternate-scroll keep forwarding whole lines,
which cannot be spread over frames either.
The distance in flight is relative rather than an absolute target, so
output arriving mid-scroll shifts the grid without dragging the animation
elsewhere. Everything that moves the view on its own cancels what is in
flight first.
Settings -> Terminal -> Scrolling -> Smooth scrolling, on by default.
Also pin the scratch config dir in the terminal-view test harness: building
a view reads the config, and which test got there first decided whether
that touched the real user directory.
* fix(terminal): tell a trackpad from a wheel by phase, not delta type
The first cut split the two on the delta variant — Lines meant a wheel,
Pixels meant a trackpad. That holds on Windows and Linux. It does not hold
on macOS, which sets hasPreciseScrollingDeltas for a wheel mouse as well, so
a wheel reported pixels, took the trackpad path, and was never animated.
Measured on this machine, one detent arrives as a single ~103px event —
about five lines at a 21px line height, applied in one go. That is a worse
jump than the three lines Windows produced, and it was invisible to a check
based on the delta's type.
Size cannot separate them either: a trackpad flick reaches ~3 lines in one
event while an inched wheel moves ~0.6, so the ranges overlap and any
threshold misclassifies a quarter of the trackpad stream.
Phase can. Only a device that can gesture ever reports Started/Ended; a
wheel is Moved forever, on every backend. So track the gesture instead —
and hold it open on a 150ms idle timer rather than closing it on Ended,
because lifting the fingers is not the end of the stream: the momentum tail
keeps delivering Moved events larger than the gesture that spawned them,
and animating those would smooth what the system already smoothed.
Also skip the animation for jumps under a line. Inching a wheel one detent
at a time lands there, and spreading a half-line move only adds lag to
something that already reads as continuous.
Tests are now shaped after the measured event streams rather than after an
assumption about which variant each device sends.
* chore: trigger a Claude review of this branch
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
* fix(daemon): notice a server that is only a control dialect behind
The pane protocol and the control dialect are versioned apart, and the
launch check only compared the first. A server from before the v4 to v5
control bump answers the pane handshake with this build's own number, so
it was waved through as ours while every machine-tree call was refused:
the window opened with no tabs, the only trace a log line the default
config does not write anywhere.
Ask the control socket too, before calling the server ours, and say which
version disagrees when offering the restart. A window that still opens
empty now says why in the window it happened to.
* fix(daemon): do not call a newer server old, and say the empty-window reason once
The dialect handshake reports disagreement, not direction, but the restart
prompt read every mismatch as "from an older build". A daemon left running by
a newer build passes the pane-protocol check the same way an older one does,
so the prompt told users their newer server was old — the same wrong guess the
remote path stopped making in #384. Split the copy in two and pick by which
side is ahead, as `dialect_complaint` already does for remotes.
The window's own explanation had two ways to misfire. A hydration superseded
by a newer one still announced an emptiness someone else was already filling,
because only `owe_rehydration` checked the epoch; it now reports whether it
claimed the debt and only the owner speaks. And since a failed pull is retried
from every `sync_window`, a machine that never answers repeated the same
notification every fifteen seconds — latched to once per window, cleared when a
pull finally lands so a later outage still gets a word.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
The update channel becomes a property of the installation rather than
something derived from how version numbers happen to sort, so a Nightly
follows Nightly instead of being walked back onto Stable by an update it
never asked for. Stable reads /releases/latest, which excludes
prereleases; Nightly reads /releases/tags/nightly. Neither feed can hand
the other an update, so an installation only changes channel when the
user changes it in Settings.
The nightly release cannot state its version in its tag — `nightly` is
force-moved every night, so `tag_name` is the literal string. It now
publishes nightly.json beside the packages, falling back to parsing asset
filenames for builds that predate the manifest. Prereleases are ordered by
every numeric identifier in the stamp, and the stamp goes to the minute so
two builds in one day are distinguishable; a stable release still outranks
every dated build of its core version, which is how switching back to
Stable graduates instead of downgrading.
Switching channel invalidates what the old feed produced: the staged
package, the deferred prompt, and the transfer still in flight, which
would otherwise finish and stage a build from the channel the user just
left. Settings keeps one action on the update row rather than three —
the update dialog covers the rest, but it is a moment rather than a place,
and where the package cannot be installed for the user the release page is
the whole update path. Skipping a version is retired along with its state,
its Settings row, and its localization keys.
Also carries the staging work this was branched from: an update is fetched
and verified while the prompt is up, so installing it is a restart, and
declining one defers it instead of retiring it permanently.
A handshake refused on the control dialect was shown with the protocol
layer's own wording — "java answered, but not as a tty7 server: control
peer (build …) speaks control v4, this build speaks v5". The far end is
tty7; it is a build on the other side of a dialect bump, and the reader
cannot act on the dialect numbers either way. Restate it as which side is
behind, and name the action on the button: "Update Server", not
"Restart Server".
The failure that followed was also invisible. The switcher paints a failed
`connect` in preference to `remote_host_errors`, and restarting or
replacing a server cleared only the latter, so whatever went wrong during
the install was covered by the complaint that started it — the button read
as doing nothing at all.
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
* feat(switcher): two-column panel with IDEA-style Ctrl+Tab
The switcher listed workspaces only; reaching a tab inside one meant
opening it first. It is now two columns — workspaces on the left, the
tabs of whichever one the cursor sits on to the right — and Ctrl+Tab
raises it as a most-recently-used tab switcher that commits when the
modifier comes up.
Picking a workspace or a tab now switches this window in place. A second
window is something you ask for, with the platform modifier or "Open in
New Window", rather than what happens by default.
New workspaces get a codename ("amber-yak") instead of inheriting
whatever directory their first shell started in. The generator moves out
of worktree.rs into core::codename so both callers share it.
* fix(switcher): review fixes — platform-gate the Ctrl+Tab key test, park the new-window tab
- The key_intent test asserted only the macOS half of the Tab chord; off
macOS Ctrl *is* the secondary modifier, the raw key falls through on
purpose (the chord arrives as NextTab), and the test now says so
instead of failing there.
- open_at_tab probed a freshly opened window's tabs exactly once, but a
new window hydrates them asynchronously — route through
activate_tree_tab so the pick parks until the tab arrives.
- Escape mid-rename backs out of the rename instead of tearing down the
whole panel.
- A one-tab workspace reads '1 tab', not '1 tabs'.
Claude-Session: https://claude.ai/code/session_01V7xjxdUGWQp93KQDuFuh8d
* style: cargo fmt
Claude-Session: https://claude.ai/code/session_01V7xjxdUGWQp93KQDuFuh8d
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
The single en/zh tuple table becomes one module per language behind a
`SUPPORTED_LANGUAGES` table, and Japanese joins English and Simplified Chinese.
- `gui_language` accepts `ja-JP`; anything unrecognized still falls back to `en`.
- The language picker and `refresh_locale_state` both read `SUPPORTED_LANGUAGES`
instead of keeping their own copy of the code list.
- Language names in the picker stay endonyms (English / 简体中文 / 日本語) in
every locale, as English and Chinese already were.
- The zh and ja key tables are exhaustive, so a new `L10nKey` fails the build
until it is translated rather than silently rendering English.
Co-authored-by: Chihiro WATANABE <chihiro.watanabe@live.jp>
The GUI update check already uses reqwest, which reads the Windows
system proxy from the registry by default. The remote server
installer / bundled-server fallback uses ureq, which only reads
HTTP_PROXY/HTTPS_PROXY environment variables unless the
win-system-proxy feature is enabled.
Enable ureqs win-system-proxy feature so that release downloads
inside the daemon also honor the Windows system proxy set by tools
like Clash (System Proxy mode), v2rayN, etc. This is a no-op on
non-Windows platforms.
Fixes the inconsistency where the update check could reach GitHub
through the proxy but the actual download would time out trying to
connect directly.
Resolve an HTTP/SOCKS proxy for tty7's own update checks and release downloads, from (in order) a new `http_proxy` config field, the platform system proxy — Windows registry / macOS SCDynamicStore — and the HTTP_PROXY/HTTPS_PROXY/ALL_PROXY environment variables.
Programs running in a pane are deliberately unaffected: they inherit their proxy from their own environment, as in any other terminal.
Fixes#365.
Removes the Outline right panel and all of its wiring: the `RightPanelTab::Outline` variant, the `ShowRightPanelOutline` action and its handler, the tab-strip button, the palette command, the keymap arm, `render_panel_outline`, `TerminalView::{command_marks, scroll_to_mark}`, the `one_line` helper, and four i18n keys.
An existing config value of "outline" falls back to the default Info tab via `de_lenient`, and a user keybinding naming `ShowRightPanelOutline` degrades to a logged warning rather than breaking the keymap.
Also drops what the removal stranded: `Marks::list` is now `cfg(test)` (its last production caller was `command_marks`), and `icons/list.svg` — the Outline button's icon, with no other user — is gone.
Closes#374
The per-window taskbar overlay badge (#355, for #199) is removed, and with
it the in-flight follow-up that was making its green "finished a turn"
state reachable: the feature is not wanted. Nothing shipped — the badge
only ever existed in Unreleased — so this is a plain removal rather than a
deprecation, and its CHANGELOG entry goes with it instead of gaining a
"Removed" counterpart.
What goes: `ui::taskbar` and its `ITaskbarList3::SetOverlayIcon` poll, the
`taskbar_status_icon` config flag and its Settings → Window & Tabs row and
strings, `Tty7App::taskbar_signals`, `TerminalView::shell_busy` /
`RemoteTerminal::shell_busy` (the overlay was their only caller), the
`raw-window-handle` dependency and the `Win32_UI_WindowsAndMessaging`
feature it needed, and the feature docs in both languages. A stale
`taskbar_status_icon` left in someone's `config.json` is ignored, as any
unknown key is.
The tray badge and the in-window status dots are untouched; they were
always the ones the taskbar was mirroring.
* fix(windows): bundle Microsoft's ConPTY so panes can answer color queries
The in-box conhost swallows a pane process's OSC 11 background query: it
never reaches tty7's emulator and no reply is ever written back, so
applications that choose a light or dark UI from the terminal background
render a dark UI under a light theme.
tty7 already answers OSC 10/11/12 from the live theme, so nothing was
missing but a pseudoconsole that forwards the question. Microsoft ships one
as a redistributable, and portable-pty already prefers a sideloaded
conpty.dll over kernel32's, so this is packaging rather than code: the pair
goes beside tty7-app.exe, where the DLL search path finds it.
Measured on Windows 11 26200, same binary, only the pair added beside it:
in-box conhost: the terminal side never sees the query; the client times
out with no reply
bundled ConPTY: the terminal side sees ESC]11;?BEL and a real pane reads
back rgb:efef/f1f1/f5f5 under catppuccin_latte, which is
the preset's exact background
The two files are one supported unit, so the release verifier fails a
package that carries only one, a mismatched pair, or the MIT notice-less
DLL. They also join PORTABLE_MANAGED_ROOTS, without which the updater would
reject every portable archive that contains them; they are deliberately not
required by verify_portable_payload, since tty7 runs without them and a
packaging slip should fail the release rather than a user's update.
build.rs stages the pair beside cargo's output so a development build does
not quietly run on the in-box host, and the daemon logs which pseudoconsole
it got.
Closes#345
* fix(windows): restage the bundled ConPTY when it goes missing
Watching only the vendored sources meant a staged copy that left the target
directory stayed gone: the build script was cached, so it never ran again to
put it back, and the build silently fell back to the in-box conhost. Cargo
treats a rerun-if-changed path that does not exist as changed, so naming the
destinations makes the staging self-healing.
Found by deleting target/debug/conpty.dll and watching the next build not
bring it back.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
* feat(windows): taskbar status overlay per window (#199)
Stamp a colored status dot on each window's taskbar button using the
same palette as the in-window agent dots:
- blue while a shell command or agent is working,
- amber when an agent is waiting on the user,
- green when work finishes while the window is unfocused (cleared on activation).
Adds a `taskbar_status_icon` setting (default on, Windows only) and a
Settings -> Window & Tabs row. The overlay is updated by a foreground
poll that aggregates agent status and shell busy state across each
window's panes, diffing against the current taskbar badge and only
calling ITaskbarList3::SetOverlayIcon when the badge changes.
Includes unit tests for overlay priority and the done-while-unfocused
edge tracking.
* fix(taskbar): retry a failed overlay instead of caching it as drawn
Four fixes on top of the overlay:
- A failed SetOverlayIcon was still recorded in `shown`, so a badge the
taskbar never took was remembered as drawn and never retried. Stamp now
reports success, and a failure drops the interface so the next tick
re-creates it — which is also what an Explorer restart needs.
- `create_failed` was a permanent latch: one CoCreateInstance failure
killed the badge for the whole process, though Explorer may simply not
be up yet when the first window opens. Use the tray's attempts/cooldown
backoff instead, which this module otherwise copies.
- The overlay's accessibility description was hard-coded English in an
app that localizes everything else. Reuse the panel and tray strings.
- Render the dot at 32px, not 16. SetOverlayIcon wants 16x16 at 96 dpi,
so at 150%/200% scaling the shell upscaled a 16px icon; `tray::icon`
already renders at 32 off macOS for the same reason.
Also drops the Win32_Graphics_Gdi feature: CreateIcon, DestroyIcon and
HICON all live in Win32_UI_WindowsAndMessaging, and the build and the
taskbar tests pass without it.
Claude-Session: https://claude.ai/code/session_01H9QqEZ6JH3dGS6atEcf6ab
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: l0ng-ai <ysdpk123@gmail.com>
* feat(bell): add combined visual + audible terminal bell mode
Currently BellMode only offers None, Visual, and Audible. Audible falls
back to Visual if the system bell cannot be rung, but there is no way to
intentionally get both at once.
Add a `Both` variant that rings the system bell *and* flashes the pane,
exposing it as a fourth option in Settings -> Terminal -> Bell. Existing
`none`/`visual`/`audible` values remain backward compatible.
Updates config serialization tests and i18n keys/translations/test list.
* fix(bell): align the settings copy and picker with the new Both mode
The bell description still enumerated three outcomes and the settings
search keywords omitted both, so the new mode was invisible to search
and contradicted by the row that labels it. Drop the picker's catch-all
onto the default instead of Both, which is the shape the PR just fixed.
Claude-Session: https://claude.ai/code/session_01H9QqEZ6JH3dGS6atEcf6ab
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Windows hands every process a private copy of the environment block at
`CreateProcess` time and never updates it. A tty7 daemon that has been up
since before an installer edited `HKCU\Environment` therefore gave a
brand-new pane its startup `PATH`, and the freshly installed command was
unresolvable until tty7 restarted (#333) — while a Windows Terminal
launched from Explorer found it, because Explorer rebuilds its own block
when it sees `WM_SETTINGCHANGE`.
Rather than chase broadcast messages, `daemon::windows_env` re-reads the
two hives Windows itself composes a process environment from — the
machine `Session Manager\Environment` and `HKCU\Environment` — at the
moment a pane is spawned, and pins the merge onto the pane's command.
The merge is a pure function over (machine, user, process, configured
overrides), so every semantic that matters is unit-testable without a
registry:
- Names are keyed case-insensitively, so a `Path` from the process block
and a `PATH` from a hive collapse into one variable instead of reaching
the child as two.
- `PATH` and `PSModulePath` are *combined* — machine value first, user
value appended — which is what Windows does and what keeps a per-user
install from shadowing the system half. Nothing is ever written back to
the user hive; that would bake the machine half into it permanently.
- `REG_EXPAND_SZ` values are expanded against the merged map, so a user
value naming a machine value naming a process value resolves. A
reference that resolves to nothing is left verbatim, as Windows leaves
it, and the chain is depth-bounded so a self-referential value cannot
hang the spawn path.
- The machine hive's `USERNAME=SYSTEM` is dropped, as Windows drops it.
- Directories the daemon's own `PATH` held that neither hive lists stay
reachable, appended behind the registry entries: freshening `PATH`
should only ever add resolvable commands, never take one away.
- Configured `env` overrides are applied last and win outright.
Non-Windows builds are untouched: only the registry reader and the spawn
wiring are `cfg(windows)`, while `cfg(test)` keeps the pure merge
compiling everywhere so its tests run on every platform.
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
A pane configured to run fish still advertised the login shell, because
pane_environment() injected TERM, the TTY7_* markers and TERM_PROGRAM
but never touched SHELL -- so the pane inherited the GUI session's
login-time snapshot of it. Everything that spawns "the user's shell"
read that: tmux's default-shell started zsh inside a fish pane, and so
did sudo -s, an editor's shell escape, and any coding agent picking a
quoting dialect from $SHELL. The failure is silent -- fish rejects the
bash line, the agent's sentinel file never appears, and the rejected
text stays in the line editor to concatenate onto the next send.
Inject SHELL alongside the other markers, set to the absolute path of
the program the pane is about to exec. That program is read off argv
rather than off the shell tty7 resolved: an argv-replacing integration
injection and the parent-shell override both rewrite argv, while
CommandBuilder::get_shell() keeps answering the passwd entry.
Only an absolute path is ever written. A configured command may be bare
(the inventory keeps it bare so PATH decides which install wins), and
consumers exec $SHELL under a PATH of their own, so a bare name is
resolved against the PATH the pane will inherit -- the user's env-block
PATH when they set one -- and skipped when that finds nothing. A stale
login shell beats a name that resolves somewhere else.
An explicit SHELL in the user's env block still wins, the same
precedence TERM_PROGRAM has: tty7 describes the pane, the user's config
gets the last word.
Windows is deliberately left out. Neither cmd nor PowerShell reads
SHELL; the tools that do are the POSIX emulations (MSYS/Git Bash,
Cygwin, WSL), and they want a POSIX path, not the Windows one this
would have to give them. That also leaves the WSL pane alone, where the
pane program is wsl.exe and the distro's own login shell is the right
answer.
Native SSH panes are unaffected: they never build a local command, and
the remote sshd sets SHELL from the remote passwd entry.
Closes#342
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
* fix(install): prefer bundled server over release download for SSH remotes
SSH remote installs used , which only checks
and ignores the server binary already shipped next
to the Windows executable. WSL already uses
to find that bundled binary.
Add to auto-discover the bundled server and
fall back to the GitHub release download only when no matching local asset
exists. Switch and to use it.
This lets the Windows installer/zip (which already stages server binaries
under <exe>/server/) satisfy SSH remote installs without hitting the network.
The explicit path keeps its strict no-fallback behavior, and WSL
remains bundled-only.
Refs: future issue/PR for bundling server binaries into Windows releases.
* style(install): fix rustfmt formatting in bundled-server tests
---------
Co-authored-by: l0ng-ai <ysdpk123@gmail.com>
* fix(windows): advertise terminal background to TUI apps
* refactor(windows): keep the background hint out of config.json
The daemon needs to know whether the window is light or dark when it
spawns a Windows pane, because ConPTY drops the child's OSC 11 query
before tty7's emulator can answer it. It was reading that from
`Config::theme` — a field nothing had written since it went dead — which
meant the GUI had to rewrite the user's `config.json` every time the
effective preset changed sides.
Move the hint to `appearance.json`, beside `machine.json` in the data
dir, and leave `Config::theme` exactly as it was. It is derived state:
written by the process that paints the window, read by the process that
has to describe it, and of no interest to the user. A file of its own
rather than a field on `Machine`, because the machine tree is owned by
the daemon and flushed on a timer, so a second writer would clobber the
workspaces and panes it had not seen. Absent, unreadable, and unparsable
all read as light — what the default preset is — so a daemon that starts
before the GUI has ever applied a theme describes the default window
instead of guessing.
Also silence the `unused variable` warning the hint parameter raised on
every non-Windows build, where the `COLORFGBG` block it feeds is
compiled out.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(ui): add GUI localization for en and zh-Hans
* feat(ui): localize search placeholders and relative time
* feat(ui): localize palette, switcher, and sftp strings
* feat(ui): localize home shortcut labels
* feat(ui): localize tray, ssh prompt, and editor strings
* feat(ui): add plural/select i18n helpers and localize sftp/settings labels
* feat(ui): localize settings search, forwards panel, and file tree
* feat(ui): localize code editor and right panel
* feat(ui): localize stop/delete workspace confirmations with plural support
* feat(ui): localize diff overlay with plural-aware summary
* feat(ui): localize pending pane, worktree prompt, and home time strings
* feat(ui): localize app menus, tray, tab strip/sidebar, and remote status strings
* feat(ui): localize switcher, file_tree, machine_mirror fallback strings
* feat(ui): localize ssh prompts, theme presets, host error wrapper, and finish remote strings
* feat(ui): localize command palette strings
* feat(ui): localize app.rs notifications, prompts, placeholders, and parse errors
* feat(ui): localize remaining theme, switcher, settings, and sftp strings
* style: cargo fmt
* feat(ui): add language selector to settings
* fix(ui): refresh locales across windows
* refactor(ui): make GUI language selection explicit
* fix(ui): localize Explorer settings after merge
* fix(ui): keep persisted theme names out of the GUI locale
A theme's name is data, not chrome: it is written into the theme YAML and
matched back with `trim_end_matches(" (custom)")`. Translating it meant a
Chinese GUI forked "Nord" into "Nord(自定义)", the next fork stacked a second
suffix on it, and the name stayed Chinese after switching back to English. The
derived-name fallback had the same problem. Both are English again.
Also in this pass:
- Give each test thread its own locale override. The locale is process-wide and
tests run in parallel, so the two tests that switched to zh-CN could flip the
language out from under another thread's English assertions.
- Rebuild the menu bar when gui_language changes in config.json, the way the
in-app picker already does — otherwise the menus kept the old language.
- Document the values the setting actually accepts. The docs still described
`auto` and `zh-Hans`, which sanitize() resets to `en`.
- Put the English words back into the Chinese search keywords for the language
setting; the other 58 keyword sets keep them.
- Drop the unused is_zh_hans helper.
---------
Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
* add CLI support for opening directories in new tabs
f
* feat(windows): add optional windows explorer context menus
f
* fix(gui): restore missing windows and reject lossy paths
* fix(windows): harden explorer menu registration and native path handling
* fix(cli): preserve native GUI paths on Windows
---------
Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
The rcfile tty7 hands to bash replays the login-shell startup chain, but
sourced ~/.bashrc unconditionally after it. A login shell never does that
on its own — ~/.bashrc arrives only because the profile that won the chain
forwarded to it, which is how nearly every ~/.bash_profile is written. The
result was the user's whole ~/.bashrc running twice per pane: banners
printed twice, completions were sourced twice, and appends to
PROMPT_COMMAND stacked up.
Move ~/.bashrc into the same first-match-wins chain. That fixes the double
source and still keeps the fallback for a $HOME with no profile at all.
The existing test only asserted the rcfile mentions ~/.bashrc, which the
buggy version satisfied too. Add one that runs real bash against a
throwaway $HOME whose .bash_profile forwards, and counts the sourcings.
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
$SHELL is a snapshot the session inherits at login, so chsh never moves
it -- a GUI launch keeps reporting the shell that was current when the
user logged in, and goes on doing so until they log out. The window's
shell menu marked the wrong entry "default" for that whole stretch.
Read the passwd entry instead, via getpwuid_r -- the reentrant form,
since getpwuid returns a pointer into a shared static another thread's
lookup can overwrite. $SHELL stays as the fallback for the rare case
where the lookup fails. The three callers that each reached for the
variable on their own -- the default-name lookup, the PATH enrichment
that runs the login shell at startup, and the shell-integration kind
probe -- now share the one function.
Same commit fixes who wins a name in the menu. Candidates were login
shell, then /etc/shells, then $PATH, and dedupe keeps the first -- so
on a machine with a Homebrew bash, /etc/shells listing /bin/bash first
handed the entry to macOS's 3.2 from 2007, old enough that
bash-completion 2.x will not load against it. Probe $PATH before
/etc/shells and widen the probe list to the POSIX shells, so the menu's
"bash" is the binary typing bash would reach; /etc/shells still catches
anything installed off $PATH.
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
When a pane inherits no locale at all — the usual case for a GUI-launched
process on macOS — we derive an installed UTF-8 locale and inject it. But
we injected it as LC_CTYPE, which backs only character handling. Collation,
time and numbers stayed at C, and a shell that asks setlocale(cat, "") per
category finds no variable for the rest: bash warns
setlocale: LC_COLLATE: cannot change locale ()
once per category on every launch. zsh and fish swallow the failure, so
they merely look fine while being just as half-configured.
LANG backs every category and still loses to any LC_* the user's own rc
files set afterwards, which is what a fallback should do. LC_ALL would also
cover everything but would override those.
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Some Windows shell brokers enforce `ProcessRedirectionTrustPolicy` on what
they launch. The daemon inherited it, every ConPTY shell under the daemon
inherited it in turn, and PowerShell could then no longer traverse a
user-created junction — which is exactly what Scoop's `current` links are.
`oh-my-posh` and `fzf` died with `Shim: Could not determine if target is a
GUI app`. Windows Terminal was unaffected because its process tree never
picked the policy up.
The policy cannot be relaxed once enabled, so the fix is to not inherit it:
when tty7 detects the enforcing bit, it creates the daemon with
`STARTUPINFOEXW` and `PROC_THREAD_ATTRIBUTE_PARENT_PROCESS` naming the
interactive desktop shell, which supplies the ordinary desktop token, device
map, and mitigation policy. The Win32 code stays isolated in
`daemon/spawn/windows.rs`, and the ordinary path still runs whenever the
policy is absent — or whenever the desktop shell cannot be borrowed, in
which case tty7 logs a warning and starts degraded rather than not at all.
Because naming a logical parent makes handle inheritance follow that
process, the daemon starts with no standard handles. `daemon::server` and
the pane reader's trace line now write to stderr in a way that tolerates
that, instead of `eprintln!`, which panics on a failed write.
ConPTY exit ordering: the process-exit monitor could observe a short-lived
shell exiting before the reader had delivered its final frame, so `Exited`
reached clients ahead of the output that preceded it. The monitor now
releases the pseudoconsole and lets the reader — which reports only after
forwarding everything up to EOF — announce the death, with a bounded window
behind it for the case where EOF never arrives because a grandchild holds
the ConPTY output pipe open.
Note this changes the daemon's token on the clean-parent path: it derives
from Explorer, so an elevated tty7 starts a medium-integrity daemon.
Co-authored-by: ARNO <ArnoChenFx@users.noreply.github.com>
capture_plain_returns_text_not_escapes gated its byte-level asserts on
the marker reaching the rendered capture, then asserted the raw capture
already carried a CR. The two captures are separate snapshots taken in
sequence, and on Windows ConPTY re-emits the echoed command in
escape-laden bursts: the marker can render (from the typed input line)
while the slightly earlier raw snapshot has yet to see a single CR —
Enter's CRLF only arrives with the command's execution. CI hit exactly
that window on x86_64-pc-windows-msvc.
Make the CR part of the settle condition the loop polls for, alongside
the marker, and name both in the timeout message so a genuine
CR-stripping regression still reads as one.
Co-authored-by: l0ng-ai <ysdpk123@gmail.com>
Three holes in the wait primitive, all of which make a delegation loop
answer with something other than what it asked for.
The agent status the server keeps is a level, not an event: `done` stands
until the next turn begins, `waiting` until the agent moves again. A wait
issued right after a `send` therefore returned last turn's state before
the worker had even read the input, and a second task in the same pane
matched `--until done` instantly. `--changed` snapshots the position the
wait arrived at — status plus the activity counter, which ticks even when
the status letter does not — and refuses to match it; the JSON carries
`stale` so a plain wait can tell whether the answer might be a leftover.
`exit` was unreachable for any pane that had ever had an agent: the
snapshot has no liveness in it, and the daemon keeps a dead pane
registered until it is closed. A worker that crashed mid-turn reported
`working` until the timeout. Liveness is now re-checked from the tree
every few polls; the fast path where the first poll already answers still
costs exactly one request.
The "pane exited before reaching the awaited state" branch built its JSON
and then threw it away on an anyhow error, leaving `--json` with nothing
to read and the exit indistinguishable from an unreachable daemon. It now
exits 1 with its report.
Also: the wire spelling of a state is written out instead of derived from
the variant name, the sleep no longer overshoots a near deadline, an
absurd `--timeout` cannot overflow, and `--interval` is range-checked at
parse time rather than silently clamped.
* feat(cli): `tty7 wait` + the agent-coordination note
The two pieces of the original session-CLI PR that main's own CLI
doesn't cover, rebuilt as a minimal delta against it.
`tty7 wait %N --until waiting,done --timeout 600` blocks until a pane's
agent reaches a requested state — the orchestration primitive that lets
one agent sleep until its peer blocks on a permission prompt or
finishes a turn, instead of screen-scraping. A poll of `AgentStates`
rather than an `events` subscription on purpose: a one-shot stateless
question composes into scripts, survives a server restart mid-wait, and
needs no cursor management. Agentless-but-live panes read as idle via
the machine tree; a dead or vanished pane reads as exit, which ends
every wait (matched only when asked for). Timeout exits 124, the
`timeout(1)` convention.
The coordination note is discovery for the whole CLI: a marked,
idempotent block describing the verbs, installed into
~/.claude/CLAUDE.md (always; CLAUDE_CONFIG_DIR honored) and
~/.codex/AGENTS.md (only when ~/.codex exists). A one-time "Let your
agents coordinate?" prompt fires the first time a pane detects a coding
agent; a Settings → Agents switch drives the same install/remove, with
state read from the files themselves. Uninstall strips exactly the
marked block; an unterminated block is left alone rather than truncated
at a guess.
* feat(agents): replace the global note with an orchestration skill
Per review: global instructions tax every session's context and hand
every agent — workers included — the ambient authority to orchestrate
its neighbours. The common shape is primary → workers: one agent owns
decomposition, dispatch, waiting and aggregation; workers just do
bounded tasks.
A Claude Code skill fits that exactly. `core::orchestration_skill`
installs ~/.claude/skills/tty7-orchestration/SKILL.md — only its
one-line description rides in context until the user or the primary
agent explicitly invokes it, and workers never see it. The body can
therefore afford the full delegation loop (tab new → send → wait →
answer-or-capture → pane close) instead of a token-starved cheat
sheet.
The file is wholly tty7-owned: install is a plain overwrite (also the
version-refresh path), and uninstall keys on an ownership marker so a
user's hand-written skill under the same name is refused, not deleted.
Gone with the global note: the first-agent-detected prompt, its config
flag, and the CLAUDE.md/AGENTS.md writers — the Settings → Agents
switch now drives the skill install instead.
---------
Co-authored-by: l0ng-ai <ysdpk123@gmail.com>
pane_title_of() picked any non-empty PaneRecord.title, but an idle
terminal's foreground process is just the shell itself (zsh, bash, ...).
That made almost every idle-shell workspace show up as "zsh" in the
sidebar instead of the far more useful cwd/repo-derived name, which
defeats the "cwd as final fallback" intent of this change.
Skip bare shell process names when picking a pane title, so the
process-name fallback only kicks in for genuinely distinctive
foreground processes (nvim, an agent, ...).
The CLI's own --help calls it "built for coding agents", but `capture` handed
back the daemon's raw PTY bytes, which is the least readable thing it emits,
and every verb panicked when its reader hung up.
`capture --plain` replays those bytes through a terminal grid instead of
stripping escapes from them, using the same alacritty_terminal rev the GUI
renders panes with. The difference is not cosmetic: only the grid knows that a
break at the pane's width was a wrap rather than a newline, that a CR meant
"overwrite this line" rather than "end it", and which cell a wide char shares
with its spacer. A regex gets the easy 90% and then invents the rest — on one
real pane it turned 1193 lines into 2806.
The size each segment needs comes for free: the daemon already sends
DaemonMsg::Size right before every Snapshot, and the CLI was discarding it.
Panes here measure 249 and 86 columns, so the hardcoded 120 would have wrapped
both in the wrong places. Observing still resizes nothing.
The pipe fix is two mechanisms with one contract. On Unix SIGPIPE goes back to
its default disposition, which covers every write site at once and ends the
process the way it ends `cat` (141). Windows has no such signal, so stdio::out
recognizes the hung-up write and leaves quietly. Before this, 16 of 19 verbs
printed a panic and a backtrace note for `tty7 ls | head -1`; `run` instead
reported it as a failure with exit 1.
Also adds skills/tty7, the Claude skill for driving this CLI. It shipped with a
Python ANSI stripper, which is what prompted --plain; the script is gone.
alacritty_terminal moves to [workspace.dependencies] so the GUI and the CLI
cannot drift onto two revs of the fork.
a_slow_stream_outlives_its_idle_timeout proved that the idle window
resets per message by having a thread sleep 60ms between sends and
trusting that to stay under a 150ms timeout. That is a 90ms margin
against the OS scheduler, and the darwin CI job lost the bet.
It also tested the wrong thing: whether recv_timeout fires after the
deadline is the standard library's contract, not ours. What is ours is
that the loop restarts the window on every message rather than budgeting
the whole stream.
Make the one blocking call injectable and script it. Both timing tests
now feed drain_git_stream a fixed sequence -- no threads, no sleeps, no
wall clock -- and the pair runs in microseconds. The queue-budget tests
keep their real channel, which is what they are about. Rename the second
test to say which property it holds.
The `tty7` CLI was built by every release run and thrown away: all four
bundle scripts copied only `tty7-app`, and the upload glob covers `dist/`,
which the CLI never reached. Nothing put it on PATH either, so the
agent-facing half of the product was unreachable from a shipped install.
Bundle it on all four platforms, and have the GUI link it up itself rather
than hiding the step behind a menu item most people never find.
The install has two halves. The environment half prepends the CLI's
directory to this process's PATH before the daemon is spawned, so every
pane inherits it — that alone makes `tty7` work where agents actually run,
writes nothing to disk, and behaves the same everywhere. The on-disk half
symlinks into a directory already on PATH (Unix) or appends to
HKCU\Environment (Windows), and is allowed to fail.
Candidate directories are a fixed list intersected with PATH, not the first
writable entry on it: pyenv/rbenv/asdf/mise shim directories sit at the
front of PATH on many machines and are writable, and anything dropped there
is deleted on the next rehash — silently, days later.
Debug builds get the environment half only. `target/debug` holds a `tty7`
too, so otherwise a `cargo run` would repoint the developer's real `tty7`
at a debug binary, and each isolated dev-verify instance would rewrite the
PATH of the machine it is meant to stay away from.
Listing WSL distros shells out to wsl.exe with no time limit, so a WSL
service that is starting up, updating, or simply wedged blocks the whole
shell probe -- and with it the menu of shells the window offers. The
Windows CI job hit the slow end of this often enough to flake.
Add proc::output_within, which spawns the child, drains both pipes on
their own threads so a large write cannot deadlock the reaper, and kills
the child once the deadline passes. Give the distro listing three seconds
of it; a machine that cannot answer by then simply reports no distros,
which is what an unreachable WSL already produced.
Both socket_path_for and socket_path_in live behind cfg(unix) — Windows
serves the control channel over a named pipe. The test referencing them
broke the Windows lib-test build. Gate the test, and narrow the
socket_path_in re-export to test builds so it stops warning as unused.