Commit Graph
953 Commits
Author SHA1 Message Date
l0ng-ai 84947b2deb fix(settings): refuse a half-filled SSH profile instead of saving it
The SSH profile form saved whatever was in it. An empty host wrote a
profile that renders as a blank row in the host list and hands
`TcpStream::connect` an empty name — and Connect had no gate at all, so
the first thing the user heard about it was a DNS error naming nothing.
A jump host with a typo in it resolved to `None` and saved as a direct
connection, with the field blank the next time the form opened. And
`parse_host_port` was `parse().unwrap_or(0)`, so `proxy.example.com`,
`proxy.example.com:` and `proxy.example.com:88O` all saved a proxy on
port 0, which the socket layer got to explain (#492, #493, #494).

The rules now live in `validate_ssh_draft`, a plain function over a
plain-String snapshot of the form, which returns both the profile the
form would save and what is wrong with it. Both, always: the Escape
prompt asks whether the form differs from the config, and handing back
only the errors would make a brand-new invalid profile compare equal to
the nothing on disk — Escape would throw the typing away without asking.

Only the host is required. A name is not, because the list already falls
back to the address and every host imported from ~/.ssh/config arrives
without one. A blank port still means 22, but a non-empty one has to be
a port, so "0", "abc" and "70000" are refused rather than saved as
written or quietly rewritten. A proxy address with no port takes the
scheme's default (1080 / 8080) and `host_port_text` writes that back
into the field, so the number it picked is visible; a colon with nothing
usable after it is an error. `map_proxy` also stops treating port 0 as a
proxy, because configs written before this are already on disk.

Each complaint prints under the field it is about, and Save and Connect
are disabled while any of them stands; a section holding one unfolds so
the disabled button always has a visible reason. The "needs a host" line
waits until the name/host/port/user group has something in it — every
field notifies per keystroke, so otherwise a new host would be told off
before anyone had typed a character. Consequence: on a pristine new form
Save is now disabled where it used to be enabled.
2026-08-11 19:58:33 +08:00
l0ng-aiandl0ng-ai 0f5e63701e fix(ui): let the overlay scrollbars fade out again (#471)
* fix(search): wash a match in the accent, at a strength the theme can afford

A search hit was washed from the terminal palette's selection colour at a
fixed 1.45:1 against the background, so it read as a weaker selection on a
grid that is already grey on grey — and 1.45:1 is under what a hairline is
worth, spread over a whole cell.

Two changes. The tint is now the theme's accent (`ActiveAccent`, already
floored at 3:1 by `legible_accent`), which is the one colour the terminal
surface has nothing else in. And the strength is derived per theme instead
of fixed: the wash is opaque with the glyph drawn on top, so what it may
spend is the theme's own text-contrast budget. A palette with 21:1 between
text and background can afford a wash you cannot miss; one with 6.6:1
cannot, and a single constant has to be safe for the second.

The current match drops its caret-coloured outline. That existed because a
fill 2.1:1 off the background could not say "this one" on its own; now that
it sits at the top of the theme's budget, the outline is the same colour
saying the same thing twice.

* fix(ui): let the overlay scrollbars fade out again

macOS reports should_auto_hide_scrollbars() = false for anyone with a mouse
plugged in, and apply_theme turned that into ScrollbarShow::Always for every
list in the app. That preference is about legacy scrollbars, which take a
gutter out of the layout; ours are overlay bars painted on top of the content,
so Always parked an opaque bar over the switcher's tab column for as long as
the panel stayed open, with nothing to fade it.

Pin scrollbar_show to Scrolling instead, so every list fades its bar out after
it stops scrolling.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-10 18:36:21 +08:00
l0ng-aiandl0ng-ai 99a388331c fix(search): wash a match in the accent, at a strength the theme can afford (#470)
A search hit was washed from the terminal palette's selection colour at a
fixed 1.45:1 against the background, so it read as a weaker selection on a
grid that is already grey on grey — and 1.45:1 is under what a hairline is
worth, spread over a whole cell.

Two changes. The tint is now the theme's accent (`ActiveAccent`, already
floored at 3:1 by `legible_accent`), which is the one colour the terminal
surface has nothing else in. And the strength is derived per theme instead
of fixed: the wash is opaque with the glyph drawn on top, so what it may
spend is the theme's own text-contrast budget. A palette with 21:1 between
text and background can afford a wash you cannot miss; one with 6.6:1
cannot, and a single constant has to be safe for the second.

The current match drops its caret-coloured outline. That existed because a
fill 2.1:1 off the background could not say "this one" on its own; now that
it sits at the top of the theme's budget, the outline is the same colour
saying the same thing twice.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-10 18:26:18 +08:00
l0ng-aiandl0ng-ai fafcaa0685 feat(splits): draw the pane grip as Ghostty draws its own (#463)
The bar a pane was picked up by grew and recoloured under the pointer,
and showed the moment the pointer was anywhere in the pane at all. It
was loud in the wrong places: a mark on top of the terminal wherever the
mouse happened to rest, and a target that moved while being reached for.

Cut to the shape Ghostty gives its grab handle instead:

* three dots, 80x12 of reach around them, and nothing between the two
  states but ink — 0.3 in the band, 0.8 on the grip itself.
* the dots are asked for by the pane's top fifth (floored at 24px), not
  by the whole pane, so the terminal is left alone everywhere else.
* the target is there for as long as the pane can be moved, and only the
  dots come and go, so a pointer going straight for the top of a pane can
  press the grip on the frame it arrives.
* a 150ms fade in, so the dots read as arriving rather than blinking.

The fading a pane is under is now worn by the terminal it holds rather
than by the pane, which keeps the grip legible on the very panes
`dim_inactive_panes` fades — the ones being reached for.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-10 17:24:40 +08:00
l0ng-aiandl0ng-ai 425f87e9a4 fix(core): key the machine tree to the config directory (#462)
* fix(core): key the machine tree to the config directory

The tree resolved from $HOME while everything else an instance owns —
views.json, the scrollback, the history, both sockets, the pidfile, and
daemon.lock — resolved from the config directory. So --config-dir moved
every part of an instance except the one that says which workspaces
exist, and two tty7s pointed at different config directories, each
holding its own lock and each certain it was the only server on the
machine, still co-owned one ~/.local/share/tty7/machine.json.

MachineStore::persist writes the document whole. The second one to flush
replaced the first one's workspaces with its own, and the next daemon to
start read the survivor's tree as the machine's. An empty tree is not
distinguishable from a machine that really has nothing on it, so the GUI
does what an empty tree means and forgets those workspaces for good.

A lock and the thing it protects have to be keyed alike. data_dir() now
follows the config directory; TTY7_DATA_DIR stays as the highest-priority
override so the test harnesses keep their sandboxes.

Moving the path without carrying the file would lose every workspace at
the moment of upgrade, which is the failure this change exists to stop,
so the daemon adopts the legacy file on startup before it opens the
store. The destination already existing is the whole guard: it means a
newer run owns the tree and the copy at the old path is stale, from a
build that predates the move and still writes where it believes the tree
lives. Adopting that over the live file would hand the old tree back.

* fix(core): only the machine's own instance inherits the legacy tree

The migration moved `machine.json` into whichever config directory started
first. In the very setup this change exists to fix — a default install beside a
`--config-dir` one — that is the second instance renaming the machine's tree
into its own directory, leaving the primary to come up owning nothing. It also
fired in our own test suite, where `routed_pane` and friends launch a real
`tty7-server --config-dir <TempDir>` under the developer's own `HOME`.

Adoption is now the entitlement of the instance running out of the config
directory this machine resolves to on its own: `$TTY7_CONFIG_DIR` where the box
names one, `$HOME`'s otherwise. Comparing paths rather than asking whether
`--config-dir` was passed is what keeps the ordinary install working — `spawn`
hands every daemon it starts an explicit `--config-dir`, its own included — and
counting `$TTY7_CONFIG_DIR` is what keeps remote hosts upgrading, since a remote
`tty7-server` is launched without the flag and finds its directory that way.

Also tightens the cross-filesystem fallback: a rename that failed because
another process already carried the file over is the one benign race, not an
error to report and not something to copy over. What is left copies through
`create_new`, so "never overwrite what is already there" holds against a racing
writer and not merely against an `exists` check several syscalls old, and a
write that does not finish leaves nothing behind.

Tests: the gate both ways, the appearance hint riding along, the same directory
under two names, the copy path refusing an occupied destination, and two
cross-process cases in `machine_tree` that start a real server under a scratch
`HOME` — one carrying the legacy tree in, one leaving it alone.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-10 17:12:26 +08:00
l0ng-ai d223818e1b Merge pull request #461 from l0ng-ai/fix/scrollback-restore-survives-restart
fix(daemon): give a restarted server back its panes' shells and screens
2026-08-10 16:46:52 +08:00
l0ng-ai a55340ed7f fix(daemon): sweep a dead daemon's leavings on the writer's tick, not at startup
Review follow-ups on this branch.

`history::sweep` still ran at startup, three lines under a new comment
explaining why sweeping there is wrong. The reasoning transfers exactly, and
worse than by analogy: a restore carries the dead pane's commands to its
successor via `history::carry`, so sweeping before the window can ask deletes
the file the request is about. Same shape as the scrollback bug, one file over.
Both sweeps now run on the writer's tick off one shared id set, and the writer
is named for what it does.

`pane_attachable` lost its only caller when the restore path moved to
`pane_free_for`, leaving a function kept alive by the test asserting on it. The
attach site does not need to predict the listing: it tries the attach, and a
pane that is gone falls through to the fresh spawn on its own. Gone, with its
tests folded into `pane_free_for`'s.

`restored_screen` now drops the snapshot in both directions. Keeping the file
when it decoded to nothing left it to be re-read and re-rejected by every later
restore, and swept never, for a pane the tree still names.

Also: the module doc still said scrollback was off unless asked for, which is
what this branch reverses; and #449 landed the whole feature with no CHANGELOG
entry, so nothing told anyone that pane output now lives on disk.
2026-08-10 16:36:35 +08:00
l0ng-ai b3a66e75d0 fix(test): let the machine-tree seed keep up with a new pane field
PaneSeed grew a `shell`, but this test is unix-only, so a Windows box
never compiles it and never says so. Build the seed from `bare` and the
next field lands on its own.
2026-08-10 16:30:53 +08:00
l0ng-ai 852d3178c8 style: rustfmt 2026-08-10 16:10:58 +08:00
l0ng-ai 477d82524f feat(daemon): keep every pane's screen, without asking
`persist_scrollback` is gone, and with it the switch, its three
translations and the branches that read it. Keeping a capped tail of
each pane's output is now what the daemon does, not something it can be
asked to do.

This reverses the call made when the feature landed. The argument for
off-by-default was that the ring holds whatever the pane printed —
echoed tokens, `env` output, an agent's transcript — and that writing
that down should be the user's decision to make. What the argument
missed is when the decision gets made: the moment anyone learns they
wanted this is the moment a daemon has already died, and by then the
setting could only be turned on for next time. A feature whose entire
purpose is to survive an event nobody schedules cannot be opt-in.

The cost is real and does not go away: pane output now lives at
`<config>/scrollback/*.bin` on every machine, 0600 on unix and behind
the config directory's ACL on Windows, capped at 256 KiB per pane and
dropped as soon as no window can still ask for it.

Old configs naming the key still parse — nothing in `Config` refuses
unknown fields — so the key simply stops meaning anything.
2026-08-10 16:06:54 +08:00
l0ng-ai 412bfcfc90 fix(session): let a dead pane keep its id so its screen can be asked for
`pane_attachable` answered one question and was used for two. Deciding
whether to attach needs to know the pane is alive; deciding which dead
pane a fresh one replaces needs only its id — and that is the case the
stored screen exists for.

Using the first answer for the second was self-defeating. After the
daemon restarts, every pane the window held is missing from the new
daemon's listing, so the id was ruled out, so `restore_pane` was `None`,
so the window spawned a pane that had never heard of a predecessor. No
attach was tried, no restore was requested, and the screen the daemon
still had on disk was swept a tick later without anyone reading it. The
setting was on, the snapshot was written, the daemon was ready to hand
it over, and nothing ever asked.

Ownership still rules an id out, because another workspace's pane is
neither ours to attach to nor ours to show. Liveness no longer does:
the attach is still tried first and still gives way to a fresh spawn
when the pane really is gone, which is the arrangement the tree path
already argues for at length — `live` is a hint about what to show,
never the judge of what to destroy.
2026-08-10 16:06:54 +08:00
l0ng-ai 3093babddd fix(pane): say when a restore is not asked for
Dropping the request here produced a blank pane, which is also what a
pane with nothing stored looks like and what a daemon that refused would
produce. Three causes and one appearance, with nothing anywhere to tell
them apart — the filter was silent, so reading the source was the only
way to find out which had happened.
2026-08-10 16:06:54 +08:00
l0ng-ai c138be687a fix(daemon): keep a pane's shell and its screen across a restart
Two things a pane lost when the background service stopped and started,
both of them things the tree was the only possible place to keep.

**The shell.** `PaneRecord` and `PaneSeed` carried a pane's cwd, its ssh
spec and its agent, but never what it was running. A window rebuilding a
dead pane from the tree therefore had nothing to pass and spawned on
whatever the default shell is now — so a restart turned a bash pane into
a PowerShell one, quietly and in place. The daemon resolves the override
against the config at spawn time and is the only party that knows the
answer, so it keeps it and reports it; the seed carries it too, for the
panes a window spawned itself. A handoff carries it in the blob, because
nothing on the far side of an `execve` can work out the command line of
a child it never spawned.

**The screen.** The startup sweep ran before the endpoint was listening,
which is the one moment nothing can answer the question it asks: the
registry is empty and the windows that know which screens are still
wanted cannot say so yet. A tree that failed to parse made it worse —
`read_machine` quarantines it and returns an empty `Machine`, so one bad
file took every pane's stored screen with it. The sweep now happens only
on the periodic pass, a tick later, with the registry filled in and the
tree caught up; nothing is serving a request in between. Turning the
setting *off* still clears the directory at once, because there the
promptness is the whole promise.

Two smaller ones alongside it: `restorable_pane_ids` now counts the
tree's pane list and not only the panes some tab currently stands on —
the two disagree while a window is between layouts, and being wrong
costs a file swept a tick late in one direction and somebody's terminal
in the other. And `restored_screen` drops the snapshot file *after*
deciding it was not empty, so a snapshot holding nothing is no longer
consumed by the request it could not answer.

The restore path had no end-to-end test, which is how this shipped: the
unit tests cover the file, not whether a window that reattaches is shown
anything. The new one runs a real daemon, puts a marker on a real pane,
stops the daemon, starts another, and reads the wire.
2026-08-10 16:06:54 +08:00
l0ng-ai edfadb7df2 Merge pull request #424 from l0ng-ai/feat/scm-foundation
feat(scm): a full Source Control panel, decorations and commit history
2026-08-10 14:12:21 +08:00
l0ng-ai 2dc6a88af6 merge: main into the Source Control branch
Conflicts were the two streams touching the same seams, resolved by
taking the newer decision on each side:

- main's interface font scale (rems tokens) wins in right_panel.rs; the
  SCM panel keeps its local px steps until it moves onto that scale,
  and the now-unused PANEL_TEXT constants are gone.
- main's l10n_keys! macro (idents only) means the key list carries no
  doc comments any more; our SCM keys fold into it, and PanelUntracked
  stays deleted — its only caller was the panel this branch replaced.
- main's Command::localized palette style carries our Git group; ORDER
  keeps main's visibility and our width.
- main's ansi_seed/clear_ink refactor in presets.rs carries the lane
  colours: lanes() now clears through the same helper semantics uses.
- file_tree keeps both: main's drag-and-drop targets and this branch's
  git decorations per row.
- diff_overlay keeps both: main's sidebar-count write-back on snapshot
  install and this branch's epoch read and untracked preview.
- main's window.prompt SSH-close confirmation supersedes the bespoke
  modal our branch still carried; main's tile-glyph revert stands.
- main's two new guards are satisfied: the fourteen SCM actions carry
  authored names on the Keybindings page (their palette wording, plus
  a new CmdGitToggleGraph), ja translates ScmDetached, and CmdGroupGit
  joins the kept-in-English list — Git is a name.

2571 tests, 0 failures.
2026-08-10 13:25:30 +08:00
l0ng-aiandl0ng-ai 9815f2d16f fix(windows): restore Ctrl+C in panes (#459)
The daemon was created with CREATE_NEW_PROCESS_GROUP, which disables
Ctrl+C for the whole new group — and Windows hands that "ignore Ctrl+C"
state down to every descendant. Every ConPTY shell a pane spawned
inherited it, and so did everything those shells ran: the pane wrote 0x03
and conhost turned it into a keypress, but the CTRL_C_EVENT never came,
so `go run` and `npm install` carried on. Git Bash looked fine only
because MSYS synthesises SIGINT from the byte itself and never waits for
a console event.

DETACHED_PROCESS already leaves the daemon without a console for a
control event to arrive on, so the group flag bought nothing to begin
with. Both daemon spawn paths and tty7-cli's headless server — which
spawns panes too — now share one constant without it, and
DaemonPane::spawn clears any inherited ignore before it opens the pty, so
a tty7 launched from a shell that already had the bit set is covered as
well.

The regression test has to inherit the state rather than switch it on in
place, since that is the shape the daemon was in: an intermediate process
created exactly as the daemon used to be runs both arms, and a pane must
be interruptible only after the clear. Its observable is the shell rather
than the interrupted command — after the ^C, cmd gets its prompt back and
acts on the `exit` typed behind it — so it reads no message and holds on
a non-English Windows.

Fixes #451
Fixes #314

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-10 13:24:23 +08:00
l0ng-ai feb027da1f feat(scm): show an untracked file's content when its row is opened
Focusing an untracked file in the diff overlay used to fall through to
the names-only "Untracked files (N)" card — git has no patch for a
file it does not know, and `--no-index` needs a null device whose
spelling is platform business. The overlay now reads the file's own
bytes (lazily, only the focused file, 4 MiB cap) and synthesizes the
card a parsed added-file patch would produce: every line an addition,
new-side numbers, true counts past the single-file budget, git's own
NUL-in-the-first-8000-bytes binary rule. A fresh snapshot clears the
preview so an edit shows up on the same cadence a tracked file's does;
a failed read says so instead of showing an empty file.

Found in manual acceptance of the panel.
2026-08-10 13:06:15 +08:00
l0ng-ai 1df43b72b5 feat(files): copy dropped files into the folder they were dropped on (#458)
* feat(files): copy dropped files into the folder they were dropped on

The Files panel has only ever been a drag *source* — a row dragged into a
terminal inserts its path. Nothing on the tree ever registered a drop, so
a file dragged in from the desktop did nothing at all, not even a
highlight. Closes #453.

The drop is the whole gesture: files land where the cursor was, not
somewhere a dialog asks about afterwards. A folder row takes them itself,
a file row stands in for the folder holding it — "next to this one" — and
the space the rows do not cover belongs to the top of the tree. The
placeholder inside an empty folder takes a drop too; it is the only thing
drawn there, and letting it fall through to the root would put files
somewhere the cursor never was. A row under the cursor wins over the
column, which is what gpui's innermost-first dispatch already does.

The copy itself goes through the `Host` the tree is listing, so a remote
workspace reads here and writes there. Locally it is `fs::copy`, which is
what keeps the executable bit that `write_file` would drop; remotely the
bytes ride one control frame, and a file too big for that is refused with
the advice to use SFTP rather than half-sent.

Names already taken are asked about before anything is written, and the
answer governs the whole drop — a half-done copy would have to be undone
to honour a "no". Replacing a folder replaces it rather than merging into
it. A drag let go where it started is a miss, not an error, so it says
nothing.

* fix(sftp): list the directory again once an upload lands

An upload is written to `<name>.tty7-upload-<hex>` and renamed into place
at the very end. The browser listed the directory the moment the transfer
was handed to the daemon, so it caught that temporary name — and nothing
ever listed again, so a finished upload sat on screen as a file with a
hash glued to its name until the directory was navigated by hand.

The premature listing is gone, and the panel now remembers the job ids it
started: once one stops running — done, failed, cancelled, or dropped off
the job list entirely — the directory is listed once more. Two uploads in
flight settle independently, so the second one finishing does not depend
on the first.

* docs(changelog): note the SFTP upload listing fix

* ci(host-boundary): allow the source side of a file drop, and stop scanning two files as empty

The Files panel now copies dropped files in, and what the desktop hands
over is by construction a path on the desktop's own machine: reading it is
a local read even when the tree being dropped on is remote. The
destination side goes through `Host`, and the one `std::fs::copy` that
touches a destination sits inside a branch already gated on
`host.id().is_local()`.

While adding that entry: `attr` starts unset, which awk reads as 0, so a
file whose first line is `mod something` matched `attr == NR - 1` and cut
its body at line 0. `head -n -1` then errored and the file was scanned as
empty — `src/terminal/mod.rs` and `src/ui/tray/mod.rs` both open that way,
and the guard had been blind to both. Neither contains a violation, so
seeing them is free.
2026-08-10 12:41:17 +08:00
l0ng-ai 58d7ef5838 fix(scm): close out the review's minor findings across the data and UI layers
The second pass over the branch review: every remaining finding verified
against the code, the real ones fixed.

Data layer:
- A truncated log parse is never called complete: RecordSplitter drops
  an overlong record whole and reports the count (delivered cut short,
  a commit body cut mid-way reads as the real message), parse_log
  carries a truncated flag past MAX_LOG_BYTES, and load_page only says
  "end of history" when the parse read everything git returned.
- Every scope pins symbolic revs to shas before walking, so a commit
  landing between two pages can no longer shift where page two starts
  under Head and Refs scopes; unresolvable names read as "no history"
  rather than as a load failure. --parents was doing nothing and is
  gone; edge sort is stable so a merge's Outs keep first-parent order.
- The lane model's central invariant now names the join case — a merge
  whose second parent already has a lane reserved sends its Out onto
  that lane, one line below the cut, not two — with a golden test for
  the commonest merge topology of all, which no golden covered.
- DiffSource revs get the same could-be-an-option guard log already
  had; C-quoted paths decode the full escape set (a tab decoded to a
  literal t broke the :(literal) re-probe); rename from/to lines
  override the ambiguous diff --git header; combined-diff line numbers
  follow the sides rather than the colour, so a " +" line no longer
  drifts every number below it.
- A rename's old path stays out of the per-file decoration map, where
  it outranked a file re-created at that path; ignored records decorate
  as Ignored, not Modified; checkout <branch> gains the trailing --
  that keeps a stale name from falling back to a worktree-clobbering
  path checkout; unstage before the first commit takes -f (worktree-
  safe with --cached); batches split by bytes as well as count for
  Windows' 32K command line; a deadline expiry reports Timeout, not
  "git could not be run"; error details keep both streams.
- probe_status distinguishes "not a repository" from "could not ask":
  a dropped link keeps the cached status (stale beats blank) and rests
  10s instead of erasing the panel, while a definitive not-a-repo also
  drops the cwd→root mappings so the panel stops drawing Loading for a
  repository that is gone. Probe and watch work are wrapped against
  panics that would wedge their in-flight bookkeeping forever, watch
  landings check the wipe counter, superseded probes relaunch through
  the debounce, and a refused network slot says so instead of eating
  the click.

UI:
- Reset --hard confirms with its own words (commits fall off the
  branch), not the discard dialog's; a merge commit whose prefilled
  message the user cleared is committable again; the disabled commit
  button distinguishes "nothing to commit" from "write a message".
- Selection highlight matches on the diff source too, so a file staged
  and edited again no longer lights both of its rows for one overlay.
- The graph materializes only the rows in the viewport window (5000
  flex children per frame was most of a frame), row clicks carry the
  page Arc and an index instead of a deep Commit clone per row per
  frame, filter results are cached per (page, query), and a selected
  merge ring's hole matches the selection band under it.
- A failed commit_files read says the list could not be read instead
  of "0 files changed"; the STAGED chip and the graph's relative
  times go through the i18n table; the keys-awaiting-a-caller list is
  pruned to the seven that still are; the orphaned PanelUntracked key
  is gone; the zh commit placeholder reads naturally.

2398 tests, 0 failures. Known flake: daemon::singleton's second-claim
test, untouched by this branch, fails ~1 in 3 full parallel runs and
passes alone.
2026-08-10 12:31:58 +08:00
l0ng-aiandl0ng-ai 35bbad5155 docs(features): drop the removed orchestration skill entry (#457)
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-10 12:20:01 +08:00
l0ng-aiandl0ng-ai 5f1ee966ec fix(windows): give the pane grip and drags in flight a cursor (#455)
Win32 ships neither an open- nor a closed-hand cursor, and gpui's Windows
backend answers both with the plain arrow. The pane drag grip asked for
`cursor_grab()` and so read as ordinary background there, and the pointing
hand the sidebar's group header had worked around it with was dropped again
the moment a drag began, since the active drag cursor is `ClosedHand`.

Lift that workaround into `reorder::cursor_grab` so the grip and the group
header share one answer, and pick the held cursor per platform too.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-10 12:12:09 +08:00
l0ng-ai a764d92132 fix(scm): sequence compound verbs, cap graph paging, back off failed loads
Review findings on this branch, all in the seams between async operations:

- Commit-and-push, commit-and-sync, sync and discard-all dispatched both
  halves into the worker pool at once, so a push could resolve the branch
  tip before the commit (or pull) it was waiting for and quietly send the
  old one. Compound verbs now carry a ScmFollowUp that the first half's
  landing closure starts on success only; a refused commit, a failed pull
  or a cancelled confirmation drops the follow-up with it.
- Push sent `git push <remote> <branch>` with the branch taken from the
  upstream's name — a bare name means a *local* branch, so `feat`
  tracking `origin/main` pushed stale local `main`. The refspec is now
  `HEAD:<branch>`, and the branch is validated with the full branch
  check since a `:` would smuggle a second refspec in.
- `scm.committing` was armed before the amend confirmation and never
  disarmed on failure, so a cancelled prompt (or a hook rejection) plus
  any later unrelated HEAD move cleared a message that was never
  committed. It is armed at dispatch and disarmed when the commit errors.
- Discard-all fed staged-only paths to `checkout --`, where a staged
  deletion sank the whole batch as an unmatched pathspec. Only unstaged
  paths go in, one confirmation covers both halves, and the two gits no
  longer run concurrently.
- One "load more" click at 5000 commits grew `requested` past what
  `load_page` clamps to, so the freshness check never passed again and
  every frame refetched the full page. Growth stops at the cap, the
  button hides there, and a failing `git log` is remembered per key
  instead of being retried from every render.
- A repository switch now drops the previous repository's page before
  anything can draw it or grow from it — a stale row's context menu
  used to build ops for the new repo with the old repo's rev.
- A watch that failed to open was retried at frame rate, one host round
  trip per render; it now rests for WATCH_RETRY between attempts.
- Non-network writes on a remote host ran under the interactive
  20-second deadline while the server ran the job to completion, so a
  slow pre-commit hook was reported failed and then landed anyway. Every
  write now goes through git_with_deadline, 120s for local verbs.
2026-08-10 11:24:09 +08:00
l0ng-aiandl0ng-ai 30b16c65b5 fix(settings): keep one restart button for the stale background server (#452)
The in-place-update notice carried its own Restart server button while the
Server section right below it carried an identical one, both calling
restart_daemon. Move the notice into the Server section: the stale build
line sits under the header and its explanation replaces the generic one,
so the single button that ends every running pane is the only one on the
page.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-10 11:22:11 +08:00
l0ng-aiandl0ng-ai 88bf9a5da5 feat(daemon): upgrade in place, keep pane screens across a crash, and give panes their own history (#449)
* feat(daemon): keep a pane's screen across a death nobody chose

A daemon that crashes, is `kill -9`'d, or goes down with the machine takes
every pane's replay ring with it, and the window comes back to a row of
blank shells. The processes cannot be saved that way — nothing written to
a file brings a process back — but the picture can.

The daemon now keeps a capped tail of each pane's ring under the config
directory, and a client whose `Attach` found nothing can ask, on the
`Spawn` that replaces it, for the dead pane's screen. The new pane opens
showing it, under a rule that says the shell below is new.

- periodic and dirty-only: a ring that has not moved is not rewritten, so
  an idle machine does no IO at all. Write-through would be an enormous
  amount of write amplification for a few seconds of freshness.
- capped at 256 KiB per pane, far below the ring's 8 MiB: the value of
  scrollback decays with distance from the bottom, and every byte here is
  a byte of someone's terminal on disk.
- off by default. The ring holds whatever the pane printed, including
  echoed tokens, `env` output and agent transcripts; in memory that dies
  with the daemon, and writing it down is the whole feature and the whole
  cost. Files are 0600, and turning the setting off deletes what was kept.
- dropped by relevance, not by calendar: a pane the user closed, or one no
  workspace names any more, has its file removed on the next sweep.

Restored bytes are replayed at the geometry they were written at, and are
preceded by resets — leave the alternate screen, show the cursor, restore
autowrap, clear SGR — because a snapshot is cut at the front and can begin
in the middle of any of them.

* feat(daemon): upgrade the daemon in place instead of killing every shell

Picking up a new build meant stopping the daemon, and stopping the daemon
means every pane dies: the pty master is a descriptor this process holds,
so when the process goes the slave side raises SIGHUP and takes the shell,
the agent and the half-finished command with it. That is why the update
path leaves the old daemon serving and Settings has to offer the restart
as a thing you schedule for a quiet moment.

`execve` does not have that problem. It replaces the image and keeps the
process: same pid, same children, same descriptors, same file locks. The
daemon now rewrites itself that way on `ClientMsg::Handoff` — it writes
what it knows about each pane into a blob, clears FD_CLOEXEC on the pty
masters, the blob and the singleton lock, and execs the new binary, which
picks the panes back up on the other side.

- **the seat travels on the command line, not in the blob.** The lock is
  still held by this process, so the new image must adopt the descriptor
  rather than ask for the lock again — asking would be refused by its own
  lock and it would stand down in favour of itself. A daemon that loses
  its panes is a bad afternoon; a daemon that exits leaves the machine
  with nothing serving, so that one fact has to survive an unreadable blob.
- **the blob is unlinked before it is written.** It holds every pane's
  ring, which is the output `scrollback` makes people opt into storing;
  a handoff must not be a back door for writing it to disk.
- **the exec is the last step.** Everything is staged first, so any
  failure before it costs a log line and the daemon carries on serving —
  which is what lets callers treat a failed handoff as "fall back to a
  restart" without having lost anything on the way.

Native SSH panes cannot cross — their session is cipher state in memory,
not a descriptor — so they are hung up first and the far end sees a clean
close. Windows has neither execve nor a transferable ConPTY handle, so it
keeps the stop/start path; the dialogs there still promise what they
always did, and the new copy is shown only where it is true.

Also retries flock on EINTR: a signal landing mid-call said nothing about
the lock, but was reported as "could not be evaluated", which starts a
second daemon beside the first — the split machine singleton exists to
prevent.

The end-to-end test sets a variable in the shell, hands over, and reads it
back. Nothing but the original process can answer that, and the daemon's
instance id changing while its pid does not is what says an exec really
happened.

* feat(shell): give each pane its own history when asked

Two panes running zsh with `share_history` are appending to one file and
reading each other's lines back, which is either the feature or the
problem depending on what the panes are for. Someone with a pane per task
wants Up to walk that task's commands, not an interleaving of four.

Each pane can now have its own history file instead. It is seeded from
the shell's real history, so a new pane is not blank, and what the pane
added is appended back when it closes, so nothing typed is lost — a
per-pane history that evaporated would be a way of losing commands, not
of organising them.

The seeding is done by the shell, not the daemon, and that is the only
reason it works: `HISTFILE` belongs to the user's rc file and can point
anywhere, long after the pane's environment was decided. tty7's snippet
is appended to the rc it wraps, so it runs after that decision and is the
one place the real path is known — it copies the tail, records how much it
copied, and repoints. Both shells load history after their startup files,
so the switch lands before the first line is read.

The daemon's half is a filename, a rename when a restored pane inherits
its predecessor's file, a merge on close, and a sweep for the panes a
killed daemon never got to retire.

Off by default: shared history is what a terminal has always done, and
someone who did not ask for the change would experience it as their
history mysteriously forgetting the other window. bash and zsh only —
fish and PowerShell do not keep a HISTFILE, and a shell launched with the
user's own arguments gets no snippet to repoint anything in.

* fix(daemon): store pane screens on the shutdown a restart actually uses

The periodic writer covers a death nobody prepares for and the SIGTERM
path covers a signal, but the restart the app itself performs goes through
ClientMsg::Shutdown — which killed every pty without taking a copy first.
That is the one shutdown where the panes are expected back.

* fix(daemon): leave nothing dangerous behind when a handoff fails or lands

Review findings on the in-place upgrade and per-pane history:

- A failed exec now puts back everything it had staged: FD_CLOEXEC on the
  seat and every pty master (a child inheriting the seat keeps the flock
  held past the daemon's death, so no future daemon could seat itself),
  and the SIGPIPE disposition plus this thread's signal mask, both of
  which Command::exec resets on its way to the attempt — without this,
  the still-serving daemon dies on the first client that hangs up
  mid-write.
- The adopting image restores close-on-exec on the seat and on every
  adopted master, so children it spawns later cannot hold a pty open
  past its pane, or the seat past the daemon.
- The target binary is checked before the handoff gives anything up:
  native-SSH panes are hung up on the promise that this process is about
  to be replaced, and an exec that was never going to work must not
  collect on it.
- The integration snippets raise HISTSIZE/HISTFILESIZE (bash) and
  SAVEHIST/HISTSIZE (zsh) for the pane's private history file. At their
  defaults the exit rewrite truncates the file below its own seed mark,
  which the merge-back rightly reads as "replaced under us" — silently
  losing the pane's commands for anyone with more history than the caps.
- The restart dialog's promise now binds the action: where the copy said
  "nothing is interrupted", a failed handoff is reported instead of
  silently traded for the restart that kills every pane.
- The scrollback writer checks the ring's mark before cloning it, so an
  idle pane no longer costs a full ring copy under the state lock every
  tick.

Each behavioural fix carries a test that fails without it; the history
truncation one was verified to fail with the snippet change removed.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-10 10:59:02 +08:00
l0ng-ai 44f0683d0a fix(sidebar): cut labels on grapheme clusters, not on chars (#450)
The sidebar's elision measures against real glyph widths but slices by
`char`, so it can satisfy every width check and still hand back a torn
cluster. Scanning budgets from 30px to 200px over emoji fixtures, 48
widths produced output no font can render as intended:

    "release-…\u{200d}👩\u{200d}👧"   a joiner with nothing in front of it
    "lon…\u{fe0f}"                    the variation selector lands on the ellipsis
    "abcdef…🇳ghijklmnopqr"           half a flag, which renders as a bare N

A tab title carrying an emoji is not exotic — plenty of TUIs and coding
agents put one there — and the second case is the same U+FE0F this repo
already carries an alacritty patch for.

`elide_keep_edges`, the tail-only fallback, and `short_title`'s 40-glyph
clamp now index grapheme clusters. `elide_path_keep_tail` cuts on `/`
and was already safe. Widths are unchanged: clusters are measured the
same way chars were, so every existing elision test still passes on the
same fixtures.

`unicode-segmentation` is already in the tree via gpui; pinning it here
adds one line to Cargo.lock and no new code.

Tests assert the property rather than the symptom: whatever survives on
either side of the ellipsis has to be a cluster-aligned prefix and
suffix of the input. That catches any tear, not just the three shapes
found here. Written first, confirmed failing on all three cut sites, and
green after.
2026-08-10 10:38:04 +08:00
ARNOandl0ng-ai 1fd99ca7c6 feat(sidebar): tail-first label elision and a hover card for hidden details (#446)
* feat(sidebar): tail-first label elision and a hover card for hidden details

ff

* fix(sidebar): keep the head of a non-path label, and let the card expand what the row hid

The elision landed with four gaps between what a row hides and what the
hover card can give back.

A renamed tab is elided like anything else, but `sidebar_info` still
carried the old tooltip's guard and bailed out on any non-empty
`tab.name`, so a long custom name was shortened by the row and the card
refused to spell it out. An empty terminal title took the opposite path:
the row falls back to `Shell 3`, the card compared that placeholder
against the empty string it came from, found them different, and opened
on a row that had hidden nothing. Both came from deriving the same
strings twice; the row now hands `sidebar_info` what it rendered next to
what it rendered it from, and the card is decided by comparison alone.

`elide_path_keep_tail` was applied to every title, but a title is not
always a path — `npm run dev`, or a name someone typed. Dropping the
head of those says less than the truncation this replaced, so
`elide_label` picks the rule: tail-first for a path, both edges
otherwise.

`elide_keep_edges` promised both ends and delivered neither on a token
with no break in reach: the head ran to the 12-glyph cap, `head…` alone
overran the budget, and it fell through to a bare tail. It now tries
shorter heads before giving up, and never trades the whole tail away for
a longer head (`feature/…` became `fea…thing`).

Also: measure the active row at the MEDIUM weight it actually renders
at; subtract the list's own padding from the text budget, so a label
that "just fits" is not handed back to CSS truncation; rejoin a path
with the separator it arrived with, instead of spelling one tab
`C:\Users\dev\app` while it fits and `C:/…/app` once it does not; count
the gap between the two diff counts rather than the space standing in
for it; let the card wrap instead of truncating the one string it
promised in full; and read the remote host off the same leaf the title
came from.

The elision tests shape through gpui's `NoopTextSystem`, where every
glyph is one em — deterministic across the three CI targets, but blind
to the proportional and CJK widths this exists for. Said so where the
fixtures are built, rather than implying the pixels are real.

---------

Co-authored-by: l0ng-ai <ysdpk123@gmail.com>
2026-08-09 22:13:01 +08:00
l0ng-aiandl0ng-ai 86799220ca feat(splits): rearrange a tab's panes by dragging one onto the layout (#445)
* feat(splits): rearrange a tab's panes by dragging one onto the layout

Hovering a pane floats a small grip along its top edge; dragging it picks
the pane up and puts it somewhere else in the same tab. Three landings,
resolved from where the pointer is:

* a pane's edge — split that pane and take the side dropped on
* a pane's middle — trade the two panes' places
* the band along the outside of the tab — sit beside everything else as a
  full-width or full-height band, which is the only way to say "make this
  a full-height column" in one gesture from the middle of a 2x2

The landing is highlighted while the drag is in flight, and is offered
only once the tree agrees the drop changes something, so the highlight is
never a promise the drop does not keep.

* pane: move_leaf / move_leaf_to_edge / swap_leaves, each built on a clone
  and installed only when the layout really differs
* pane_drag: the pointer-to-landing geometry, the drag state, and the grip
* tree_sync: reconcile a tab that kept its panes but changed shape with a
  single PaneMove instead of closing and rebuilding the tab

* feat(splits): drop a pane beside its neighbours, not on top of one

Trying the drag out on real layouts turned up three ways the drop model
asked for more precision than it should have.

A drop on a pane's side always halved that pane, so putting a new column
into a row of columns was only reachable at the very edge of the window,
where the band rule took over. A side facing a neighbour in the same row
or column now joins that run: the newcomer takes an equal share and the
others give it up in proportion, keeping whatever relative sizes they
were dragged to. A side facing across the run has no run to join and
still halves the pane it landed on.

The band along the tab's edge was a flat 26px, which on any real window
is a hair's breadth. It is now measured against the pane it is read in —
a sixth of it, floored at 32px and capped at 120 — and only counts on a
side that faces the window rather than another pane. Landing there takes
an even share of the columns that side already has instead of half the
tab, so a third column is a third and not a half.

The highlight is no longer drawn from the rule. The drop is carried out
on a deep copy and the dragged pane's new rectangle is measured off it,
so the preview and the result cannot disagree; the copy is deep because
sharing a run out writes ratios the live tree's splits hold in common.

Also: the grip is a quiet 22x3 bar that grows to 40x5 under a fixed
56x10 target (it needs an id of its own, or gpui settles its size before
the group-hover is known), and every rearrangeable pane keeps an 8px
strip clear above its grid so the grip never sits on the first row.

* fix(splits): pin a drop to the pane it was offered against

Review follow-ups on the pane drag.

A drop zone named its target by position in the tab's leaf order, but it
is read on one frame and carried out on the next: a pane closing in
between shifts every index after it, and the drop lands beside a pane the
user never aimed at. The zone now carries the target itself once the
frame that drew it has resolved it, so a target that has gone refuses the
drop instead of sliding it sideways.

Alongside it:

* `Pane` is no longer `Clone`. The two copies it can be asked for differ
  in whether they share their splits' sizes, which is not a difference to
  leave to whichever one `.clone()` happens to mean; `shallow_clone` is
  now named and private, next to `deep_clone`.
* `edge_landing` no longer hands back a share that only a test read. The
  test reads it off the split the landing produced instead, which is the
  number the drop actually lands.
* A test pins the invariant the drop zones rest on: `leaf_rects` comes
  back in the order `leaves` does.
* Drop a doc comment that had landed on `close_focused` describing a
  different method, and an `Option` in `drop_pane` that was wrapped only
  to be unwrapped two lines later.
* The changelog claimed every rearranged tab now syncs as one `PaneMove`.
  Only a drop beside a single pane does; a drop beside a whole group is
  not something `PaneMove` can name, and still takes the rebuild. Both
  entries move under `Unreleased` — v26.8.2 was tagged before either
  landed.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-09 22:04:24 +08:00
l0ng-ai c2b6ba2ec0 Merge pull request #442 from l0ng-ai/fix/issues-426-430
fix: small-window Settings layout, vim caret on a raw pty, wheel zoom, theme preview and cross-locale palette search
2026-08-09 20:39:16 +08:00
l0ng-ai 2f31a11df0 fix(settings): size the nav floor for the longest label in any locale
SidebarMenuItem clips its label rather than eliding it, so a 140pt floor
that fits English cut a glyph in half elsewhere: zh-CN lost the right half
of the last character of 窗口与标签页. Size the floor for ja-JP
ウィンドウとタブ, the widest of the three, which costs the page 35pt at the
narrowest window and keeps every nav label whole.
2026-08-09 19:20:15 +07:00
l0ng-ai 3dcffe6f4e fix(windows): grow a remembered bound back up to the minimum size
window_min_size governs what a drag may do to a window, not the bounds it
opens with, so a remembered bound walked straight under the declared 720pt
minimum — the reported settings window measured 641. Clamp the restored
size on the way in, keeping the origin.
2026-08-09 19:20:15 +07:00
l0ng-aiandl0ng-ai c13efbce32 fix(terminal): draw a multiline history entry on one menu row (#436)
A command composed in the inline editor keeps its newlines when
`submit_command` pushes it into the in-session history, even though
`history::append` and the zsh/bash/fish parsers all refuse to carry an
entry across lines. gpui breaks text on `\n` whatever `white_space`
says, so one such entry painted a dozen lines inside a one-line row and
covered the whole reverse-search menu.

Fold line breaks to a visible `↵` when drawing — one char for one char,
so the fuzzy matcher's highlight positions still line up — and leave the
stored entry untouched, so recalling and running it are unchanged.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-09 20:19:53 +08:00
l0ng-aiandl0ng-ai 8b08f51773 ci: drop the two Claude review workflows (#441)
Remove `claude-code-review.yml` and `claude-review-fork.yml`. Both are
advisory-only and never gated a merge, so the required checks on main
stay exactly `rustfmt` and the three `build & test (<target>)` jobs from
ci.yml.

Nothing else references them: the `claude-review` label and the
CLAUDE_CODE_OAUTH_TOKEN secret were used only by these two files.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-09 20:11:38 +08:00
l0ng-aiandl0ng-ai 8dbc7efa1a fix(cli): stamp panes with the workspace that holds them, not the client's name (#425)
* fix(cli): stamp panes with the workspace that holds them, not the client's name

A pane's owner names the workspace allowed to attach to it. The CLI wrote
a literal "tty7-cli" there for every pane it made, so a window opening on
a CLI-built workspace found none of them attachable: it spawned a fresh
shell for each tab, orphaned the live ones, and — because the tree still
carried each pane's agent session — greeted the user with a failing
`claude --resume <id>` in every one of them.

Both spawn paths now pass the workspace id, and restore treats an owner
that parses as no workspace as no claim at all, so panes already stamped
by an older CLI attach instead of stranding.

* fix(cli): let the OWNER column speak only when it disagrees with WS

Now that a pane's owner is the id of the workspace holding it, printing
both spells the same id twice on every row of `pane ls --all` — and
buries the rows that matter. The column now shows a dash when the two
agree, so what is left is exactly what is worth reading: a pane its
holder may not attach to, and an orphan still naming where it belongs.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-09 20:08:03 +08:00
l0ng-ai 150f6ff76f fix(settings): stop the page being the only column that gives width back
The settings nav, the SSH host list and the theme panel were fixed widths
that never yielded, so the page they frame absorbed every shortfall. In a
641pt window — the one the report came from — that ran all the way down.
On SSH, 220 of nav and 280 of host list left the detail panel 141pt and
its empty state painted a couple of hundred points past the right edge of
the window. On Appearance with the theme panel open the page got about
125pt, and a Chinese description came out one character per line.

The columns are now allocated against the window instead of asserted:
each list is handed its full width, then gives back a share of whatever is
missing until the page reaches 420, and no list goes below the width at
which it stops being itself. Below the width where the nav, the panel and
a readable page cannot all fit in one row, the theme panel stops being a
column and lays itself over the page — it is a temporary layer over one
choice, and Escape already closed it first.

The floor the page keeps is derived rather than picked: it is what the
narrowest window in the wild leaves the SSH page, the one that spends a
second list, once both lists stand on their own floors. It is a target for
the allocator and not a `min_w` — a floor a flex row cannot honour does
not push its siblings back, it overflows, and overflow here means content
painted off the window, which is the failure being fixed. What makes the
floor liveable instead is that the wide controls can now shrink into it.

The thresholds that decide when a row stacks are widths a label needs, so
they follow the interface font size — at 24pt every label is half as wide
again while the slider beside it is still 240px.

The rows that were hand-rolled rather than built by `settings_row` get the
same treatment: the keybinding preset and prefix rows stack at the same
width, every binding row lets its label wrap and its key caps wrap to a
second line, the theme card drops its preview and stops pushing "change
theme" off the card, the SSH quick-connect field shrinks instead of
running past the pane it sits in, and a port-forwarding rule takes two
lines — or three, at the width the report came from — rather than one that
does not fit.
2026-08-09 18:48:06 +07:00
l0ng-ai 14c092b0d1 feat(palette): preview themes live while the picker is open
Picking a theme from the command palette closed it, so finding out what
a theme looks like meant reopening the palette and retyping the search
for every single one.

The theme picker now applies whatever row is highlighted straight to the
running window and stays open: Return persists the pick, Escape or any
other way of closing the palette puts the previous theme back. A preview
only touches the in-memory config, so arrowing through the list never
writes config.json. The picker opens on the theme already in use, so
opening it changes nothing by itself.
2026-08-09 18:39:31 +07:00
l0ng-ai 54960be6bf feat(palette): match commands in every locale, not just the shown one
The palette matched a query against the label it was rendering and nothing
else, so a window running in Chinese answered "no matching commands" to
`theme` — the English wording of that row did not exist anywhere the filter
could see it.

Each entry built from a locale key now carries the same key as every other
locale words it, plus the stable command id, as hidden search aliases. They
are built once with the entry, cost nothing per keystroke, and are never
rendered: the row keeps showing its localized label, and an alias hit scores
just below the same hit on that label so a visible match still comes first.
2026-08-09 18:39:31 +07:00
l0ng-ai 68d6b4b062 feat(terminal): zoom the font with the platform modifier and the wheel
Holding Cmd (Ctrl off macOS) and scrolling over a terminal now resizes
the font instead of the scrollback, which is what you reach for when
showing a pane to someone else.

A wheel detent is one step whatever the platform bills it as, and a
trackpad accumulates until the fingers have travelled three lines, so a
flick does not run the font end to end. Steps go out as the existing
IncreaseFontSize/DecreaseFontSize actions, so the clamp and the saved
setting stay in one place.
2026-08-09 18:39:31 +07:00
l0ng-ai 44d33031e1 fix(terminal): stop repairing a parked cursor on a raw pty (#430)
Typing `:wq!` in vim wrote it onto the row being edited instead of the command
line, and left the cursor there. The command line is not what moved: the cursor
is.

`ParkedCursorRepair` (#362) exists for conhost, which brackets every frame it
paints with `?25l` … `?25h` and, on the frames where it did not paint the
cursor, commits the show wherever the last erase or write left it. The repair
pairs the hide with its show, calls the show parked when the run moved the
cursor around to paint but did not end on a move, and puts the cursor back on
the cell the hide caught it on. The reader ran it on every platform.

Off Windows there is no conhost in between and the application owns the cursor.
Captured from vim 9.1 on a raw macOS pty, opening the command line is

  \x1b[?25l \x1b[11;10H: \x1b[1;1H \x1b[11;1H \x1b[K \x1b[11;1H: \x1b[?25h

— a run full of moves that ends on the `:` it wrote, which is exactly the shape
the scanner calls parked. So the repair dragged the cursor off the command line
and back onto the file, and vim, which echoes the following keystrokes as bare
bytes with no positioning of their own, wrote `wq!` over the text.

Gate the repair on `cfg!(windows)`, the way `conpty_resize` already is: the
artifact is ConPTY's, and on a raw pty the cell a frame leaves the cursor on is
the cell the application meant. This also keeps the scanner off the client's
output path entirely on Unix, where it used to walk every batch. A macOS or
Linux client attached to a *remote* Windows daemon loses the repair with it —
the same limit `conpty_resize` has — which costs a stray caret there and buys
back an unshredded screen on every local pane.
2026-08-09 18:39:31 +07:00
l0ng-ai 070966cfdb merge: polish pass — 151 fixes across settings, terminal, themes, copy and i18n (#402)
Polish pass: 151 fixes across settings, terminal, themes, copy and i18n
2026-08-09 18:35:44 +08:00
l0ng-ai 62b922f2c2 Merge origin/main into integration/polish
main dropped the client-side command-mark store (#404) while this branch
had just started reading it: the close confirmation names the command it
is about to end, and the mark was the only place that text existed on the
client. Keep both. The OSC 133 tokenizer main left in place already sees
every mark, so the command line now rides alongside `zle_reading` and
`shell_vi_mode` as one shared string — set on `C`, cleared on `B` and on a
`C` that carries no line — instead of a store with a list, a lock and a
cap. `busy()` reads that.

The rest:

- settings.rs takes main's opaque overlay surface and background layers,
  keeping this branch's no-match note and scrolled body. The inner
  `.bg()` goes, per main's reason: the root already paints it, and a
  second fill hides the theme image.
- i18n keeps this branch's `every_key_is_translated_in_every_locale`,
  which walks `L10nKey::ALL` in all three locales, over main's
  hand-listed zh coverage test it replaced. It immediately caught three
  of main's new backdrop keys reading English in ja — Mica, Mica Alt and
  Acrylic, which is what Japanese Windows calls them, so they join the
  allowlist with that reason.
- app.rs keeps both sides' tests and drops both sides' now-dead imports:
  `window_background` (main deleted the function) and `humanize_action`
  (this branch's keybinding note uses `keymap::action_entry` instead).

Verified: `sleep 300` then ⌘W asks about "sleep 300"; ⌘W after it ends
closes without asking.
2026-08-09 16:38:15 +07:00
l0ng-ai 5c65e2b08f test(git): pin the git config the fixtures assume, and one path spelling
Two Windows-only failures, both of them the tests asserting on the
runner's git rather than on the code.

`core.autocrlf` is `true` by default in Git for Windows, so a file
written as `one\n`, committed, and restored by `checkout --` comes back
as `one\r\n`. The `-c` list the helpers already pass would not have
fixed it: the checkout in that round trip is `run_op`, production code
running its own git with no overrides. So the pins go into
`<repo>/.git/config` right after `init`, where repository config
outranks the system config that carries the default.

The other is the same path-spelling mismatch fixed earlier in
`git_data.rs`: `git rev-parse` answers with forward slashes and no
extended-length prefix even on Windows, while `fs::canonicalize` returns
`\\?\C:\…`. Both name the same directory and the Win32 APIs take either,
so the production path is right and only the comparison needs one
spelling.

Fixed as classes rather than as instances. `core/git/mod.rs` gains a
`test_support` module holding the `-c` list, the repo-config pin and the
path normaliser, and `status.rs`, `log.rs`, `ops.rs` and `diff.rs` all
read from it — `ops.rs` had no config pins at all and `diff.rs` was
missing gpgsign. `git_data.rs` keeps its own copy because a
`#[cfg(test)]` item does not exist in the `tty7-core` the binary crate
links against; a comment says so and points at the other copy.

The normaliser has its own test over literal `\\?\C:\…`, `C:\…`, git's
`C:/…` and a unix path, and the line-ending fix was reproduced locally
by pointing `GIT_CONFIG_SYSTEM` at a config with `core.autocrlf = true`:
that panics exactly as CI did with the pins reverted, and passes with
them. One latent hazard hardened while here — `status.rs`'s scratch
directory had no pid in its name, unlike its sibling, so a leftover that
resisted removal would have been silently reused as a fixture.
2026-08-09 17:02:29 +08:00
l0ng-ai 0106430ecd merge: main into the Source Control branch
The one conflict is an import list in `diff_overlay.rs`: this branch added
`SharedString` for the unified view's row labels, main added `Background`
and `Hsla` for the window backdrop work. Both sides are still used, so the
resolution is the union.

Worth recording why this merge happened when it did. `main` moving is not
normally urgent — branch protection dropped its strict check, so a branch
behind main still merges — but a *conflicting* branch is different: GitHub
cannot compute `refs/pull/N/merge`, and every workflow that triggers on
`pull_request` silently stops running. Three pushes in a row registered no
CI at all on #424 while other PRs kept going green, which reads as a GitHub
incident and is really just an unresolved conflict.
2026-08-09 16:20:19 +08:00
l0ng-ai d0d5e149c7 fix(git): compare watch directories in one spelling of a path
`scm_watch_dirs` hands back what `git rev-parse` answered, and git writes
forward slashes and no extended-length prefix even on Windows. The test's
expected side is built from `fs::canonicalize`, which on Windows returns
`\\?\C:\…` — so the two named the same directory and compared unequal, and
the Windows job failed on a path the watcher would have been perfectly
happy with.

Both spellings reach the same directory through the Win32 file APIs, so
the watcher is right to pass git's answer straight to `Host::watch` and
nothing changes outside the test. The five assertions now go through a
`one_spelling` helper and keep their exact-equality teeth; on unix it is a
no-op, which is why this was invisible until Windows CI ran the branch for
the first time.
2026-08-09 16:08:46 +08:00
l0ng-ai 4d5ac5913c fix(scm): gate the graph's idle test on unix, like its three siblings
`test_window::harness_with_pane` is `#[cfg(unix)]` — it hands back a
`std::os::unix::net::UnixStream` — so a test module that calls it has to
be gated the same way. `panel.rs`, `detail.rs` and `file_tree.rs` all
declare theirs `#[cfg(all(test, unix))]`; this one said only
`#[cfg(test)]`, which broke the Windows test build with E0425 while
compiling fine everywhere a developer looks.

Nothing was lost by gating it: the module holds one test, and its own
doc comment already says it has to run against a real repository and a
real pane.
2026-08-09 15:46:56 +08:00
l0ng-ai e76d545655 test(git): build the log tests a repository instead of reading this one
Both tests pointed `load_page` and `local_branches` at
`CARGO_MANIFEST_DIR` — the checkout the tests happen to be running
inside — and then asserted things only a developer's clone is true of.
`actions/checkout` clones shallow, so "five commits" found one, and a
pull-request build checks out the merge ref detached, so "this checkout
is on a branch" found no branch. Both assertions were about the
environment; the code under test was answering correctly in each case.

They now build their own history, following `status.rs`'s `scratch()`:
a temporary directory removed on drop, `user.name` and `user.email`
pinned because a runner has neither, and `symbolic-ref` rather than
`init -b` so the branch name does not depend on the git version. One
test forks a side branch and merges it back over seven commits, so lane
layout is checked against a shape with more than one lane in it; the
other makes a single commit that modifies, adds, deletes and renames, so
the `--numstat` / `--name-status` join is exercised on four paths rather
than on whatever HEAD happened to touch.

Owning the history let the assertions get stricter rather than looser:
the commit count, the branch list and the per-file `(added, removed)`
pairs are now exact, the paging check is honest about how much history
exists, and the merge row is asserted to carry two parents and two
outgoing edges. The three other tests reading `CARGO_MANIFEST_DIR` were
audited and left alone — one bails when `git log` fails, one falls back
to a short sha on a detached HEAD, and the third is `#[ignore]`.
2026-08-09 15:21:48 +08:00
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 388e65e96a fix(tests): the shell check proves a good shell on every platform
The case meant to show that a real shell reports no problem named
`/bin/sh`, a path that does not exist on Windows — so the one assertion
guarding the happy path asserted the opposite there, and the Windows job
failed on it the moment the suite got far enough to run. Point it at the
running test binary instead: a file that exists and carries the execute bit
wherever the tests run.
2026-08-09 13:46:10 +07:00
l0ng-ai 83f6415644 fix(tests): the rename test only builds where its harness does
`harness_with_tabs` hands back the panes' `UnixStream`s, so it is gated
`#[cfg(unix)]` — but `rename_gpui_tests` imported it under a plain
`#[cfg(test)]`, so the Windows job failed to compile the test binary with
E0432 while every other target passed. `ssh_rebuild_gpui_tests`, which uses
the same family of harness, already carries the right gate.
2026-08-09 13:38:35 +07:00
l0ng-ai 96c997ad59 fix(scm): keep the row buttons on screen, and settle the panel's surfaces
Three real defects and a design pass, all found accepting the panel against
a running app.

The hover buttons on a file row erased themselves the instant the pointer
reached them. The strip called `occlude()`, and gpui's `Frame::hit_test`
stops at the first `BlockMouse` hitbox and drops every hitbox inserted
before it — which includes the row's own, because a parent prepaints before
its children. So the row stopped counting as hovered, `group_hover` stopped
applying, and `Interactivity::paint` returned early on `Visibility::Hidden`
before drawing either the backing or the buttons. The tooltip outlived them
because it is an `on_hover` listener armed on the last frame that painted,
which is why what was left on screen read as a grey box where the buttons
should have been. Replaced with `on_any_mouse_down` and
`stop_propagation` — the idiom `switcher.rs` already ships — and two gpui
tests now fail if `occlude()` comes back.

The commit-detail view drew "No files changed" above "Loading…" while its
read was still out, and the graph's new-branch field was the only `Input` in
the application without `.appearance(false)`, so it wore gpui-component's
default border.

The rest is the panel's visual language, which had drifted into tty7's
dialog vocabulary. `bg(theme.input)` occurred exactly once in the whole
application and `.primary()` only ever appears in modals and the settings
page, yet the commit box was a filled bordered field with an accent focus
ring and the commit button a filled slab — in a panel where nothing else is
outlined and separation is carried by surface and space. The commit area is
now two soft rounded fills, both from `field_fill`, at half the surface
ramp's first rung: `hover` is what a row wears for the moment a pointer is
on it, and a field that wears its fill permanently is the loudest thing on
an idle panel at that strength. Focus takes the whole rung instead of a
ring.

The split button lights as one shape rather than one end — it has no outline
around either half and a seam one pixel wide, so half a lit pill read as a
paint bug — and its halves paint nothing themselves in any state. That last
part is not only about hover: gpui-component resolves a custom variant's
*selected* paint from its `active` slot, and a dropdown holds its trigger
selected for as long as the menu is open, which parked a block on the
chevron for the whole time the menu was being read.

Smaller things in the same pass. The graph's conventional-commit prefix is
inline muted text rather than a coloured pill, which stops a second colour
column competing with the lane gutter beside it and un-ragged the left edge
of the subjects. Commit-detail refs no longer paint `theme.accent` at full
opacity under muted text — that is the system's loudest neutral fill, and it
made an ordinary `origin/main` shout over the HEAD chip it was meant to
defer to. The commit button is compact and right-aligned beside a
staged-file count, the history filter hides behind a toggle that takes its
query with it when it closes, and the detail view says how many lines a
commit moved. `TILE_SIZE_XS` and `TILE_GLYPH_XS` moved to `app.rs`, so the
SFTP and port-forward panels no longer import tile sizes from the Source
Control module.

The lane-gutter fold is gone. It bought about six characters of subject
width, but folding the lanes away leaves a list rather than a graph, so
nobody would ever press it.
2026-08-09 14:35:54 +08:00