Commit Graph
172 Commits
Author SHA1 Message Date
l0ng-ai a689ee2bb2 refactor: say why the clippy allows are there, and drop the one that is not
Sixteen `#[allow(clippy::…)]` in the tree; thirteen carry a comment saying
why. These were the three that did not, and the reason turned out to differ
for each.

`spawn_once` takes eight arguments against a threshold of nine, so its
`too_many_arguments` allowed nothing at all -- removed. `render_ssh_row` does
trip it, at ten with `self`, and `LocalHost::new` really does hand back a
`SharedHost` rather than a `Self`; both now say so.

Removing all three first and reading what came back is what separated them,
and it needed two passes: clippy stops at the first crate that fails, so
tty7-core's error hid settings.rs's until it was fixed. A single clean run is
not evidence about anything downstream of the first failure.
2026-08-15 22:54:11 +08:00
l0ng-ai c957231142 chore(lint): put clippy on the CI gate, and clear the ~200 findings behind it
CI checked `cargo fmt --check` and the build, so nothing ever read the
content of the code — ~200 clippy findings had accumulated, a third of them
in `#[cfg(test)]` modules.

Two of them were real:

- `resolved_background_appearance` took `backdrop`, and only the
  `#[cfg(windows)]` arm used it. Renaming it to `_backdrop` is what clippy
  asks for and compiles cleanly on macOS; on Windows it is an undefined
  name. Kept the parameter and discharged it in the non-Windows arm the way
  `package_for_current_install` already does.
- `SettingsSearchBackdropKeywords` and seven other `L10nKey` variants were
  carrying translations in three languages for strings nothing reads.

The rest is mechanical: let-chains for collapsible `if let`s, struct-update
syntax for `Default::default()` reassignment, `sort_by_key` where a manual
reversed comparator was doing the same job the same file already did with
`Reverse` two functions later.

