Commit Graph
661 Commits
Author SHA1 Message Date
ARNOandl0ng-ai 61efe27f2d feat(windows): add native backdrop material presets (Mica / Acrylic /… (#412)
* feat(windows): add native backdrop material presets (Mica / Acrylic / Blur)

Adds a Background material dropdown (Auto / Blur / Mica / Mica Alt /
Acrylic / Off) that maps onto the native Windows backdrop APIs already
provided by the gpui fork — Mica and Mica Alt via
DwmSetWindowAttribute(DWMWA_SYSTEMBACKDROP_TYPE), Acrylic via the new
DWMSBT_TRANSIENTWINDOW material, and Blur via the classic
ACCENT_ENABLE_ACRYLICBLURBEHIND path — with no fork changes required.
* config: introduce WindowBackdrop in tty7-core with lenient kebab-case
  deserialization, defaulting to Auto for existing configs
* theme: resolve the backdrop through a build-number fallback chain
  (Mica/Mica Alt need Windows 11 22H2, Acrylic needs 22H2 natively and
  1809 via classic acrylic, Blur needs 1809; older builds fall back to
  plain translucency) and default the background alpha to
  SYSTEM_MATERIAL_OPACITY (0.82) while a material is active
* settings: replace the blur toggle with a localized backdrop dropdown
  that only lists the presets the current Windows build actually
  supports, and keep the settings panel fully opaque so workspace
  translucency never shows through it
* theme: make the file sidebar and right detail panel follow the window
  opacity so the backdrop material shows through the whole workspace,
  keeping row-level accents opaque for readability
* i18n: add backdrop keys for en, zh-CN and ja-JP, covered by the
  translation completeness test

* feat(theme): let the sidebar and right panel follow the window opacity

* update GPUI

* fix(windows): gate the sidebar translucency to translucent windows and sync the opacity slider

fix(windows): gate the sidebar translucency compensation to active materials

* fix(windows): derive the material opacity default from the resolved appearance

* fix(theme): keep WindowBackdrop semantics consistent on non-Windows

f

* fix(theme): stop Windows-only materials from pinning the blur on other platforms

* docs(changelog): document the Windows backdrop material settings

* refactor(theme): share the default window-opacity derivation

* fix(ui): keep gradient presets behind the settings panel and scope its fallbacks

* fix(ui): keep the settings theme picker legible and the backdrop label honest

f

* fix(theme): let every backdrop variant defer to the local blur toggle on non-Windows

* fix(settings): restore the backdrop dropdown selection on locale refresh

* fix(ui): keep the opened-file editor surface opaque under window translucency

* fix(settings): rebuild backdrop options after selection

* fix(settings): ignore synced windows backdrop overrides on other platforms

* fix(settings): preserve synced windows backdrop on non-windows reset

* fix(diff): keep the full-window overlay background opaque

* fix(windows): keep Auto opaque and stop the backdrop from misreporting itself

Ten findings from a review of the backdrop-material work, all in the
Windows-only paths.

The root one: `material_active` treated `Auto` as a material whenever the
legacy blur toggle happened to be on. `Auto` is the default in every config
written before this setting existed, and plenty of them carry
`window_blur: true` from the switch that no longer renders on Windows, so an
untouched install would drop from opaque to 0.82 alpha - with its file
sidebar and right panel at 0.15 - on first launch after the update, with no
visible control to undo it. Only an explicit pick in the dropdown now buys
the translucent defaults. The switch comes back on Windows while the
backdrop is `Auto`, since that is exactly when the legacy flag still decides
something.

The rest:

- Mica and Mica Alt fell back to `Blurred` with no lower bound, asking for a
  blur that does not exist below 1809 - and build 0, which is what a failed
  `RtlGetVersion` reports. They now degrade to plain translucency like
  `Blur` and `Acrylic` already did.
- Acrylic is no longer offered below 22H2, where it resolves to the very
  same classic WCA blur as `Blur`. A test now asserts that no two offered
  presets render identically on any build.
- `reload_from_config` re-applied the theme and the opacity slider but not
  the backdrop dropdown, so an external config change switched the window's
  material while the control kept naming the old one.
- The settings, opened-file and diff overlays were made opaque so the OS
  backdrop cannot show through their text; that also hid the theme
  background image, which used to show through them. They paint their own
  copy of it now, and the fill they share moved into
  `theme::overlay_background`.
- The SFTP transfers tray painted `workspace_surface_color` inside the right
  panel, which already paints it, stacking the same translucent surface
  twice into a darker band with a hard seam.
- `apply_theme` re-issued `set_background_appearance` on every `Config`
  mutation in every window. With a DWM material that now costs a
  `SetWindowPos(SWP_FRAMECHANGED)` frame recalc, so dragging the opacity
  slider recalculated the frame once per mouse sample; it is skipped when
  the appearance is unchanged.

* fix(ui): dim the overlay background image, and stop telling Windows it is macOS

Two defects found while driving the previous commit's changes in the app.

The overlays repaint the theme background image over their own opaque fill,
so it survives them being made opaque - but nothing dimmed it. Before those
overlays were opaque the image reached the eye through their translucent
fill; painting it at full strength put the settings text straight on top of
the wallpaper and made the panel unreadable at any image opacity above about
half. They now paint the image and then the workspace's own fill over it,
which is exactly the strength the image had through these overlays before,
and which needs no new constant to say so. Shared as
`app::overlay_surface_layers`, empty when the theme has no image so a
themeless window paints no second pass of anything.

The Windows-only blur row reused `SettingsBlurDesc`, whose text ends in
"(macOS)". It gets its own key in all three locales, describing the job the
flag actually still has on Windows: feeding the `Auto` material.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-09 15:14:54 +08:00
l0ng-ai e46bcbcf56 chore(terminal): drop the client-side command-mark store (#404)
Removing the Outline panel (#374 / #375) took away the only reader of the
client-side command marks. The scanner kept running on every batch of PTY
output, and it was the one scanner that forced the batch to be split before it
reached the emulator, so it was not free.

Gone: `MarkScanner`, `Marks`, `CommandMark`, `record_mark`, `Cut::Mark` and the
tests that only covered them. With the cursor cut as the sole cut left, the
offset sort is a no-op and goes too — `ParkedCursorScanner` already reports in
ascending order — and `Cut` itself collapses into a plain `CursorCut`.

Kept: `zle_tok` and `mode_tok` read the same OSC 133 bytes and are load-bearing
for `zle_reading` / `shell_vi_mode`, including the deliberate live-vs-snapshot
split. Daemon-side OSC 133 handling is untouched.

Dropping `marks().clear()` left `clear_scrollback` with no anchored-state
invalidation at all, and it never had any for the other store that needs it:
kitty image placements are anchored to an absolute scrollback row, so purging
the history moves every anchor and the frame paints over unrelated text or
resolves past the viewport, with no redraw coming since the daemon does not
replay out-of-band image frames. Clear the image store there, as the reattach
path already does, and route the purge through
`Term::clear_screen(ClearMode::Saved)` so a selection reaching into the
discarded rows is invalidated instead of clamping onto the viewport.

Closes #378
2026-08-09 14:49:47 +08:00
l0ng-ai 09a653d10b fix(workspace): make the CLI and the GUI agree on what exists (#423)
Five places where a workspace, a tab or an attachment was real on one side of the socket and invisible on the other. They share a root: the GUI kept its own list of which workspaces exist (WindowViews on disk) and consulted the machine tree only for the ones already in that list, so anything created by another client was unreachable by construction.

- The switcher lists workspaces the machine holds but this client has never opened, and opening one keeps its id instead of claiming a fresh one.
- for_workspace_at hydrates whenever the machine holds tabs, so opening a workspace no longer saves an empty session over them.
- finish_hydration writes a full window back over an empty tree, which is what puts a ws rm'd workspace back under the same id.
- A deletion nothing has open is forgotten here too, instead of haunting the switcher until a restart.
- Workspace::attachment travels over the wire (minus the token that proves the hold, which stays on the connection that owns it) and is stripped in persist, so tty7 ls can name the host holding a workspace.
- tab ls / ws tree fall back through name -> agent -> cwd leaf -> process name, and tab ls grew a read-only GROUP column.
- tty7 new --open raises a window on the workspace it just made.
2026-08-09 14:18:19 +08:00
ARNO 8981cc7429 fix(wsl): start a pane when fish is the distro's default shell, with its integration (#422)
A WSL pane whose distro defaults to fish never started: `wsl.exe --` hands the
command line to that default shell, which parses the POSIX bootstrap before
`sh` can receive it. Every wsl.exe invocation that carries an argv — the pane
bootstrap, `RemoteLink::wsl`, `RemoteLink::wsl_shell`, and the installer's
`sh -s` / `tee` calls — now goes out under `--exec`, which runs the program
itself. `--` keeps its other meaning, "just start the default shell", for the
no-argv case.

Those fish panes then came up with no shell integration at all, because the
bootstrap only ever had a bash arm: no OSC 133, no OSC 7, so no prompt marks,
no exit status, no cwd, and `tty7 wait` and busy/idle status dead in the pane.
The `case "$SHELL"` now has a fish arm carrying FISH_INTEGRATION inline, the
way `remote::bootstrap_command` already does over SSH — POSIX-quoted, since
`sh` parses this script rather than the user's own shell.

The PTY tests' completion marker is anchored on its terminator: `133;D;1` is a
prefix of `133;D;127` and `133;D;130`, so they would have gone green on a
bootstrap that never ran the command they type.
2026-08-09 13:53:23 +08:00
dongyuwei 1c1b4e2c92 feat(terminal): include fish_history in command history search (#421)
fish's history joins the Ctrl+R menu and inline completion, locally and on
remote hosts.

`fish_history` looks like YAML and isn't: fish escapes only `\` and newline and
quotes nothing, so a YAML reader drops every record holding a `: ` or a leading
`[` and truncates anything with a ` #` — conventional-commit messages, `echo
a: b`, `[ -f x ]` tests. It is read with a line scanner shaped like fish's own
reader instead, which also keeps pane construction off a per-record libyaml
parse.

Each history file fetched from a remote host now carries the name it came from,
so the far end's fish records go to the fish reader rather than arriving as
literal `- cmd:` rows in the menu.

Multiline commands are skipped rather than half-recalled: `append`, the shell
handoff and the reverse-search menu are all single-line, so an entry that
cannot be run is worse than one that isn't offered.
2026-08-09 13:52:54 +08:00
l0ng-ai b4e7add65d chore(release): v26.8.2 v26.8.2 2026-08-09 09:59:26 +08:00
l0ng-aiandl0ng-ai 8b7215f0d8 fix(remote): let a WSL machine restart its server, and call it "server" in zh (#417)
* fix(remote): let a WSL machine restart its server, and call it "server" in zh

The machine menu offered "restart server" only when `target.is_ssh()`, so a
WSL distro's row had just "new workspace" and "disconnect" — and the router
refused the action for anything but SSH, even though `restart_wsl_daemon`
had been sitting in `install/wsl.rs` unused since it was written. A distro's
server is installed and launched from this computer exactly like an SSH one;
only the transport differs.

- `RemoteTarget::hosts_our_server()` replaces `is_ssh()` as the gate in the
  sidebar menu and in `restart_window_daemon`. A `--stdio` workspace still
  has nothing to restart: it is whatever program the user named.
- The router grows WSL arms for both actions — `RestartServer` relaunches
  the distro's daemon, `ReplaceServer` (new `replace_wsl_server`) publishes
  the bundled binary first. The mismatch dialog's "update server" used to be
  a dead end on WSL for the same reason.
- `AppRestartServerNotSsh` is now `AppRestartServerNoServer`: the message no
  longer talks about SSH, since the only machine it can fire for is a local
  `--stdio` one.

The Chinese table called tty7's server 服务器 / 服务器端 / 后台服务; it now
says `server`, the way it already keeps `shell`, `pane` and `agent` in
English. The macOS Services menu keeps its 服务.

* fix(i18n): keep 服务器 where it means the SSH host, and test the new gate

The sweep that renamed tty7's own server to `server` in the Chinese table
also caught two SSH settings where 服务器 was never tty7's server: the
host-key description talks about the key of each machine being connected
to, and the banner switch suppresses the login banner sshd prints. Written
as `server` both now read as if tty7 ran something there, which is exactly
the confusion the rest of the sweep removes. Put the Chinese word back in
those two.

`hosts_our_server()` is the gate the whole change hangs on and nothing
exercised it, while the test next door still asserted under the name
"only SSH machines have a server to restart" — the rule this branch
retires. Name that test after what it actually checks, the transport, and
add one for the new predicate. It matches on the variants rather than
negating a single one, so a transport added later has to say for itself
whether we can restart what is on the far end.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-09 01:04:25 +08:00
l0ng-aiandl0ng-ai a6eccc6937 fix(terminal): complete a WSL workspace's panes over its share again (#418)
`wsl_share_cwd` opens with `if !self.host_id.is_local() { return None }`,
which throws away the very case its second branch exists for. A pane in a
WSL workspace is served by the daemon inside the distro, so `set_workspace`
gives it `RemoteTarget::Wsl.host_id()` — never `HostId::LOCAL` — and the
`workspace.target` branch below is unreachable. Tab in those panes fell
back to bash, while the wsl.exe pane in a local workspace (remote context,
no workspace, host LOCAL) kept working.

The locality check belongs to that first branch alone: a `wsl.exe` pane
reaches the distro of whichever machine hosts it, so a remote host's pane
must not read a same-named share here. A `RemoteTarget::Wsl` workspace is
this computer's distro by construction — tty7 reaches those by running
wsl.exe locally, which is why the sidebar labels them "WSL · this computer".

The decision moves into a free `wsl_share_distro`, testable the way
`loopback_plan` is, with all four combinations covered.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-09 01:01:32 +08:00
l0ng-ai 0add6e482b fix(terminal): re-engage conpty_resize - the opt-in line never landed (#419)
The conhost-semantics half of #415 hangs on one line: terminal_config_from_user setting conpty_resize on Windows. It was verified in the acceptance build but lost from the working tree before the commit, and the field's off-by-default kept build, tests, and CI green while nightly 202608081441 shipped with the fix disabled. Restores the flag and pins the wiring with a regression test.
2026-08-09 00:03:30 +08:00
l0ng-ai 51c35aac1f fix(terminal): resize ConPTY panes with conhost's semantics, in stream order (#415)
ConPTY emits no repaint after a resize; conhost silently re-anchors its
layout and keeps painting with absolute cursor addresses computed against
it. Measured live: growing the window keeps rows and cursor pinned and
opens blank rows below, and shrinking scrolls the last written row to the
new bottom. The grid resized the alacritty way instead, so after a
maximize every absolute-CUP paint landed mid-screen inside the old output.
The vendored alacritty_terminal now has a conpty_resize mode mirroring
conhost's model (fork rev 1276f12); every Windows pane opts in.

Separately, a resize during a burst of output reflowed ahead of the
backlog (up to the gate's 16 MiB of old-width bytes). The daemon now
echoes a Size frame to the controller at the exact stream position where
the PTY geometry changes, and a client that probes the new resize-echo
feature defers its reflow to that marker. Remote routes and older daemons
keep the reflow-at-request-time path.
2026-08-08 22:40:04 +08:00
ARNO 45e5a1ab89 fix(terminal): align smooth scroll stepping with presented frames (#414)
* fix(terminal): align smooth scroll stepping with presented frames

* fix(terminal): wake the window when a scroll animation is queued
2026-08-08 22:20:56 +08:00
ARNO d9a6553651 fix(theme): lift illegible bright ANSI slots to the text floor (#413)
* fix(theme): keep the bright ANSI half of the palette legible on the theme background

fmt

* feat(theme): make the bright-color legibility rescue toggleable

fmt
2026-08-08 20:07:43 +08:00
l0ng-aiandl0ng-ai 12cfc18632 fix(sidebar): put a new tab in its repo group on the first frame (#411)
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-08 20:07:07 +08:00
l0ng-aiandl0ng-ai 5e6bc1246f fix(workspace): stop switching workspaces from destroying live sessions (#410)
* fix(workspace): stop switching workspaces from destroying live sessions

Switching workspaces rebuilt every pane it was asked to restore, and a
window that rebuilt nothing then deleted the workspace outright — tree
and store both. Three separate guesses, each one authorizing an
irreversible act:

- `session_from_tree` erased a pane's id when the tree said `live: false`.
  That flag is a cached observation from another process, reloaded as
  false on every server start, so a quiet pane read as dead while its
  shell was running. The restore had nothing to attach to and spawned a
  fresh shell over it.

- Two servers could start against one config dir. `run_with` decided
  another server was dead by failing to connect once, then unlinked its
  socket and bound its own. The loser kept `control.sock` with an empty
  pane registry, so `MachineGet` reported every pane dead and nothing
  logged an error.

- `finish_hydration` marked a window informed before the rebuild and
  without looking at the result. `tabs_from_session` drops any tab whose
  panes all fail to start, which is every tab when the pane socket is
  unreachable — leaving a window that was empty and authoritative at
  once, and the next switch deleted a workspace with ten live tabs.

Each is now settled by whoever holds the truth: attaching decides
whether a pane is there, an advisory lock decides which process is the
server, and a deletion needs the machine's own mirror to agree that the
workspace is empty.

* fix(state): quarantine a corrupt views.json instead of silently discarding it

machine.json already sets a corrupt file aside before falling back to
defaults; views.json just logged and returned None, and the next save
overwrote whatever the file held. Move the quarantine helpers to config
so both loaders share them.

Also make the no-lock-primitive fallback in the daemon singleton report
Unavailable rather than Taken, so a platform without flock still gets a
server instead of one that refuses to start.

* fix(restore): a failed List no longer reads as every pane being dead

Review follow-ups on #410, all three the same shape the PR exists to
stamp out:

- alive_panes_on flattened a failed List RPC into an empty alive-set,
  which made pane_attachable respawn every pane in the batch over its
  running session. The failure now surfaces as None and the attach
  itself decides, the way session_from_tree already leaves it to.
- pub fn run() bypassed the singleton lock entirely; it had no callers,
  so a future one would have silently reintroduced the split-brain.
  Removed.
- Singleton::path() and the field behind it were unused. Removed.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-08 16:22:19 +08:00
l0ng-aiandl0ng-ai 9c54ccf8e8 fix(terminal): complete WSL panes over the distro's \\wsl$ share (#408)
* fix(terminal): complete WSL panes over the distro's \\wsl$ share

Tab in a WSL bash pane always fell through to the shell: the pane's
filesystem is foreign, so the completion engine had no cwd to list and
handed every Tab back to bash. Now a WSL pane's POSIX cwd (OSC 7) is
translated to the distro's \\wsl$ share, which this process can read
like any directory:

- complete_foreign lists paths against the share but keeps everything
  that would consult this machine switched off: no PATH binaries in the
  command position, no generator scripts, and `~` is left to the shell
  (it names the distro's home, not this machine's).
- Absolute words stay inside the share: Windows join semantics keep the
  UNC prefix when a rooted word lands on it, so `ls /etc<Tab>` lists the
  distro's /etc, not C:\etc. The automount stays on the share for the
  same reason.
- A wsl.exe pane spawned without --distribution now resolves the default
  distro from the registry (Lxss\DefaultDistribution), so its remote
  context names a real distro instead of an empty placeholder.
- A WSL workspace no longer claims the SSH remote-listing path; only a
  spec-carrying workspace does.

Verified end to end against a live Ubuntu-24.04 through an isolated
daemon: the default distro resolves, cwd frames flow on cd, and the
translated share lists from the Windows side.

* style: cargo fmt

* fix(terminal): keep the \\wsl$ completion route on this machine's panes

A WSL pane owned by a remote host reaches its distro through that host,
not through a local \\wsl$ share -- a same-named local distro would list
the wrong machine's files. Gate wsl_share_cwd on host locality, so a
remote host's WSL pane falls back to handing Tab to the shell.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-08 15:41:16 +08:00
l0ng-aiandl0ng-ai 692cb76635 fix(windows): never join the pane reader on the UI thread (#407)
* fix(windows): never join the pane reader on the UI thread

On Windows, shutdown() does not wake a thread parked in a blocking
read() on the same socket (it does on unix). detach_link, adopt_relink,
and Drop all shut the writer down and then joined the reader thread,
counting on that wake-up - so when the peer stayed silent (a routed
pane whose SSH leg went zombie: nothing arrives, no FIN ever comes),
the join blocked its caller, the UI thread. That is the whole-window
'not responding' hang right after a remote workspace reconnects.

Teardown now sets a per-reader quit flag and abandons the thread
instead of joining it. The reader's read always times out within
500 ms so a parked reader notices the flag promptly, and it re-checks
the flag under the term lock before every grid mutation, so a retired
reader can never touch the freshly reset grid of an adopted link.

The new windows-only tests fail on the old code by blocking >3 s in
detach_link; the reader teardown previously had no Windows coverage
at all (the whole test module is cfg(unix)).

* fix(windows): gate every buffered frame on the reader's quit flag

Review follow-up on #407: the quit checks guarded the grid and the
exited flag, but a retired reader could still hold complete frames in
its local buffer and process them — and most arms (Cwd, Prompt, Agent,
and especially Exited via child_exited) write shared state that
adopt_relink reuses across the swap, so a buffered Exited from the
abandoned link could close the freshly adopted pane. Check the flag
once per frame at the top of the inner loop, so a retired reader
processes nothing at all; the adopt test now also fires a late Exited
down the abandoned link and asserts the pane survives.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-08 14:39:38 +08:00
l0ng-aiandl0ng-ai a570cb1496 fix(windows): carry launch flags into agent resume commands (#406)
On mac/linux the daemon reads the foreground process's real argv, so
resume_command can replay launch flags like --dangerously-skip-permissions.
The Windows path detects the agent from the OSC 133;C shell-integration
command capture but threw the command line away, stamping an empty argv:
every resumed agent came back bare, with none of its launch flags.

Tokenize the captured command (case-preserved, quotes trimmed, PowerShell
call operator dropped) and stamp it as the pane's launch argv. Detection
keeps lowercasing its own copy; replay_flags' safety allowlist already
rejects tokens a whitespace split would mangle, falling back to the bare
resume command as before. Fork commands share the same argv source, so
they are fixed by the same change.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-08 14:39:10 +08:00
l0ng-aiandl0ng-ai 817447bd48 feat(agents): recognize Oh My Pi and install its status hooks (#405)
Issue #376 asked for `omp`. Oh My Pi is a fork of Pi (can1357/oh-my-pi,
descended from badlogic/pi-mono), but the fork is where the similarity
stops for our purposes: it ships one binary of its own — `omp`, the only
`bin` in `@oh-my-pi/pi-coding-agent`, and it never installs a `pi` — and
it keeps its config under `~/.omp`. A pane running it was therefore not
detected at all, and aliasing `omp` onto `CLIAgent::Pi` would have been
worse than nothing: the status bridge would land in `~/.pi`, and Resume
Session would offer `pi --session <id>` to a binary that spells that
flag `--resume`.

So it gets its own variant, wired the whole way through:

| | |
|---|---|
| Detection | argv stem `omp`, distinct from `pi` in both directions |
| Avatar | its own mark, normalized from the project's `assets/icon.svg` |
| Resume | `omp --resume <id>`, opting out on `--no-session` |
| Fork | `omp --fork <id>` — a verified fork command, so the menu item appears |
| Hooks | Settings → Agents, at `~/.omp/agent/extensions/tty7/index.ts` |

The status bridge is the one piece the fork did not change. Oh My Pi
inherited Pi's extension contract intact — same default-exported factory,
same `session_start` / `agent_start` / `agent_end` / `session_shutdown`,
same `ctx.sessionManager.getSessionId()` — so `pi_extension_ts` now takes
the agent and substitutes two things, the package it imports the type
from and the slug it calls the emitter with. Pi's generated file is
byte-identical to before, so no installed bridge goes stale.

`--resume`, `-r` and `--session` are three spellings of one flag in Oh My
Pi; all three shed when a session command is rebuilt, while `--session-dir`
is a different flag and rides along. `fork_command` now honors the same
`--no-session` opt-out `resume_command` already did — Oh My Pi rejects
`--fork` outright under it, and no existing agent declares an opt-out.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-08 13:10:38 +08:00
l0ng-aiandl0ng-ai 741c863c2c fix(windows): make the install directory actually replaceable before updating (#403)
* fix(windows): make the install directory actually replaceable before updating

The updater stopped the daemon and started the Inno installer the moment
the daemon's endpoint disappeared — but the endpoint going away is not
the same event as the images being released. The ConPTY hosts
(OpenConsole.exe) are the daemon's children, not the shells', so the
per-pane kill never reached them, and the daemon's exit(0) skipped every
destructor that would have closed them; they kept the installed
OpenConsole.exe open for seconds after --stop-daemon returned. Silent
Setup then hit the lock, took the suppressed dialog's default (Abort),
and the updater's recovery relaunched the old build — "updated,
restarted, still the old version". A daemon that died without cleaning
up made it permanent: its orphaned hosts survive indefinitely, which is
the DeleteFile-code-5 users hit even after "closing everything".

Reproduced both shapes in isolation before fixing: with a pane open,
--stop-daemon returned ~1s in while OpenConsole.exe stayed locked for
another ~1.4s; after taskkill on the daemon, the orphaned host held the
lock forever.

The shutdown now finishes what it starts, at every layer that can be
the last one standing:

  * The daemon reaps its remaining descendants and waits for them
    before exiting, while the endpoint — the signal stop() watches —
    is still up.
  * stop() reads the pidfile before asking, and waits for that process
    to actually exit after the endpoint goes, not just stop listening.
  * The recorded-daemon reap waits for the images to be released
    instead of returning on the async TerminateProcess.
  * stop_for_update(dir) — reached via --stop-daemon
    --update-install-dir, which PrepareToInstall and the portable
    updater now pass — also terminates anything still running from the
    installation directory (the orphan case no pidfile can name) and
    only returns once the .exe/.dll images there open for writing,
    naming the holdouts in the error if they never do.
  * The updater runs that clearing itself before invoking Setup, so a
    directory that cannot be cleared fails with a cause in update.log
    and relaunches the previous build, instead of Inno's bare
    "DeleteFile failed; code 5".

The update dialog on Windows also told a macOS truth — "the background
service keeps running, so whatever is open in your panes survives".
Windows cannot replace a running daemon's image, so its install path
stops the service; the dialog now says so.

* fix(windows): tighten the install-dir clearing per review

- An image that fails to canonicalize stays in the lock check instead of
  being silently skipped; only a positive match against the caller's own
  running image is excluded.
- reap_recorded_daemon shares one deadline across the whole tree via a
  new winproc::terminate_and_wait_all, which stop_for_update and
  reap_descendants_of now use too — one implementation of "terminate,
  then wait, bounded overall" instead of three.
- [UninstallRun] passes --update-install-dir "{app}" like
  PrepareToInstall, so uninstalling after a daemon crash gets the same
  orphaned-ConPTY-host cleanup as upgrading.

* fix(update): close three gaps the update audit found

- macOS updater: wait for the parent by watching getppid() reparent to
  launchd instead of polling kill(pid, 0), which a recycled pid could
  satisfy forever. The kill loop remains only for a hand-run updater.
- Windows: a new update guard (config-dir update.lock, held by the
  updater from daemon stop to relaunch) makes ensure_running refuse to
  spawn a daemon mid-install, so a tty7 CLI call or manual launch can no
  longer relock the images the installer is replacing. Stale guards —
  dead writer or past the TTL — are shed on sight.
- Windows portable: the update backup now carries an incomplete marker
  from before the first file moves until the replacement lands. At
  launch the app reports a backup still carrying it as an interrupted
  update (the installation may mix two versions; the old files are
  preserved), and silently removes marker-less backups a finished
  update failed to delete past an antivirus hold.

* fix(update): verify the guard's writer by start time, and guard manual Setup runs

Review round three, both findings and all three minors:

- The guard no longer expires a live, verified holder: a pid is believed
  to be the writer only if the process behind it started before the
  guard was written (winproc::creation_time via GetProcessTimes), which
  is what tells a genuine holder from a recycled pid. The TTL now bounds
  only the unverifiable case, so an install slowed past ten minutes by
  an antivirus sweep keeps its protection.
- Manual Setup runs get the guard too: the --stop-daemon
  --update-install-dir helper holds it in its parent's name — the Setup
  or uninstaller that keeps replacing files after the helper returns —
  and it goes stale when that parent exits. ensure_running gained five
  seconds of patience so the post-install "Launch tty7" click, racing
  Setup's own exit, gets its daemon instead of an error.
- processes_running_from also matches images against the canonicalized
  install-dir spelling (junction, subst, 8.3 given form).
- reconcile_portable_backups reports every interrupted backup, not the
  first.
- The unix signal-and-wait loop now reuses wait_for_recorded_exit.

* style: rustfmt

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-08 11:04:15 +08:00
l0ng-aiandl0ng-ai c5aaf2b4f7 fix(theme): floor the tokens that bypassed the contrast machinery (#400)
* fix(theme): floor the tokens that bypassed the contrast machinery

Sidebar text, the caret and hairlines were flat blends with no floor,
sitting one line away from tokens that are bisected to hit a target
exactly. Semantic inks were floored against the window background but
painted on popovers and sidebar rows, which sit a step toward the
foreground.

- sidebar_fg is now floored at 4.5:1 on the sidebar fill it is painted
  on, not on the background. Four builtins landed it at 3.35-3.92:1.
- caret is conditioned to 3:1. The default Light theme shipped an
  orange caret on pure white at 2.07:1.
- border keeps its blend but gets a 1.5:1 floor so a divider is worth
  the same in every theme, instead of ranging 1.26-1.64:1.
- semantic inks and fills clear their floor on background, sidebar and
  popover, not just the background.

Themes that already cleared a floor are untouched.

* fix(theme): floor the hairline on every neutral fill it divides

Review catch on #400: border is handed to sidebar_border and drawn on
popover chrome, but was floored against the window background alone —
a narrower guarantee than the one this PR gives sidebar_fg and the
semantic inks.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-08 09:11:01 +08:00
l0ng-aiandl0ng-ai fe558a1c8e fix(terminal): localize the right-click menu and the search bar (#401)
The terminal's own context menu was the last surface still speaking
hardcoded English in a three-locale app — 14 literals, while the tab and
switcher menus route every item through t(). The search bar had three
more, and its Previous / Next / Close buttons were icon-only with no
tooltip at all.

It also spoke a fourth vocabulary for one action: "Maximize Pane" for
ToggleMaximizePane, which the menu bar, the palette and Keybindings all
call "Zoom Pane". "Close Pane" is likewise "Close Pane / Tab"
everywhere else, and it is the accurate name — the action closes the
tab when the pane is the last one.

Adds AppMenuSplitLeft / AppMenuSplitUp and SearchFind / SearchMatchCase
/ SearchUseRegex across en, zh and ja. The fork submenu now takes its
title from the locale table instead of tty7-core's fork_label(), which
is a capability probe in a crate that has no locale table.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-08 09:10:31 +08:00
l0ng-ai 1f91719da9 fix(terminal): paste images into remote panes on macOS, and into WSL by its own path (#399)
Staging a pasted screenshot to a file and handing the agent its path was
built off macOS only. A local macOS agent reads the system clipboard itself
when it sees SYN, and that carries the image at full fidelity, so the
platform kept SYN and skipped staging entirely.

That reasoning stops at the pane boundary. An agent in an SSH pane or a
remote workspace reads the clipboard of the host it runs on, which never
holds this machine's screenshot, so SYN was a no-op and pasting did nothing
at all. Remote panes now stage and upload on every platform, decided by one
predicate instead of a platform cfg over the whole feature; a local macOS
pane still forwards SYN untouched. macOS screenshots reach the pasteboard as
TIFF, which agent vision rejects the same way it rejects a Windows BMP, so
those transcode to PNG on the way out.

A WSL pane was broken for a different reason: it shares this machine's disk
but not its path syntax, so it needs no upload, only a rewrite -- and there
was none. The agent got C:\Users\...\paste-1.png and found nothing there.
The paste now carries the automount view, /mnt/c/Users/... A path with no
mapping, such as a UNC temp directory, keeps the Windows name, which at
least says where the file went.

Seven pre-existing tests for this feature were compiled out on macOS by the
same cfg and now build and pass there. Three were added: the staging
predicate, the WSL rewrite (including UNC and drive-relative refusals), and
the TIFF-to-PNG transcode, whose failure mode is otherwise a silent no-op.
2026-08-08 00:05:56 +08:00
l0ng-aiandl0ng-ai e4fff64833 fix(daemon): stop offering to carry on beside a mismatched server (#389)
Both handshakes compare their version for equality and hang up on
anything else — the pane protocol in `ensure_running`, the control
dialect in `host::server`'s hello. So a server whose number disagrees
cannot be talked round, and the "Keep Shells" button offered a state
that does not work: panes still spawn while every machine-tree call is
refused, which is how a window opens with no tabs and saves none of the
ones you make. Taking that button was indistinguishable from the bug it
sat next to.

Restart or quit, then. Quitting is the half that destroys nothing — the
server and every shell under it keep running — which makes it a real
answer for someone who would rather go install the matching build than
lose a session mid-flight. Quit goes first, the way every other
destructive prompt here is ordered: NSAlert and TaskDialog give the
first button Return, and this prompt arrives unasked at launch, the
moment a stray Return is likeliest, so the reflex key has to be the one
that destroys nothing. Restarting stays a click or an arrow away.

A prompt dismissed without an answer re-arms instead of falling through:
`take_mismatched_daemon` takes, so without this a closed window is a
back door into the state this prompt exists to prevent.

The copy is rewritten to one line of diagnosis and one line per button,
in all three locales, listed in button order. It was a paragraph that
had to be read twice to find out what the buttons did.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 23:41:37 +08:00
ayamir 43a22855a2 perf(graphics): move kitty frame pixels instead of copying them (#388)
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.
2026-08-07 23:30:11 +08:00
l0ng-aiandl0ng-ai bc03e59b6f ci: keep the fork review to forks (#398)
The fork job never checked where the head branch lived. It was written
as the fork path and reads like one, but `pull_request_target` fires on
every pull request, so labelling one of ours landed there too --
silently, and with the weaker review: no plugin, no whole-repo context,
read-only tools. #389 got that instead of the review it should have had.

Also corrects the `labeled` comment in claude-code-review.yml, which
promised exactly the case that cannot work. A `pull_request` workflow is
read from the PR's merge ref, and GitHub recomputes that on a push and
not otherwise, so a PR whose last push predates the file has a merge ref
without it and no label can summon it. Pushing fixes it, and also
triggers synchronize by itself -- which is why the empty commit worked
and the label looked broken.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 23:26:29 +08:00
l0ng-aiandl0ng-ai 51ea055d2a ci: anchor fork review findings to lines, and fence the reads (#397)
Two changes to the same job.

Findings now go inline. The action ships an inline-comment MCP tool
that buffers rather than posts: the queue is sent after the session
ends, by the action's own code, past a classifier. That keeps the
property review.md was chosen for -- the reviewer writes the words, a
step that cannot be argued with performs the act -- while putting a
finding next to the line it is about. review.md stays for the summary,
which belongs to the change as a whole.

Reads are fenced. A comment body is posted verbatim, so any file the
reviewer can read it can publish, and the token is in this process's
environment; /proc is the short path between the two. Reads are already
confined to the working directory in the default permission mode, but
this job should not rest on a default. Deny is evaluated before allow,
so the rules hold whatever --allowedTools says.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 23:13:12 +08:00
l0ng-aiandl0ng-ai ea0a0f078c ci: keep reviewing a fork PR after the label goes on (#395)
The label was a one-shot trigger, so a contributor who addressed the
findings and pushed got no second look unless someone removed and
reapplied it. It now reads as a subscription: `synchronize` re-reviews
while the label is present, and taking it off stops that.

Two clauses rather than one label test, because `labeled` carries the
label that was applied and `synchronize` carries none -- testing only
the list would spend a review every time an unrelated label landed on a
subscribed PR.

This does not gate the code in the next push, and the header comment now
says so. The gate is spend and attention; the safety is the base-branch
workspace root, the absent Bash, and executing nothing.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 23:04:11 +08:00
l0ng-aiandl0ng-ai 59b26ce283 ci: stop the fork review prompt from reading as a checklist (#396)
The general instruction was one line -- logic errors, edge cases,
regressions -- followed by a numbered list of four, three of them
tty7-specific. A list anchors, and that one named none of what actually
breaks a terminal emulator: panic paths, unsafe, ordering, resources on
the failure path, an API contract that no longer holds for its new
callers.

Replacing it with a longer list would only move the boundary. So the
prompt now states the aim, offers examples while saying outright they
are not a checklist, and notes that the finding nobody listed is
usually the one worth having. The repo rules stay, demoted to what an
outside reader cannot know and explicitly skippable.

claude-code-review.yml is left alone: its rules are appended to the
code-review plugin, which brings its own methodology, so a general
clause there would compete rather than add.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 23:02:41 +08:00
l0ng-aiandl0ng-ai fb4e8f0860 ci: post the fork review from a step, not the action (#394)
track_progress only fires on opened, synchronize, ready_for_review and
reopened. The approval gate depends on `labeled`, which is none of
them, so the action refused the run outright rather than falling back
to the log.

The review is written to review.md and posted by a final step that runs
no model and reads one file. That adds Write to the allowlist, which
costs nothing: there is still no Bash, so still no curl, and the runner
is discarded after the comment goes out.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 22:51:22 +08:00
l0ng-aiandl0ng-ai d2a7089189 ci: give the fork review the PR's tree and a voice on the PR (#393)
Three changes, one shape. The run was failing at the app-token exchange
because the action checks the triggering actor's repository access and
a fork PR's author has none; `github_token` plus
`allowed_non_write_users` is the documented pair for
pull_request_target, and the label gate is what makes trusting that
actor a decision somebody made.

Findings now go on the pull request instead of the run log, which needs
`pull-requests: write`. That is affordable only because the tool
allowlist stays read-only: with no Bash there is no curl, so the worst
a successful injection buys is a silly comment.

The fork's tree comes back, one directory down. #392 removed it
entirely because a checkout at the workspace root is what Claude Code
reads as the project -- but the action's own security guide gives the
middle path, a subdirectory, which keeps the project files ours while
letting the review see whole files instead of hunks. `.claude/` and
friends are dropped from that tree and a top-level CLAUDE.md is renamed
rather than deleted, since a PR that edits it still deserves review.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 22:47:53 +08:00
l0ng-aiandl0ng-ai 2ddf2bad25 ci: review a fork's diff, never its tree (#392)
actions/checkout refuses a fork ref under pull_request_target without
allow-unsafe-pr-checkout, and the flag is not the fix. The working
directory is what Claude Code reads as the project, so checking out a
fork hands it that fork's CLAUDE.md as instructions and that fork's
.claude/settings.json hooks as commands -- neither of which the
--allowedTools list governs. Constraint 2 said nothing from the pull
request is executed; a checked-out tree could not honour it.

Check out the base branch instead and bring the contribution down as
diff text in one file. The reviewer reads the diff against trusted
sources rather than the merged tree, which is less context than the
same-repo path gets, and the right trade for code we do not control.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 22:42:43 +08:00
l0ng-aiandl0ng-ai 8652c7928a ci: review fork pull requests behind a label (#391)
A fork PR gets no secrets on `pull_request`, so claude-code-review.yml
skips it. `pull_request_target` is the only event that reaches the
diff with our token, and it puts that token in a job beside code we did
not write, so the fork path is a separate file under four constraints:
a label applied by someone with write access is the only trigger,
nothing from the PR is executed, the tool allowlist is read-only, and
the job holds no write permission to carry anything back out.

The same label also re-runs the ordinary review, which a PR opened
before that workflow existed otherwise has no way to start.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 22:37:45 +08:00
l0ng-aiandl0ng-ai d992d6d9a9 Smooth scrolling: spread a wheel detent over frames instead of jumping it (#382)
* 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>
2026-08-07 22:34:32 +08:00
l0ng-aiandl0ng-ai 305baa8746 ci: review pull requests with Claude Code (#390)
Runs the code-review plugin on every PR open and push, on our own
Actions minutes rather than the managed Code Review service, and posts
the findings back onto the PR. Advisory only: the required checks on
main stay rustfmt and the three build & test jobs.

The append-system-prompt carries the four rules a general-purpose
reviewer cannot infer -- dialect bumps staying readable to an older
peer, src/ui paths going through Host, i18n keys landing in all three
locales -- and tells it not to repeat what rustfmt and clippy already
decide.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 22:18:43 +08:00
l0ng-aiandl0ng-ai f1f632edd1 fix(daemon): notice a server that is only a control dialect behind (#387)
* 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>
2026-08-07 21:08:30 +08:00
l0ng-ai f45dd34cab feat(update): separate Stable and Nightly release channels (#386)
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.
2026-08-07 19:44:31 +08:00
l0ng-aiandl0ng-ai fb4f008828 fix(remote): say a version-mismatched server is old, not that it is not tty7 (#384)
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>
2026-08-07 16:11:22 +08:00
l0ng-aiandl0ng-ai 6773a8c366 refactor(settings): drop the close-confirmation setting and trim About copy (#383)
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 15:50:19 +08:00
l0ng-aiandl0ng-ai b9956ad6af feat(switcher): two-column panel with IDEA-style Ctrl+Tab (#380)
* 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>
2026-08-07 14:30:40 +08:00
cwatanab 05bfa8a269 feat(ui): add ja-JP locale and split i18n into per-language modules (#372)
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>
2026-08-07 12:56:38 +08:00
Hongwei Qin bb72be338d fix(windows): respect system proxy for remote server downloads (#364)
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.
2026-08-07 11:57:14 +08:00
Hongwei Qin 4a8a4bcbaa feat(proxy): macOS system proxy, Windows SOCKS parsing, manual override (#367)
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.
2026-08-07 11:35:21 +08:00
Hongwei QinandHongwei Qin a7de7db2c4 feat(windows,macos): clickable toasts, richer context, and i18n (#373)
Desktop notifications now carry the pane they came from: clicking one reveals
that pane's window, tab and split. Windows shows a WinRT toast with an
`Activated` handler, macOS uses mac-notification-sys' click response, and both
route through the existing tray dispatch channel. Linux keeps the plain
notify-rust path.

Titles gained context — an agent name or the machine, then the workspace — and
bodies name the command or agent alongside the duration, all of it translated.

Notification text is sanitized on every path: it comes off the terminal, and a
stray control byte used to make the Windows toast XML fail to parse and lose the
notification outright.

Co-authored-by: Hongwei Qin <exqinhongwei@outlook.com>
2026-08-07 11:27:07 +08:00
Gabiandl0ng-ai e47b49dfdd fix(bundle): declare macOS TCC privacy keys for child processes (#323)
* fix(bundle): declare macOS TCC privacy keys for child processes

tty7 currently ships no NS*UsageDescription keys and no data-access
entitlements, so macOS falls back to a repeated "access other apps' data"
prompt whenever a child process (shell, coding agent, mole, etc.) touches a
protected folder such as ~/Library/Containers, Mail, Messages, or Calendar.
kitty and Kaku both declare these privacy intents, which converts the prompt
into a single, clear one-time grant.

Add the folder/volume usage descriptions and the matching personal-information
and device entitlements to the macOS bundle so the app behaves like its
terminal peers.

* fix(bundle): rework TCC usage strings per review

- Correct problem statement: describe child-process-denied-without-prompt
  instead of the Full Disk Access framing (no NS*UsageDescription key exists
  for that class).
- Add the full usage-string set (camera, microphone, contacts, calendars,
  reminders, photos, location, motion, local network, bluetooth, speech
  recognition, system administration, apple events), kitty-style wording.
- Use macOS spellings: NSCalendarsFullAccessUsageDescription /
  NSRemindersFullAccessUsageDescription / NSLocationUsageDescription.
- Drop every entitlement that has no matching usage string; keep only
  com.apple.security.automation.apple-events.
- Restore trailing newline at EOF in bundle-macos.sh.
- Document the Full Disk Access manual-grant requirement in docs/features.md.

* docs: rewrite macOS privacy as feature notes (en + zh-CN)

* fix(bundle): drop the apple-events entitlement, tidy the privacy docs

The entitlement did not do what its comment claimed. Nothing in tty7 or in
gpui's mac platform layer sends an Apple event, and it would not help the
case this change is about either: the hardened-runtime automation check runs
against the process actually sending the event, which is the pane's child
carrying its own signature. What TCC reads off tty7.app is the usage string
in Info.plist, which stays. Entitlements are per-executable and never
inherited, so granting this one only widened what injected code could reach
under an identity that already holds disable-library-validation.

Docs: spell out the four Full Disk Access paths instead of running them
together as one nested path, drop motion from the user-facing list (Core
Motion has no macOS implementation, though the key stays for kitty parity),
and place the section identically in the English and Chinese files.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 11:15:22 +08:00
l0ng-aiandl0ng-ai 17c0ea2935 fix(terminal): honour application cursor key mode (#366)
* fix(terminal): honour application cursor key mode (#361)

Arrow, Home and End were always sent as their CSI form, no matter what
the foreground program had asked for. Programs that turn on DECCKM via
smkx -- which is every ncurses full-screen app -- expect the SS3 form,
because that is what xterm-256color spells kcuu1 & co. as, and ncurses
matches terminfo byte for byte.

htop was the report: ncurses failed to match \E[A, handed the bytes to
htop one at a time, and htop binds `[` to "lower priority", so every Up
or Down bumped the selected process's nice value instead of moving the
selection. The same breakage hits ncdu, mc, dialog, menuconfig, nmtui.
Shells were unaffected because readline and zle bind both forms, and
the mouse wheel already got this right in wheel_route().

Named keys now also carry their modifiers the way terminfo declares
them (kLFT=\E[1;2D, kUP5=\E[1;5A, kDC3=\E[3;3~), instead of dropping
Shift/Ctrl entirely and prefixing Alt with a bare ESC. Cmd stays out of
the modifier parameter -- xterm has no encoding for it.

The legacy and kitty encoders shared this table already; they now share
one function, so both follow DECCKM. KittyFlags grew past its name and
becomes KeyFlags.

Verified in a dev instance: with DECCKM on the arrows arrive as ^[OA
^[OB ^[OD, with it off as ^[[A ^[[B ^[[D.

* test(terminal): guard the SS3 cursor walk-back on line handoff

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 11:13:37 +08:00
Hongwei Qin 0190036a2e chore(ui): remove the Outline right panel (#375)
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
2026-08-07 10:48:32 +08:00
l0ng-ai e1531cdea6 revert(windows): drop the taskbar status dot (#377)
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.
2026-08-06 21:40:25 +08:00
l0ng-aiandl0ng-ai e3dded4be3 fix(release): keep the Inno payload for the package verifier to read (#368)
`verify-windows-package.ps1` reads the Inno staging directory to check what
lands in {app} — the compiled setup.exe cannot be read back without
innoextract, which the runners do not carry. But `bundle-windows.ps1` deleted
that directory as its last act, so the verifier has failed on every Windows
build since the check arrived in #330: "the Inno staging directory is missing".

Nightly has been red for two nights (2026-08-05, 2026-08-06) and a stable
release would fail the same way — release.yml runs the same step.

Both workflows already expected the directory to survive: their upload steps
name it among the dist/ intermediates the asset globs deliberately skip. So
this drops the removal rather than teaching the verifier to tolerate an absent
payload, which would retire the check it was added to make.

Claude-Session: https://claude.ai/code/session_01H9QqEZ6JH3dGS6atEcf6ab

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-06 13:14:08 +08:00
l0ng-aiandl0ng-ai 4dbbe95be9 refactor(settings): trim the About page down to what it is for (#358)
The About page carried a marketing paragraph and two multi-sentence
explanations that walked through updater internals and per-platform
support. None of it helps someone who is already running the app.

Drop the feature-list paragraph, cut the update and server text to a
single sentence each, move the tech credits to the bottom of the page,
and render the update toggle with settings_row so it matches every
other switch in Settings. The tagline and credits line now match the
README.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-06 12:11:39 +08:00
l0ng-aiandl0ng-ai 0c61e533b3 fix(terminal): put back the cursor ConPTY parks at the end of a repaint (#362)
conhost's VT renderer brackets every frame it paints with `?25l` … `?25h` so
the cursor does not flicker across the repaint, and it moves the cursor
explicitly just before the `?25h` only on the frames where it painted the
cursor. On the other frames the show commits wherever the last erase or write
left it — the tail of the status line, the head of a row — and the cursor
blinks there until conhost's next frame moves it back.

tty7 repaints when a batch of pty output lands, so it draws that cursor for a
frame. A TUI that repaints on a spinner produces one every tick, which reads as
a second cursor blinking in the wrong place. macOS never shows it: no ConPTY
sits in between, and the TUI itself always moves the cursor before showing it.

Measured on Windows 11 26200 from a raw ConPTY capture of a Codex session, a
110x30 pty, cursor-visible dwell per cell:

  in-box conhost: 295 ms across 42 frames parked at the end of the status line,
                  each stray corrected 7-15 ms later by the following frame
  with this fix:  that cell never appears; those frames fold back into the
                  composer cell the repaint hid the cursor on

A scanner over the stream pairs the hide with its show and marks the show as
parked when the run in between moved the cursor around to paint but did not end
on a move — nothing chose the cell it is about to appear on. The repair then
restores the cell the cursor stood on when it went invisible, which is where the
correcting frame would have put it anyway. A hide and a show more than 100 ms
apart are an application keeping the cursor off for the length of some work, not
a renderer bracketing one frame, and are left alone.

This does not cover the other ConPTY cursor artifact: conhost also samples an
application's partially written frame, and then it *does* emit an explicit move,
so the stray position is genuine — just transient — and nothing in the stream
tells it apart from a real one. Only a render-side settle would catch that.
Microsoft's ConPTY redistributable avoids both, so with the bundled pair beside
the daemon this is inert; it earns its keep on hosts without it, notably a
remote Windows server.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-06 11:51:50 +08:00