Where clippy was wrong, the reason is now in the tree rather than rediscovered:
per-platform `#[cfg]` blocks each keep their `return` (dropping it only
compiles on whichever target's block lands last), the loopback parser keeps
three parallel arms instead of folding one into a `?`, and the four wide enums
are all built on the stack and consumed immediately, so boxing them would add
an allocation rather than save one.

`L10nKey` cannot be clean under `dead_code` on any single platform, so the
allow there records how to audit it instead — which is how the eight dead keys
were found.

`english()` gained the cross-platform test its doc comment already claimed;
only the Windows hint had been pinned.

The clippy job is non-required until it has green history, for the same
branch-protection reason `host-boundary` documents.

2930 tests pass.
2026-08-15 18:11:17 +08:00
l0ng-ai 3d6528737a fix(settings): give the page back its scroll range, and hold the bar off the window corner
The centring added in #631 turned the settings content box into a flex
column, and that cost the page most of its scroll range: the box is an
item of the scroll pane, which is itself a flex column, so its height
came out of a negotiation with the pane rather than from the rows it
stacks. `content_size` is just that box's laid-out bounds, so the range
ended a screen short of the last row — dragging to the bottom still left
content cut off. `flex_shrink_0` does not help; the height is agreed,
not squeezed. Centre with `mx_auto` on the column instead and leave the
box a block, which reports the full height it stacks.

While there, hold the content scrollbar 12px clear of the top and
bottom. Every other list this bar serves sits in a bordered panel where
running the full height is right; this pane is the window, and a bar
drawn to the last pixel lands on the rounded corner. New
`with_inset_vertical_scrollbar` takes the inset, and the existing
`with_vertical_scrollbar` keeps its behaviour for the other twelve
call sites.
2026-08-14 21:47:08 +08:00
webdev 422808191d feat(sidebar): group a tab by its folder when its cwd is not a repo (#631)
`sidebar_grouping` gains a third, opt-in mode, `repo-or-directory`: group by repository home as before, and when the repo probe has landed and answered "not a repo", group under the cwd itself instead of filing every such tab under Scratch. A probe that has not run yet resolves to no decision, so a tab keeps the group it already has rather than bouncing through Scratch mid-probe. The decision lives in one `resolved_group` free function shared by the per-frame key derivation and spawn-time seeding.

The default (`repo`) and flat modes behave exactly as before, and an unknown value in an existing config still degrades to `repo`.

Knock-on: `machine_mirror::subject_path_of` names a window after its most common group, so in the new mode a window of plain shells takes its name from the most common directory rather than from the first pane's cwd.

Closes #620.
2026-08-14 15:57:36 +08:00
l0ng-aiandl0ng-ai 72db26d15a feat(prompt): let the shell's own line editor own the prompt (#633)
Closes #624

tty7's inline editor takes the prompt the moment OSC 133 reports one, and
until now the only way to keep it off was to hide the shell's own name
from tty7 so integration never armed — which costs the prompt boundaries,
cwd and exit codes as well. Someone who binds `history-beginning-search-
backward-end` to Up in their zshrc had no way to reach it, and the local
history the editor walks instead is per-view: a command run in one pane is
not in another's list, so the shell's shared history looked broken too.

The new `prompt_editor` switch (Settings -> Input -> Prompt, on by
default) hands the line back. Off, every key at the prompt goes to the
PTY, so ZLE / readline / fish do the editing and what the user bound
behaves as written. Shell integration is untouched by it.

The gate is one line in `input_inactive_reason`, which every path that
could take the prompt from the shell already asks: keys, IME commits,
paste, Tab, the completion and reverse-search menus, the input bar. That
is what makes this a mode rather than a special case per key.

`shell_owns_prompt` learns the flag too, and that half matters more than
it looks: the gap hold and the typeahead record both exist to feed the
local editor, and `flush_typeahead` sends ^U to erase the line before
moving it there — on a line only ZLE is editing, that erases the user's
work. Ctrl-R landing on the PTY also stops raising the missing-integration
notice: the shell owning it is what was asked for.

Turning it off mid-line hands what is typed to the shell the way an
unknown chord does, so the text is still on the prompt to finish. Live
panes follow the switch, including a hand edit of config.json in another
window.

Tab completion and history search are menus tty7 opens inside that editor,
so the page greys them out and says why while it is off. Only their text
dims — a switch already draws its thumb at 35% when disabled, and dimming
the row on top of that leaves a pill with nothing visible in it. Their
stored values are left alone and come back with the editor.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-14 14:46:22 +08:00
l0ng-ai 1424da0891 fix: nine UX fixes across the diff overlay, layout, settings and pane spawn (#623)
Found by driving a dev instance and measuring what an idle window costs.

Two of them were frame loops that never stopped. A `Head` diff overlay
calls itself stale when the cached git status disagrees with the snapshot
on screen, and every landed probe wakes that check by touching the cache
— but the read published its counts and left the branch behind, so a
branch switched outside tty7 made the disagreement permanent: two `git`
processes a lap, forever, with `refreshing…` pinned to the header and an
idle window at 7% of a core. And the home page asked for a frame sixty
times a second to change one glyph's opacity twice, which made a window
with nothing open in it eight times more expensive than one running a
shell. Both now settle: the diff read publishes the branch it found, and
the home cursor flips a bool on a timer the way the terminal's own does.

The rest:

- The tab sidebar and the right panel each capped themselves at half the
  window and knew nothing about the other, so together they could take
  all of it — 260 points of terminal on a 720-point window. Both now cap
  at whichever binds harder: half the window, or what is left after the
  terminal's floor and the other panel's floor. The same cap bounds the
  drag, so a panel dragged to its limit stays where it was dropped.
- Only a HEAD diff may correct the sidebar's counts. Those numbers mean
  `git diff --numstat HEAD`; an unstaged or staged patch answers a
  smaller question, so opening an untracked file from the Source Control
  panel took the staged lines off the total on the click.
- An untracked row in the diff overlay had no click target, and once
  focused could not be left — the breadcrumb looks the path up in
  `files`, where an untracked file has no entry. Both ends fixed.
- A new pane keeps the name its directory was reached by. `cwd()` alone
  loses it: the shell falls back to `getcwd()`, so `/tmp/x` became
  `/private/tmp/x` in every tab opened from the first. `PWD` carries it,
  and POSIX has the shell discard a `PWD` that names the wrong
  directory, so this can correct the name and cannot invent one.
- The settings search now sees into the Keybindings page, which is
  generated from the binding table rather than the static index — so
  searching for a feature finds its shortcut, and the page filters to
  the matches. Closes #444.
- The settings reading column is centred rather than pinned to the nav:
  on a window as wide as the display it was made for, 640 points of
  settings sat beside 1600 points of nothing.
- `New Workspace…` takes the ellipsis its three sibling actions already
  carry — it opens a form asking for a name and a host.

Every fix has a test. The re-probe loop is pinned end-to-end with
`render_probe::draws() == 0` against a real repository, confirmed to
fail on the old behaviour before it was kept.
2026-08-14 08:42:29 +08:00
Hongwei Qinandl0ng-ai a2d53a9597 fix: 19 项低危 UX 问题(#584–#602) (#615)
* fix(scm): say what "discard all" actually discards (#594)

The group-level Discard prompt asked to "discard every change in this
repository", but discard_all_ops has only ever swept unstaged edits and
untracked files — staged changes survive, as the function's own comment
notes. Users confirmed under one belief and the code kept another.

Narrow the prompt to the operation's real footprint, in all three
languages.

* fix(scm): keep the amend toggle when its confirmation is cancelled (#595)

scm_commit cleared scm.amend when Commit was pressed, before the
"rewrite the last commit?" prompt. Answering Cancel returned to a panel
whose amend mode had silently been dropped, so the next Commit created a
brand-new commit — exactly what the user had just declined to risk.

The toggle now clears where scm.committing arms, at dispatch in
run_git_op, extending the rule the armed flag already followed: a
cancelled confirmation leaves nothing behind.

* fix(cli): answer a wait timeout in the success path's JSON shape (#589)

The 124 branch returned {pane,status,timed_out} while a finished wait
returns {pane,status,matched,stale,activity,message,session_id} — so the
one branch a consumer writes error handling for was the one missing its
fields. The timeout now carries the full shape plus timed_out, and
reference.mdx documents the schema and the flag.

* fix(cli): report a failed wait on stderr, even under -q (#590)

wait's failures are structured exits (124, or 1 when the pane died
first), so they never passed through the anyhow path whose eprintln is
the only thing quiet mode cannot silence — contradicting the documented
"errors still go to stderr". Both exits now print their headline to
stderr, the discipline pane close already established.

* docs(cli): describe owner as the workspace that may attach (#591)

commands.md still claimed the CLI stamps a literal "tty7-cli" owner on
the panes it spawns — the behaviour the orphan-workspaces work removed,
because an owner names the workspace allowed to attach and a stranger's
stamp got the panes respawned. Every spawn path now writes the workspace
id, or nothing while the pane is still unfiled. Bring commands.md in
line with reference.mdx, and note the absent case in both.

* docs(cli): close five contract drifts between the tables and the code (#592)

- The key tables listed pgup/pgdn as aliases but not pgdown, which the
  parser has always taken; both references name it now.
- "Case-insensitive" was flat wrong for Alt: M-x keeps its case because
  Alt is a prefixed ESC, unlike Ctrl. Both references note the exception.
- procs' ports JSON has carried addr since the field exists; both schemas
  show it.
- TTY7_WS is tab ls's default too; both environment tables say so.
- split --ratio's clamp to [0.05, 0.95] was discoverable only in code;
  both split sections document it.

* fix(cli): doctor exits 1 when the server is unreachable (#592)

doctor is the verb people run when something is not working, so an
unreachable server is *the* finding — not a row to exit 0 over while
`tty7 doctor || alert` never fires. The table and JSON still go out
(the context rows are the other half of what doctor is for), and stderr
carries the headline under -q. MockBackend grows an `unreachable` flag
so the branch is testable; no Status/Routes round-trips happen once
hello has failed.

* fix(settings): refuse a Start-in path that names no directory (#601)

The custom path was stored unchecked, and the daemon's picker then
skipped it — not a directory — so every new pane silently started in
the fallback directory and the typo read as a tty7 bug. Settings now
marks the field red and refuses to save, the proxy row's pattern
(#551), with the red line and the commit gated on one shared predicate
so they can never disagree; a hand-edited config.json holding such a
path gets a log::warn! naming it at the moment the fallback engages.

* fix(terminal): rescan search highlights when the pane's width changes (#586)

A match point is an absolute (line, column) against the width it was
scanned at, so a column change reflows the text out from under every
highlight. Output rescans them (Wakeup → refresh), but a quiet local
pane has no output coming and the drift outlasted the resize
indefinitely. set_grid_size now rescans on a column change with the
output path's discipline — selection and scroll untouched — and takes
the Context it needs to do so; a rows-only change reflows nothing and
stays cheap.

* fix(terminal): keep the grid selection when the search bar opens and closes (#584)

The selection that seeds the query is the thing being searched for, yet
opening the bar ran recompute_matches' unconditional clear — right for
its other callers, where the user *changed* the query and the old
selection names nothing — and closing cleared it again, so select →
Ctrl+F → Esc lost the selection every time. The seeded selection is now
restored after the opening scan, and close_search no longer clears; a
query the user actually changed still retires the stale selection, the
discipline refresh_matches_after_output already stated.

* fix(tabs): a zoomed pane stays zoomed across a tab switch (#599)

Zoom was a window-level value that activate() cleared unconditionally,
so looking at another tab and coming back restored the split layout —
while a zoom is a tab's temporary view state, like its focused pane.
It now rides with the Tab: activate stashes the outgoing tab's zoom and
brings the incoming tab's back. The clears that genuinely reshape the
layout (drag, split, close) still stand, and a stashed zoom whose pane
exited while the tab was away is validated away rather than restored.

* fix(tabs): track an open rename box by tree id, not index (#598)

The rename box held only an index, which drifts the moment any other
tab closes or the strip reorders — so close_tab_inner and
apply_tab_order threw the half-typed name away on any unrelated tab
event, and a reorder mid-rename still left a window where the commit
landed on whichever tab had taken the index over. The box now names its
tab by tree id end to end (start, render match, commit): only closing
the renaming tab itself ends the rename, and the name lands on the tab
the box was opened on wherever it has since moved.

* fix(i18n): move seven hard-coded user-facing strings into the language tables (#602)

Seven spots rendered English no matter which UI language was set: the
shell-integration notice that explains why a wrapper was blocked or never
engaged, the titles a pane wears once its process exits or the server
loses it, the loopback forward's failure line, the tray tooltip that
lists running agents (whose separator also wanted a CJK enumeration
comma), the cursor-shape choices in settings, the command palette's
empty-result hint, and the updater's install hint. Each is a L10nKey now
with en/zh/ja entries, so the parity guard keeps them translated from
here on.

The palette's empty state was also wrong in content, not just language:
every menu suggested connecting over SSH when nothing matched, including
menus that have no hosts in them. The hint now only appears in the
quick-connect menu; everywhere else the palette suggests a different
search instead.

Verified on Linux: the title/palette/tray suites (48 tests) and the i18n
parity guard all pass.

* fix(terminal): show remote path completion is listing, and say when it fails (#585)

Tab-completing a path on a remote workspace had two silences. The whole
network round-trip painted nothing, so a slow link read as a broken Tab
key; and a listing that failed was unwrapped into an empty candidate
list, so "the directory is empty" and "the listing never happened" ended
in the same nothing.

A pill over the pane's bottom-right corner — the style the integration
notice already uses, factored out — now says the listing is running from
the moment it starts, and a failed listing sets a notice with its error
instead of the empty vector. The failure pill stays until the next
keystroke dismisses it, and the trailing notify after an empty listing
closes the menu brings the "listing…" pill down with it.

Verified on Linux: the new gpui test covers the idle/listing/failed
states, and the neighbouring completion tests still pass.

* fix(files): quote cd Here / Insert Path for the shell the pane runs (#593)

Both file-tree actions wrapped a path with spaces in POSIX single quotes
whatever the focused pane's shell was. In cmd.exe a single quote is an
ordinary character, so `cd 'C:\Users\me\My Documents'` split at the
first space and cmd complained about 'C:\Users\me\My' — while the same
action was fine in PowerShell and bash, which is why only cmd users ever
saw it.

shell_quote_for takes the pane's shell program (the pane already knows
it — the settings page lists it) and picks double quotes for cmd.exe,
single quotes for everything else; an unknown shell keeps the POSIX
form, and a path that needs no quoting stays bare either way. Windows
paths cannot contain a double quote, so the cmd form has nothing to
escape.

* fix(cli): pane close fails for a pane the registry does not hold (#588)

`tty7 pane close %99` printed {"closed":[99]} and exited 0 for a pane
that never existed. The workspace path cannot drift this way — PaneClose
answers — but an orphan has no workspace to route through, so close
hangs it up directly, and that kill is fire-and-forget: the daemon never
says whether it knew the pane, so Ok(()) only ever meant the bytes
reached the socket. A reaper script chasing the orphans `pane ls --all`
points at would read the ghost success as cleanup done.

The direct path now reads the running-pane registry once per batch and
refuses ids it does not hold: the miss lands in `failed` with exit 1,
next to the failures kill itself can report. A pane that exits between
the listing and the kill is gone either way, which is what closing it
wanted, so that race still reports closed.

* fix(session): a launch that leaves workspaces running says so (#597)

Quitting with several windows open and starting again restored only the
most recent one; every other open window was marked detached — panes
alive, nothing on screen, the only trace a "left N detached" log line.
The workspaces were reachable from the sidebar, but nothing said they
existed, so they were easy to forget entirely.

restore_one now returns how many windows it detached, and both launch
paths (normal startup and the CLI-driven open) push an in-app
notification into the restored window naming the count and where to
reopen them. The count rides the return value rather than firing the
notification inside the store, because the store has no window to notify
in — and a launch that detaches nothing, like the reattach-the-last-
closed case, stays silent.

* fix(switcher): list the local machine's orphan panes, with a way to close them (#596)

A pane whose workspace went away — an interrupted `tty7 run`, a forgotten
workspace that kept its shells — was invisible everywhere in the GUI: not
in the sidebar, not in the switcher, not in the tray. It kept its process
and its memory, and the only way to even learn it existed was the CLI's
`tty7 pane ls --all`, which a GUI-only user never runs.

The switcher's local machine group now carries a "Background panes" block
under its workspace rows: one line per live pane the daemon's registry
holds and no workspace does — id, owner, cwd — each with a Close button.
The listing is the same PaneClient::list the CLI's reaper reads, fetched
off the UI thread when the panel opens; closing kills and then re-lists,
so a pane that survived simply stays on the list instead of pretending
to be gone. The block steps out of the way while the search field holds
a query, which narrows the panel to workspaces.

Local on purpose: a remote machine's orphans belong to its own daemon,
and routing a listing per host is what the CLI reaper is already for.
The block joins no keyboard navigation — the panes are not workspaces
and the arrows have no business landing on them.

* fix(updater): keep Inno's progress window on screen during the install (#600)

The Windows installer ran /VERYSILENT, so from the app quitting for the
update to the watcher bringing the new build up — tens of seconds, longer
under an antivirus scan — the screen held nothing at all: no window, no
progress, no tray note. "Clicked update, the app vanished" reads as a
crash, and double-clicking the icon does nothing while the files are
being replaced.

The installer now runs /SILENT instead. Nothing about the flow becomes
interactive — /SP-, /SUPPRESSMSGBOXES, /NORESTART and /CLOSEAPPLICATIONS
are untouched — but Inno's own progress window stays on screen for the
gap, which is exactly the span the user had no word about.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 18:11:17 +08:00
l0ng-aiandl0ng-ai 3cd90c6ca6 feat(ssh): name panes after their host, reach the host form from where you are, and test a connection (#566)
* feat(ssh): name panes after their host and reach the host form from where you are

Five gaps in the SSH flow reported in #438, and the two silent no-ops
around them.

Panes now carry a display name: a saved host's own name, or its address
when nobody named it — every host imported from ~/.ssh/config arrives
nameless, and each one opened a tab reading "tty7". The name survives a
title reset and a dropped link, and `strip_host_prefix` no longer cuts
`deploy@10.0.0.5:2222` down to "2222" on its way to the tab strip.

The host form is now reachable from the machine in front of you: the
switcher's machine menu edits the host it is showing, or offers to save
one for a machine reached by address or by ~/.ssh/config alias. A live
connection dialled by hand can be kept as a host from the palette, with
everything it was dialled with carried over — the one thing that cannot
come along is an ad-hoc jump hop, and that is said out loud rather than
saved broken.

The New Tab menu lists the saved hosts, most-used first, and the typed
address route that already understood -p, -J and config aliases. Both
were behind the workspace switcher, its host dialog and the settings
list.

Finally, the three proxy fields are exclusive — map_proxy picks the
first one filled and ignores the rest — so the ones that lose now say
which field won instead of leaving it to be discovered by connecting.

* feat(ssh): test a connection from the host form, and pick a host by name

Three things the host form and the New Tab menu were missing.

The form gets a Test button. It hands the spec to the daemon, which
dials it the way Connect would — proxy, jump host, host key, auth — and
drops the connection again, reporting how long it took. A test never
rides an existing connection: one would answer for the credentials that
connection was made with, so a password typed wrong would come back
green. Anything the handshake stops to ask a person is declined on the
spot and reported as what it asked for, since a form is nowhere to
answer a password prompt and waiting out the two-minute prompt timeout
would be a worse answer than "it got that far and wants your password".
The result clears the moment a field changes: vouching for a host that
was edited since is worse than saying nothing.

The Auth row becomes a dropdown. Six methods is more than a segmented
control can label without squeezing, and it is the row that stacks
first on a narrow page.

The New Tab menu scrolls — PopupMenu only does that past 20 items, and
every shell on the machine plus a handful of hosts already runs off a
short window — and past the five hosts it lists, Find a Host opens a
filter box over all of them.

* feat(new-tab): put a search box on the New Tab menu

The menu is as long as the machine is — nine shells here, and a
~/.ssh/config with two dozen hosts in it is ordinary — so it needed
filtering, not a scrollbar and a row leading somewhere else.

A PopupMenu cannot hold a text field: it claims the keyboard for its own
navigation, and there is no search input anywhere in it. So the New Tab
button now opens a popover holding the same searchable list the command
palette is built from, with the shells and the hosts under their own
headings and one box over both. Typing filters across both groups;
typing an address offers to connect to it, the way the palette does.

The standalone host picker this replaces is gone with it, and the row
that led there — one search reachable from the button beats two behind
a menu.

* revert(new-tab): put the New Tab menu back to shells only

The searchable popover was the wrong shape for a button in the chrome:
too big and too heavy next to the tab strip it hangs off. The menu is
the plain shell list it was before this branch — byte for byte, so
nothing about it needs re-reviewing — and the hosts, the search box and
the row leading to a host picker are gone with it.

Everything that came along to serve it goes too: the positional
NewTabWithShell command, the picker's palette delegate and its compact
row metrics, the standalone host palette, and the four strings they
needed. Connecting to a saved host is the palette's job again, which is
where it was and where it works.

* fix(switcher): size and weight the machine glyphs like the icons beside them

The two machine icons are drawn by hand; every other glyph in that
gutter comes from lucide. Ours were built to a tighter box — ink 19.3 ×
17.3 of a 24 grid against lucide's 22 × 20 — so at the same nominal
16pt the local machine rendered 11.5pt of ink beside a 14.7pt globe and
read as a size smaller. Both are redrawn to lucide's extents, which
also makes them agree with each other.

The local machine's glyph was muted while every remote one was full
strength, and while its own name was full strength either way. Beside
the machine under it that read as a disabled row rather than as the
computer you are sitting at. One weight for all of them now; the
"Other Machines" globe keeps its dimmer register, which belongs to the
muted section label it sits next to.

* fix(tabs): only a port stops the host head being cut off a title

Teaching `strip_host_prefix` that `deploy@10.0.0.5:2222` is an address and
not a titled directory was done by requiring the tail to start with `/` or
`~`. Two very common titles do neither.

Debian's stock bash title is `\u@\h: \w` — a space after the colon — so
`user@host: ~/work` would have stopped being cut at all, and every one of
those tabs would have gone from reading `~/work` to `user@host: ~/wo…`. And
tty7's own PowerShell integration writes `ann@BOX:C:/src` whenever the cwd is
off the home drive, which would have read `ann@BOX:C:/src` rather than
`C:/src`.

Key on the port instead, which is the thing that actually makes the string an
address: a tail of nothing but digits is kept whole, and everything else is
the path it always was. The space belongs to the head, so the tail is trimmed
on the way out.

* fix(ssh): keep a connection test off the cache, off a stale form, and clear about a changed host key

Three ways the new Test could answer for something other than the host in
front of it.

It dialled with `reuse: false` but still took the connection cache's slot
lock, which is held for the whole handshake. So a test stalled every Connect
to the same host behind a connection it was never going to leave them — and,
queued behind a session already dialling, spent its own budget waiting and
came back "connection timed out" about a host that answers fine. A test that
is not going to touch the cache has no business locking it: it now skips the
slot entirely, and only a reusing dial takes the guard it later fills in.

The form dropped a test result whenever a typed field changed, on the
grounds that the answer was about the host as it was a moment ago — but the
authentication method is a dropdown, and changing it left the green line
standing under a handshake the form would no longer make.

And a host key that has *changed* was reported with the same words as one
nobody has accepted yet. Those are not the same news: the first is a new
host, the second is the server presenting a different key than the one on
file. `SshTestNeed` now tells them apart and each gets its own line, in all
three locales.

Verified against a live sshd on localhost: refused port and unresolvable
name come back in milliseconds with the connect path's own message, a
password host comes back `NeedsInput { Password }` in 55 ms rather than
waiting out the two-minute prompt timeout, and two tests of the same host
back to back no longer serialize.

* chore(palette): drop the root flag the New Tab revert left behind

`grouped_root` was split out of `quick_connect_root` for the searchable host
picker on the New Tab menu, which was taken back out again. Every
constructor now sets the two to the same value, so the second one is a field
and a doc comment describing a list that does not exist.

* docs(changelog): record the SSH host form, pane names and connection test

Every user-facing change in this branch: panes named after their host, the
host form reached from the switcher and the palette, and Test.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 15:02:58 +08:00
Hongwei Qinandl0ng-ai 664b766698 fix(settings): split Shell Arguments like a shell, quote them on the way back (#551) (#573)
* fix(settings): split Shell Arguments like a shell, quote them on the way back (#551)

The field split on raw whitespace, so `-c "echo hi"` became four argv
fragments with the quotes still attached, and it silently rewrote config
too: `build_shell_inputs` refilled the field with `args.join(" ")`, which
cannot spell an argument containing a space, so a legal
`"args": ["-c", "echo hi"]` in config.json re-committed as three argv on
the next blur without the user typing anything.

Parse with shell-words rules instead and quote each argument on the
refill, so field text and the argv array round-trip losslessly. An
unbalanced quote cannot become argv at all, so commit refuses it and the
row explains why under the input — the proxy field's pattern. The field
description in en/zh/ja now says quoting works.

Program gets the milder half of the same treatment: a bare command that
detection (a PATH probe) never saw is almost always a typo like `pwsh7`
that today only surfaces when a pane fails to open, so the row warns
under the field. It never refuses — the field stays free-text so a shell
detection missed remains reachable — and anything spelled as a path is
taken at its word. The comparison reuses core's `same_shell_program`, so
"known" here means exactly what the new-tab menu dedup means.

shell-words was already in the tree via portable-pty, so the direct pin
adds no new code.

* fix(settings): split Shell Arguments as argv, not as POSIX source

Review pass over the #551 fix. Splitting with `shell-words` bought the
quoting contract at the price of two silent rewrites of its own, both the
same shape as the bug being fixed: a backslash outside quotes is a POSIX
escape, so `--dir C:\Users\me` committed as `C:Usersme` on the platform
where that is how a path is spelled, and `#` opens a comment, so
`--tag #1 --verbose` committed as one argument. The refill was noisier
than claimed too — `shell_words::join` quotes on `=`, `~`, `*`, `?` and
`[`, so an existing `--color=auto` came back as `'--color=auto'`.

Nothing here is a shell: the field is a text spelling of an argv array
that goes to `CommandBuilder` directly. So split and join are now a local
pair sized to exactly that job — quotes group, `\"` and `\\` inside double
quotes escape, everything else is a character — and the direct
`shell-words` pin goes away again. They are exact inverses, which is what
`config.json` needs, and the test walks the round trip over the cases a
space-join cannot spell plus the two above.

Drops the Program nudge. `shells::inventory()` inserts the *configured*
shell into the inventory it returns, so `pwsh7` is in `self.shells` from
the next refresh onward: the warning could only ever flash between the
commit and the refresh landing, and never appeared at all on a later visit
to Settings. Its test passed because it hand-built an inventory that
version of the value could not be in. Making it true needs core to say
which rows were detected rather than configured, and that is a serialized
protocol struct — too much for a nudge the issue itself called the milder
half.

Refusing the arguments no longer drops the Program typed or picked beside
them: the stored argv stays as it was, which is what "this value was not
saved" already told the user, and the shell picker works again while the
arguments field is mid-edit.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 10:32:18 +08:00
Hongwei Qinandl0ng-ai 8071eddb5b fix(ui): clamp the font metrics to the config's own range, and stop buckets mislabeling a hand-set value (#550) (#572)
* fix(ui): clamp the font metrics to the config's own range, and stop buckets mislabeling a hand-set value (#550)

The settings steppers and the Ctrl+=/Ctrl+- keys clamped font size to
6-48 and line height to 1.0-2.0, while `sanitize` allows 4-256 and
0.5-4.0. A value inside the config range but outside the GUI's got
pushed the wrong way by a single step — `font_size: 50` shrank to 48 on
"+" — and `set_font_size` writes the result back to the file, so one
misclick permanently changed a value it only meant to nudge. The bounds
move into tty7-core beside `sanitize` (the `ui_font_size` precedent),
one shared range for validation, the steppers, and the keyboard path.

The scrollback and notify-threshold preset rows had the matching
display bug: the highlight matched a *range*, so a hand-set 5000 lit up
"10,000" and 20s lit up "30s", and clicking that cell silently
overwrote the real value with the bucket's. The segmented control now
highlights a bucket only on an exact match and otherwise shows a
"Custom (N)" cell that names the live value and is not a button.

* fix(ui): name a custom preset the way the cells beside it are written

Review follow-up on #550. The "Custom (N)" cell rendered the raw integer, so
a documented `scrollback_limit: 50000` read "Custom (50000)" between cells
reading "10,000" and "100,000" — the one number on the row not written like a
count. It is grouped now, and the presets and their labels are one pair of
lists each, checked against each other, so a cell cannot come to show one
number and write another.

The bucket match moves out of the render bodies into `preset_choice`, which
is what makes the exact-match rule the issue asked for testable: the presets
the default lands on, the 50,000 the example config in
`docs/reference/configuration.mdx` carries, and 20s on the notify row.

The core test claimed to pin "the GUI steps within the range sanitize
allows", but only asserted that sanitize agrees with the constants it is
written in terms of — true by construction, and its line-height case took the
reset path rather than the clamp, so it passed without touching
LINE_HEIGHT_MIN at all. It now pins the published numbers themselves, the
clamp in both directions, and the two values the issue was reported with.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 10:08:45 +08:00
l0ng-aiandl0ng-ai d343fd8a13 feat(links): open file links in tty7, resolved on the pane's own host (#568)
* feat(links): open file links in tty7, resolved on the pane's own host

A clicked file path now opens in the built-in editor at the line and
column the link named, and the Files panel reveals it; a directory link
opens the panel on that directory. Settings -> Terminal -> Links -> Open
files with picks between the built-in editor, the OS file association
and a command, migrating anyone who had already set link_file_command.

Detection is split into a filesystem-free candidate parser and a probe
callback, so a pane whose paths live on another machine resolves them
there instead of against the local filesystem -- an absolute path used
to open this machine's copy silently. A pane running ssh typed into a
local shell can answer for neither side and no longer offers file links
at all.

Relative paths are measured from the directory the work is happening in
(the agent's, not the shell's kernel cwd) and then from the repository
around it, and a path that matches nothing under either now says so
instead of the click doing nothing.

* fix(links): keep a remote path off the local openers, and off a dead end

Review follow-ups on the file-link work.

- A file resolved on another machine now opens in the built-in editor
  whatever `link_file_open` says. Under `system` or `command` the path was
  handed to a local `open` / `code --goto`, which threw away the resolution
  just done on the pane's host and silently showed this machine's copy — the
  same bug this branch set out to fix, left live for two of the three modes.
  A directory outside every tree root says so instead of opening a local file
  manager on a path that belongs to the far side.

- `flush_link_probes` takes the host before it takes the wanted paths.
  `take_wanted` moves them into the in-flight set on the promise that a call
  is carrying them; a host that had gone away broke that promise for good and
  left those paths permanently unanswered — no underline, and a click that
  says nothing.

- `~` no longer borrows this machine's `$HOME` for a pane whose paths are
  elsewhere. A cwd outside `/home` and `/Users` used to fall back to it, so
  `~/.zshrc` on a Linux box became `/Users/me/.zshrc` and was asked about —
  and possibly answered — over there.

- An unresolved absolute or `~`-rooted path no longer claims it was looked
  for under the pane's directory. It never was: roots are only for relative
  paths.

- A pending tree reveal counts down whether or not its row was found. A row
  that never reported bounds kept the request alive for good, re-issuing a
  scroll on every render and holding the column against a hand scroll.

- The repo root comes from `GitStatusCache` when the git-status probe has
  already asked about that directory, rather than a second round trip.

Tests: the migration `link_file_open` exists for (an old config with a
command lands on Command, one without on the editor), a probe with no host
staying wanted, and `~` refusing this machine's home for another one.

* test(links): only claim a leading slash is absolute where it is

`is_rooted` asks `Path::is_absolute`, the same question `FileCandidate::paths`
asks before it decides the roots do not apply — and on Windows `/etc/hosts`
answers no to both. The predicate is consistent; the assertion was not, so it
now lives in a unix-gated test of its own next to the untouched one.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-12 22:48:16 +08:00
webdev 4da3868797 feat(shells): let the new-tab menu carry entries the user wrote (#534)
Closes #443
2026-08-12 10:52:35 +08:00
Hongwei Qin c2950fc434 feat(ui): forget orphaned remote workspaces when a profile is deleted (#508)
Deleting an SSH profile used to leave every remote workspace entry that had
connected through it behind, labelled with a bare internal id and retrying a
route that could never work again.

`RemoteRef` now carries a `RouteSnapshot` of the profile it was made from —
name, user, host, port — written at creation and refreshed on every reopen,
`serde(default)` so older session files load. The snapshot serves labels only:
`PartialEq`/`Hash` ignore it, or a refresh would split one entry into two.

Deleting a profile cascade-forgets the entries routing through it. Forgets,
not deletes: `WorkspaceRemove` is never sent, so the sessions on the remote
machine keep running and connecting again under a new profile brings them
back from the machine's own workspace list. An entry holding a live or
in-flight link is left alone, as is one whose window is still on screen — a
window whose workspace the store has forgotten reads as local, and its next
tab would open a local shell on what the user still sees as a remote box.
Whatever survives parks instead: no retries, no error, and an inline action
to drop it deliberately. A live or preempted link outranks a lost route.

Labels fall back from the live profile to the snapshot to a placeholder, so
no branch renders a bare UUID. Resolving the live name reads memory rather
than reparsing `~/.ssh/config`, because that path runs on every frame of a
window with a remote workspace open.

Closes #485.
2026-08-11 22:10:56 +08:00
l0ng-aiandl0ng-ai bf9c57dec7 fix(ssh): let a rejected stored credential ask again (#519)
* fix(ssh): let a rejected stored passphrase ask again (#486)

Saving the wrong passphrase for an encrypted key locked that key out
permanently. `passphrase_submit` wrote `SetKeyPassphrase` on the
"remember" checkbox alone — before the daemon had tried the secret, since
`apply_keychain_write` runs ahead of `respond_active` — and
`try_identity_file` treated a stored passphrase as final: a decrypt
failure with one went straight to "could not decrypt identity file", with
no prompt and nothing in the UI that could let go of it.

The daemon now says so. `AuthPromptKind::KeyPassphrase` grows a
`rejected` flag, and a stored passphrase that does not open the file
falls through to the interactive prompt carrying it, so the typed answer
still gets its attempt. A passphrase the user typed this time keeps the
hard failure — that is a wrong answer, not stale state. The sheet renders
the warning line the password sheet already had, and a rejected prompt
answered without "remember" now emits `DeleteKeyPassphrase`, mirroring
the password idiom exactly.

The flag is a `#[serde(default)]` field on a struct variant of an
externally tagged enum, which is compatible in both directions: an older
peer never sets it and serde ignores fields it does not know. So
`PROTOCOL_VERSION` deliberately does not move — the remote-server
handshake gates on it, and a bump would turn away older servers over a
field they can safely ignore. `protocol.rs`'s compat test pins both
directions.

Also: deleting an SSH profile now drops the key-passphrase entries no
other profile still references, which is what `delete_profile_confirmed`'s
own comment already claimed to do but only ever did for the password.

* fix(ssh): stop replaying a stale password at keyboard-interactive (#487)

`try_keyboard_interactive` answered a password-shaped round from the
keychain, marked the stored password spent whether or not it had been
used, and returned on the first `Failure` — so the `MAX_ROUNDS` loop
never got a second pass with the stored password withheld. The same dead
secret went out on every reconnect and the user was never once asked to
type a different one; `ki_submit` always emitted `KeychainWrite::None`,
so nothing could clear it either.

`collect_ki_answers` now reports where its answers came from, and only a
round that actually sent the stored password spends it — which also fixes
an OTP-then-password flow that was refusing the stored password for no
reason, its first round having burned the allowance on a code. On a
rejection whose last round came from the keychain, and where the server
still offers the method, the request is started over with the stored
password withheld, so the next round reaches the prompt. That retry is
bounded twice over: the restart spends the stored password, so no second
restart can qualify, and the round counter it shares with the
info-request loop caps the method either way. The failure text now says
which of the two was turned down.

Scope, honestly: the only live scenario is auth mode Auto against a
server offering keyboard-interactive but not password, with a stored
password for that endpoint — a profile pinned to KeyboardInteractive gets
`password: None` and always prompts, and Password never tries KI. Whether
the symptom shows also depends on the server: OpenSSH ends a rejected
kbdint request with USERAUTH_FAILURE (symptom holds), while a device that
re-issues an InfoRequest in the same request already reached the prompt.

`AuthPromptKind::KeyboardInteractive` grows a `#[serde(default)]`
`stored_rejected`, same both-directions compatibility as `KeyPassphrase`'s
`rejected` and the same reason `PROTOCOL_VERSION` stays put. The sheet
shows the warning line and, on submit, forgets the rejected password.

That needed an endpoint the KI prompt does not carry, which also fixed a
bug next door: `raise_routed_auth` called `from_prompt(.., None, false)`,
so every routed password write was keyed to port 22 regardless of the real
port and the rejected self-heal could never fire there. `PendingAuth` now
carries the endpoint and the auto-supplied flag, read straight off the
route's `NativeSshSpec`.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-11 21:40:49 +08:00
l0ng-aiandl0ng-ai 16ef93693f fix(settings): confirm before forgetting a password other profiles share (#510)
"Forget Password" was a bare menu item: one click deleted the keychain
entry, with no confirmation and nothing said about who else it took
down. The entry is keyed by `user@host:port`, so two profiles that reach
the same endpoint — one direct, one through a jump host — share exactly
one secret, and forgetting from either row signed both of them out. The
notification even worded itself by endpoint while the action hung off a
single profile's menu.

It now asks first, the way deleting a profile does, and when the
endpoint is shared the dialog names the blast radius instead of leaving
it to turn up at the next connect on a host nobody touched.

Deleting a profile stays conservative on purpose — the menu that could
remove the secret is about to disappear — so the two paths keep their
different policies. What they no longer keep is two copies of the
"is this endpoint shared" question: `profiles_sharing_endpoint` is now
the one place that answers it, and it has the test.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-11 21:22:52 +08:00
l0ng-aiandl0ng-ai 01969ef6bb fix(settings): say what an ssh config import added, updated and could not keep (#515)
Importing from ~/.ssh/config was silent in three ways. A missing or
unreadable file did nothing; a file of nothing but `Host *` and `Match`
did nothing; and a successful import did nothing visible either, so the
only way to learn what had happened was to go count the host list.

Options tty7 has no field for — IdentityAgent, CertificateFile,
AddKeysToAgent and the rest — were dropped without a word. They still
are, because there is nowhere to put them, but the import now names
them and the hosts that set them instead of pretending they were kept.

Parsing keeps each keyword's original spelling alongside the lowercased
form it matches on, and `option_is_supported` is the one list both the
resolver and the report read, so the two cannot drift. Ignored options
are grouped per Host block rather than per resolved alias: a keyword
under a two-alias `Host` line is one omission, not two, and `Host *`
noise stays out of the report entirely.

`merge_imported` now returns added/updated/unchanged, comparing the six
fields it writes before it writes them — so re-importing an unedited
file reports six hosts unchanged rather than six updated.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-11 21:22:36 +08:00
l0ng-aiandl0ng-ai 9c9a18f410 fix(settings): refuse a half-filled SSH profile instead of saving it (#511)
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.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-11 21:22:19 +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-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 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 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 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
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 686bd0bcea feat(ui): the interface has a font size of its own
The detail panel carried a private run of pixel sizes — 12 for body,
11.5/11 under it — which put its primary text at the size the rest of the
window uses for secondary text, so it read a step smaller than the sidebar
beside it. Its mono values sat at the same px as their sans labels, where a
larger x-height makes them look a size bigger, so a row read as two sizes
instead of one line. `forwards.rs` and `sftp.rs`, both drawn inside that
panel, had copied the same numbers.

Put the panel back on the rem ladder the rest of the chrome already uses,
with mono a notch under the sans it pairs with, and make the rem itself
settable: `ui_font_size` defaults to gpui's own 16, so an existing config
renders unchanged, and every window's root sets it, which reaches the whole
interface at once. The terminal grid is absolute px from `font_size` and
does not move — a display that is not Retina can now have bigger chrome
without touching the text in the panes.
2026-08-09 13:09:35 +07:00
l0ng-ai fee48a4c99 Merge origin/main into integration/polish
main shipped v26.8.2 and 15 fixes while this branch was open. Resolved:

- zh: main's #417 decided the background process is called "server" in
  Chinese, and that decision is newer than this branch's "服务器" — took
  it, kept this branch's typographic quotes around {machine}, and
  whitelisted SettingsServer in the new every-key-is-translated test,
  since the zh heading is now that English word on its own.
- presets.rs: this branch factored main's inline `clear` closure into
  Theme::clear_ink / ansi_seed; same arithmetic, so kept the methods.
  #400's border and caret floors and #413's legible-palette flag both
  survive untouched.
- app.rs: took main's Option-typed `alive` argument, kept this branch's
  note on why a dropped tab is worth a sentence.
- README / docs: agent count is now exactly 18 with Oh My Pi, so the
  precise number replaces both "17" and "~18"; the zh feature doc keeps
  its translated menu names and gains Oh My Pi in the fork list.

Six keys main added are gone because the surfaces that used them were
rewritten here: the home screen's relative time now runs to years, hook
failures name install vs remove, Full Screen left the View menu on
purpose (AppKit adds its own), the SFTP filter says "search files", and
the settings index titles its CLI row by its own label.
2026-08-09 12:28:42 +07:00
l0ng-ai a53d9ad3e4 fix(settings): let the Advanced disclosures light up under the pointer
Jump host, Port forwarding and Advanced fold and unfold on click, but
only changed the cursor. Every other clickable row on the page answers
the pointer with a band — the settings rows, and the SSH group header,
whose comment already explains it wants the same 8px inset the rows
hover with. These three now take the same band and radius.
2026-08-08 21:44:18 +07:00
l0ng-ai 98be4e5ece fix(ui): give three faded states one value each instead of two
A sweep of every .opacity() call turned up three places where one
meaning had two strengths:

- A Dynamic forward has no target, so the target endpoint fades. The
  rules editor in Settings faded it to 0.35 and the live Forwards panel
  to 0.4 — the same row, drawn twice. Now a named constant both use.
- switcher.rs destructures (fg, muted, dim) in five render functions;
  four dimmed to 0.7 and render_group_header to 0.75, so a header sat a
  step brighter than the rows under it for no reason.
- The tinted-border notice box is 0.4 in the window, the Forwards panel
  and the themes-rejected note, and was 0.35 in the switcher.
2026-08-08 21:23:53 +07:00
l0ng-ai 0eeea07571 fix(settings): round the theme card the way the theme panel rounds it
The Appearance page and the theme panel draw the same object — a theme
preview, its name, a border — and drew it differently. The page card was
rounded_xl, the only 12px corner in the app, while the panel card that
opens from it uses TRACK_RADIUS. Worse, the preview inside the page card
kept its own 8px corners inside a 12px border, the concentric mismatch
that rounding::inner_radius exists to prevent; the panel already insets
its copy of that same preview.

Both now use TRACK_RADIUS with the preview inset by the hairline, and
theme_preview names the token instead of repeating its value.
2026-08-08 21:08:59 +07:00
l0ng-ai ca8af9d169 fix(settings): tell the theme picker when its filter matched nothing
Type a word no theme is named after and the panel emptied out under its
own search box with no explanation. Every other filter in the app
answers: the settings search, the tab sidebar, the SSH host list, the
switcher and the palette all say so, and four of them already say it
with this exact string.
2026-08-08 20:56:39 +07:00
l0ng-ai bfc0937bf6 fix(settings): show the reader the note that says nothing matched
"Nothing matches X." is drawn at the top of the content pane, but the
reveal that carries the page to a search result deliberately skipped the
case where the count was zero — the one case where the thing worth
seeing is pinned to the top. Search from halfway down a page and you got
the untouched page the note exists to explain, with the note itself off
screen above you.

The note now claims the same scroll anchor the rows use, and the reveal
fires when the query matched nowhere as well as when it matched here.

Checked on screen: scrolled to the bottom of Terminal, typed a word that
matches nothing, and the page came back up to the note.
2026-08-08 20:50:03 +07:00
l0ng-ai 02f556d597 fix(settings): make background images findable from the settings search
"background image" is a headline feature — the README sells solid,
gradient and image backgrounds — and searching for it found nothing.
Worse than nothing: typing it walked the page to About, because
"background" on its own matches Download updates in the background, and
the last two words then dropped the count to zero and left the reader
stranded there with no badge and nothing dimmed.

Background image and Image opacity now carry index entries, and the
Custom themes intro gains the same keywords so the query has something
to light in the state where those two rows do not exist yet — which is
the state you are in before you duplicate a theme, next to the button
that gets you there.

The badge counts index entries, not rendered rows, so it can read (3)
while one is on screen. That is how the already-indexed ANSI colors
entry behaves too; not changed here.
2026-08-08 20:44:43 +07:00
l0ng-ai 80a61aa494 fix(settings): carry the page to a search hit that is a section header
Only settings_row could claim the scroll anchor, so a query whose only
match on the page is a section heading moved nothing. Search "ansi" from
the bottom of Appearance and every row greys out while the one thing
still lit — ANSI colors — stays above the fold; "how shells work" and
"keybindings" are in the same position.

Section headers and intros now claim the anchor the same way, through a
first_hit_anchor helper the rows share, so the first match on the page
is the first match whatever kind of element it is.

Checked both paths on screen: "ansi" from the page bottom now lands on
the ANSI colors heading, and "smooth" still lands on its row.
2026-08-08 20:27:30 +07:00
l0ng-ai 0967015ff0 fix(settings): give the add-host button the tooltip its neighbour has
The + and the ⋯ sit side by side above the Hosts list at the same size
and weight; only the ⋯ named itself on hover. It now says "New host",
the same words the form it opens puts in its title.
2026-08-08 20:16:43 +07:00
l0ng-ai fc09bb0a92 fix(settings): keep the theme editor's color labels in the current language
The editor stored each row's label as a String when it was built, so
Background, Foreground, Accent, Cursor, Selection and the sixteen ANSI
rows kept whatever language was set the moment you clicked "Duplicate
to edit" — switching language a few rows further down the same page
left them all behind.

Look the label up from the ThemeEdit at render time instead of caching
it. Verified by switching language with the editor open: the rows now
follow.
2026-08-08 19:54:21 +07: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-ai 37659990ff fix(settings): put the last four settings into the search index
A row the index has no entry for is invisible to search, not just
unkeyworded: `section_match_count` only counts index entries, and
`settings_row` leaves a page alone entirely when that count is zero. So
searching "smooth" or "nightly" produced no badge, no navigation and no lit
row — the setting was simply not there.

Four rows were in that position. The whole Updates group on About — update
channel, check on launch, download in the background — which is the part of
that page people go to the search box for, and Smooth scrolling on
Terminal, sitting between Scroll speed and Focus follows mouse, both of
which were findable.

`previously_unsearchable_settings_are_findable` grows five queries that
each landed nowhere before.
2026-08-08 19:05:07 +07:00
l0ng-ai bb4cc7793f fix(chrome): give the last three icon-only buttons their tooltips
Every other bare glyph in the app names itself on hover — the code panel's
close says "Back to Terminal (Esc)", the diff overlay's says "Close Diff
(Esc)", the SFTP tray's says "Dismiss", a workspace row's ellipsis says
"More". Three were still silent, and each one has a twin that is not:

- the themes panel's close, beside two closes that both name their key;
- the ellipsis on a machine group in the switcher, beside the identical
  ellipsis on a workspace row one line below it;
- the × that removes a port-forwarding rule, beside a + that says "Add
  rule".

The switcher's takes the string its twin already uses. The other two get
one each.
2026-08-08 18:48:17 +07:00
l0ng-ai bce4a384cb fix(settings): file Compression with the algorithm lists it belongs to
`Algorithms` holds five lists — kex, cipher, mac, hostkey, compression —
and the SSH form showed four of them under Algorithms and the fifth under
Connection, next to the keepalives. There it was labelled just
"Compression", which in an ssh_config is a yes/no, over a description
reading "Comma-separated (blank = default)." — an instruction that made no
sense for the switch the label implied.

It moves up to its siblings and takes their naming: "Compression
algorithms", beside "Host-key algorithms". Connection is left with the two
keepalives and the connect timeout, which is what that word means here.
2026-08-08 17:57:09 +07:00
l0ng-ai bf7b35bdf2 fix(settings): give the SSH form one word for "nothing set"
The disclosure headers on a host print what is inside them — a jump host's
name, "2 rules opened with the connection", "algorithms / keepalive /
proxies / X11 / login scripts". Two of them print emptiness, and they did
not agree: Jump host said "(none)" and Port forwarding said "none", one
above the other.

They share the parenthesised one now, which is the one that cannot be
mistaken for a hostname someone typed. `SettingsNoneLower` had no other
caller and goes with it.
2026-08-08 17:49:32 +07:00
l0ng-ai 270598e1f1 fix(settings): give the settings pages the scrollbar the rest of the app has
The tab sidebar, the file tree, the right panel and the SFTP browser all
run their scroll area through `with_vertical_scrollbar`. The settings
pages — the longest scrolling surfaces tty7 has, several viewports of them
on Appearance and Terminal — had no bar at all: no thumb, no sense of how
far down the page you were or how much was left. The SSH host list, the SSH
form and the theme picker's list were the same.

All four now carry the shared bar. It follows the OS "show scroll bars"
preference like every other one, so nothing appears for anyone who asked
for scrollbars to stay hidden.

The scroll areas move inside a wrapper that holds the constraints they used
to hold themselves, and two of those do not survive the move. `min_w_0` on
a column child means the *cross* axis, and carrying it inside let the SSH
form's label column shrink to nothing while the toggles stayed put; it
belongs on the wrapper, which is still the row item it always was. And the
wrapper has to take its width explicitly rather than by stretching, or the
`w_full` inside has no width to be a percentage of — which is how the
settings reading column lost its 640px cap on the Chinese page, exactly the
way 00ad4eb first found it.
2026-08-08 15:57:21 +07:00
l0ng-ai 3631c534ac fix(settings): take the page to what the search found
Typing in the settings search dims the page and lights up the rows that
match, and the nav badges count them per section. But nothing moved the
page. Search "cursor" and Appearance says (2) while the screen shows a
greyed-out Theme section: both matches live under Cursor, most of a page
below the fold, with no sign they are down there. On Terminal the single
match sat right on the bottom edge.

The content pane now carries a ScrollHandle, and the first matching row on
the page claims a ScrollAnchor on it. Whenever the query changes, or a
section is opened with a query already live, the page scrolls that first
match into view — one row per page, so the view never travels past matches
above it. Clearing the query leaves the page where the reader left it.
2026-08-08 13:57:07 +07:00
l0ng-ai 943d69501b fix(settings): start every SSH row's title on one column
The host rows carry a liveness dot in front of the title; Defaults, which
has no session to be live, carried nothing and skipped the space too. Its
title and subtitle therefore began 14px to the left of every host title
under them — the one row at the top of the list, hanging off the column the
rest of the list holds.

The gutter is now unconditional and simply stays empty on the rows that
have no dot to put in it. The group headers were 2px off the same column
for the same kind of reason: 6 + 10 + 4 instead of 8 + 6 + 8. Their inset
becomes the 8px the rows already hover with, which lands them on it.
2026-08-08 13:41:28 +07:00
l0ng-ai 9a07616e82 style: put the branch back under rustfmt
`cargo fmt --check` is the first job CI runs, and main passes it. Three
files on this branch no longer did: a long method chain in the settings
shell picker, the card-width clamp the switcher gained when it learned to
fit its window, and a Chinese plural that shrank below the line limit when
its backticks came off. None of it changes behaviour — it is what rustfmt
would have written, restored so a merge does not fail on formatting.
2026-08-08 13:27:49 +07:00
l0ng-ai e893ec1dd1 fix(copy): stop printing Markdown backticks at the user
Nine strings wrapped a command in backticks — `tty7`, `git diff`, `git
status`, `{alias}` — and nothing in this app renders Markdown, so the
marks landed on screen as literal characters. The Settings label read
"Install the `tty7` command on PATH", and its description wrapped mid-token
and left a lone backtick opening a line.

Everywhere else the same copy writes known_hosts, ~/.ssh/config and
"git diff →" bare, so the backticks go and the minority follows the
majority. The one that quotes a name — the missing ~/.ssh/config alias —
takes the quotes the rest of the app already uses for a name: "..." in
English, “...” in Chinese.
2026-08-08 13:26:52 +07:00
l0ng-ai 3ae1a931e8 fix(settings): give the shell picker's chevron the 24px target the rest of the chrome has
An icon-only xsmall button is a 20x20 box, under the 24x24 desktop floor
in WCAG 2.5.8 that hit_target exists to hold. The glyph is unchanged; only
the box it sits in grows.
2026-08-08 12:36:56 +07: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-ai 78501b86aa fix(settings): stack a row instead of squeezing its label to a letter per line
The control never shrinks and the label column must keep min_w_0 or long
descriptions stop wrapping, so on a narrow pane the label was squeezed to
nothing: at 600px the Terminal page rendered "Program" as a vertical
column of single letters, and the SSH form did the same below about
1000px, which 4275bb1 left open as the real remaining bug.

flex_wrap was the wrong answer — it let the label column size to its own
description, which then ran out past the row on every wide page. Measure
instead: render_settings knows the viewport and which page it is drawing,
so it can say how much width a row will actually get, and the row lays
itself out side by side or stacked from that. Nothing moves above the
breakpoint.
2026-08-08 12:09:58 +07:00
l0ng-ai e6240e899c fix(settings): let the Keybindings preset row wrap instead of shoving its control off
The Preset and Prefix rows on the Keybindings page are hand-rolled rather
than built from settings_row, and they were missing the one thing that
makes every other row survive a narrow window: a label column allowed to
shrink. At 560px the tmux description ran off the right edge and took the
Default/tmux control with it. Same gap_8 as the shared helper, so nothing
moves on a wide window.
2026-08-08 11:57:11 +07:00
l0ng-ai 00ad4eb62f fix(settings): keep the theme card inside the reading column
On the Chinese and Japanese pages the theme card ran the full width of
the window while every other row, and the dividers between them, stopped
at 640 — and its "Change theme" chevron, which the card pushes to its
right edge, ended up stranded in the middle. The column carries
`w_full` under `max_w(640)`, and with nothing definite to resolve the
percentage against it falls back to what the content measures, where the
card's own row wins over the cap. Give the padding box a width.
2026-08-08 11:36:02 +07:00