Commit Graph
517 Commits
Author SHA1 Message Date
thomas c7888e2c95 Merge remote-tracking branch 'origin/main' into feat/daemon-workspace-tree 2026-07-29 21:18:49 -07:00
ayamirandl0ng-ai 183b5e4950 fix(terminal): detect links across wrapped and hard-newline rows (#258)
* fix(terminal): detect links across wrapped and hard-newline rows

A URL that spans terminal rows was truncated at the first row edge, so
Cmd-hover underline and Cmd-click only saw the first line. Resolve links
over the joined logical line instead: soft-wrapped rows were already
stitched, and a new hard-wrap bridge joins rows a program split with a
literal newline when the row is full to the edge with a link char that
continues into the next row. Double-click smart-select keeps its word
boundaries and never bridges. HoveredLink now spans grid points across
rows and the underline paints every covered cell.

* fix(terminal): never bridge a hard newline into a URL authority

The hard-wrap bridge joins two rows when the first is filled to the right
edge with a link char and the next opens with one. A hard newline carries no
signal about whether the producer split a URL, so a *complete* URL ending
exactly at the right edge is bridged onto the next row's first token
(`.../a` + `README.md` resolves as `.../aREADME.md`). That false positive is
accepted: the head of a genuinely split URL is itself a valid URL, so there
is no reliable test to tell the two apart, and the address bar shows the
mistake.

The same accident promoting the *second* row to the authority is not
acceptable. `https://good.com` + `@evil.com/x` parses as userinfo per
RFC 3986, so the real host becomes `evil.com` while the hover underline
still reads `good.com` — a phishing hop wearing a trusted label. Refuse to
bridge into a `@`. Soft wraps are unaffected: there the terminal folded one
logical line, so the continuation is certain and a userinfo URL must still
resolve whole.

Also drop a dead `c != ' '` guard (`is_url_char(' ')` is already false) and
apply rustfmt, which CI enforces as a required check.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-30 11:31:30 +08:00
thomas ad9a0c0f81 fix(core): review follow-ups on the daemon-owned tree
Nine findings from a review pass over the branch. One commit because
they cross the same files, and splitting them would leave an
intermediate that does not build on Windows.

- A dropped delta announced a LayoutResync and then delivered the
  backlog behind it. The queue is FIFO, so everything still in it is
  *older* than the gap: the peer re-pulled on the notice and was then
  walked back through history it had already left — TabRestructured
  restoring the shape a tab used to have, with window and mirror
  agreeing on the stale answer so nothing recovered a second time. The
  forwarder now drops the superseded queue and sends the resync in its
  place.

- Pane facts persisted the whole document, with an fsync, from the PTY
  reader thread — once per OSC 7, so once per prompt per pane — while
  holding the lock that orders every other client's edits. A shell
  looping over directories was a write per iteration. Observations
  (pane facts, workspace_touch) now take Persist::Soon: the delta still
  goes out at once, the file catches up within FACT_FLUSH_INTERVAL, and
  the daemon flushes on the way out. The layout itself is never
  deferred.

- An ordinary output chunk paid two AgentFacts clones and a
  clone-to-compare for facts it could not have changed. Gated on the
  signals that can move one, and the compare no longer clones.

- machine.json was created 0644, naming every workspace's directories,
  the SSH user and host of every native-SSH pane, and each agent's
  session id. It is written owner-only from the first instant the final
  name exists, and a second corruption no longer overwrites the rescue
  copy of the first.

- Windows had no control listener, so on the one platform where the
  tree is the only layout store, tabs did not come back at all. It now
  serves the dialect over the transport its pane socket already uses: a
  loopback listener whose port and 256-bit token live in a user-private
  control.port beside daemon.port — its own token, not the pane
  endpoint's — refusing to rebind over a live one, since binding is
  what writes the marker. run_daemon and the GUI's local link are one
  code path again.

- Workspace names and paths came only from the machine's mirror, so a
  laptop shut since Friday listed every row as "Untitled" with a blank
  subtitle, in the picker whose whole job is offering workspaces on
  machines that are asleep. WindowView carries the label and subject
  the machine last gave, stamped on save and on detach; the tree still
  wins whenever it answers.

- liveness_of read "the mirror has not been pulled yet" as Stopped,
  which tells the user their sessions are gone on the strength of our
  own ignorance. Unknown is what that state is for.

- A WorkspaceRemove that never reached its machine was a debug line,
  though the client had already forgotten the workspace. It is now a
  warning that says what was left where.

- MachineMirrors::install landed a pull without a repaint; the two tests
  the record store's retirement took with it (a closed connection stops
  being a subscriber, concurrent connections can all write) are back
  against the tree; and CHANGELOG records the migration's one-time
  layout loss and the Windows gap this closes.

Suites green: tty7-core 675, tty7 819, tty7-server 9/5/3/3/51, fmt and
clippy clean. The Windows listener is unverified by a compiler here — a
C dependency in the tree blocks cross-checking from macOS — so CI's
Windows job is its first build.
2026-07-29 19:52:18 -07:00
l0ng-ai 12e6ce0082 fix(ui): converge the tree after adopting a delta-created tab
Adopting a TabCreated delta whose pane is dead on arrival attaches
nothing and spawns a fresh pane under a new id — and nothing on the
delta path saved afterwards, so the tree kept the dead leaf: other
clients saw a dead tab, and a relaunch would spawn a second successor
beside the leaked first. Reproduced end-to-end (external client creates
a tab with an unspawned pane; the GUI adopted it and the tree never
learned the successor's id).

One sync_window after a clean apply closes it: free when window and
mirror agree (the diff is empty), and exactly the PaneReplace that
spends the dead record when adoption had to spawn.
2026-07-30 02:01:06 +08:00
l0ng-ai 6b0bccd71e docs(core): drop the dead instance helper, the stale title field, and two doc lies
- local_daemon_instance() lost its last caller when the client-side
  pane-identity defenses were retired; deleted.

- DaemonVersion::instance's doc pointed at Workspace::daemon_instance
  (deleted with the record store) and claimed pane ids restart from 1 —
  no longer true of a tree-carrying daemon, which seeds its ids past
  everything the tree names. Rewritten to describe what the field
  actually backs now.

- PaneRecord::title claimed to label panes awaiting revival, but no
  code ever wrote it: the pane's title is a live foreground-process
  query at PaneInfo time, not state the facts path observes. The field
  is deleted (serde-compatible: unknown fields are ignored on read) and
  the decision recorded where it lived; revival labels derive from cwd
  and agent.
2026-07-30 01:47:44 +08:00
l0ng-ai d531e876cc fix(ui): a pure native-SSH tab is invisible to the tree, not held forever
Held means 'spawns are landing, wait before ordering' — but a remote
window's tab that is native-SSH through and through can never land: its
panes live in this client's daemon and are deliberately unnameable in
the remote machine's tree. Filing it as held made every diff return
before the ordering and active-tab passes, freezing tab order and
activation sync for the whole window for as long as the tab existed —
and a mixed tab whose last remote pane was closed kept its dead leaf on
the machine for ever, because the held id shielded the daemon tab from
the close.

Such tabs are now classified permanently invisible: not desired, not
held. Ordering resumes, and the mixed tab's daemon twin closes when its
last tree-visible pane goes. Pending leaves (a connecting spawn, an
empty slot) still read as held.
2026-07-30 01:44:59 +08:00
l0ng-ai 917735f3c0 feat(control): announce dropped layout deltas so lagged clients resync
A connection whose per-link delta queue overflowed lost an edit it will
never hear again — the server logged the drop, and the client mirrored
a tree it was no longer looking at until something else happened to
fail. The subscriber callback now flags the connection lagged, and the
layout forwarder sends the new ControlEvent::LayoutResync ahead of the
next delta it delivers (the flag is only ever set with a full queue
behind it, so the announcement never waits on a quiet tree). The client
answers by re-pulling the machine mirror and resyncing every window on
that machine — the same recovery an unappliable delta already uses,
announced instead of stumbled into. WatchOverflow is the precedent.
2026-07-30 01:42:04 +08:00
l0ng-ai bc5563a0d1 fix(core): machine-store hardening around seeds and unreadable files
- A PaneSeed entered the registry live:true unconditionally. A pane
  that died between its spawn and its adopting operation had its death
  observation dropped (note_pane_facts ignores panes the tree does not
  hold), and nothing ever flipped the record back — the leaf claimed a
  live pane forever and revival was never offered. The daemon now
  installs a liveness probe on the store (registry-backed), consulted
  at registration; without one (tests, clients) the seed is trusted.

- seed_ids_past computed max + 1, which panics a debug daemon at
  startup when the persisted tree names u64::MAX. saturating_add parks
  the counter at the ceiling instead.

- load_machine quarantined an unparseable file but not an unreadable
  one: a read failure logged, started empty, and the first mutation
  overwrote the very file that could not be read. Read failures now
  quarantine too — by rename, since a copy would need the read
  permission that just failed.

Also de-flakes the pre-existing spawn_writer test: the first write into
a freshly-closed socket can succeed before the kernel processes the
close, so the poll loop now keeps the writer fed until a write fails.
2026-07-30 01:38:56 +08:00
l0ng-ai 13c074fd2e fix(ui): apply ratio deltas in the server's clamp band
set_gui_ratio clamped to 0.1-0.9 while the server accepts 0.05-0.95, so
another client's 0.07 arrived as 0.1 — and the next save's ratio diff
pushed the rewrite back at the machine, silently moving their divider.
2026-07-30 01:32:57 +08:00
l0ng-ai 3a7d584b0c fix(ui): delta application survives pulls in flight
Three overlap bugs between the incremental delta stream and the full
pulls it has no ordering barrier with:

- A TabCreated straddling a pull was applied by both — the snapshot
  already carried the tab, and the delta inserted a second copy into
  the machine mirror and the window mirror, and rebuilt a second GUI
  tab whose attach stole the pane's single stream from the window
  itself. All three application sites now replace by id.

- A delta arriving while a window's prime/hydration was in flight was
  applied to the window even though the mirror side skipped it — a
  TabCreated landing in a still-empty window made finish_hydration
  read 'the user got here first' and skip adopting the tree, leaving
  the window with only the concurrently-created tab forever. Window
  application is now gated on the mirror being primed; the pull's
  snapshot carries the delta's effect.

- A prime answered after a newer cycle (hydration, desync, preemption)
  replaced it would install its stale tree over a mirror that had since
  advanced, and the next diff would re-emit the rollback as operations.
  Every cycle now stamps an epoch, and pulls landing under an old one
  are dropped.
2026-07-30 01:32:13 +08:00
l0ng-ai 70a0b29224 fix(ui): preempted windows stay passive and take-back rebuilds from the tree
Two halves of the same takeover contract were broken.

A preempted window kept pushing: sync_window had no preemption check, so
a click on the read-only tab strip sent WorkspaceSetActiveTab against
the usurper's session, and the next save Full-diffed the stale layout —
rolling the usurper's edits back wholesale. sync_window now returns
early for a preempted workspace, and preemption itself drops the
window's queue, mirror and 'informed' licence (tree_sync::on_preempted,
shared with the delta path's existing reset).

Take Back never rebuilt: the recovery attach ran the ordinary IfEmpty
hydration, which skips any non-empty window — and a preempted window is
by definition non-empty with the pre-takeover layout. retry_now now
marks the workspace as reclaiming, and finish_attempt rebuilds marked
(or still-preempted) windows via Adopt::Replace, honouring the 'take
back re-pulls whole' promise the delta path documents.
2026-07-30 01:28:25 +08:00
l0ng-ai 1792bb846a fix(control): bump both dialect versions and gate tree verbs on the machine-tree bit
The tree migration deleted four control verbs and added seventeen, but
CONTROL_VERSION stayed at 2 — two builds that cannot understand each
other's requests would have shaken hands as equals. It is now 3, with
the history entry the file's format asks for.

PROTOCOL_VERSION moves to 4 for the service change underneath: a
pre-tree 'tty7 --daemon' has no control listener at all, so a GUI from
this build silently adopting one connects its control link into the
void forever and every window hydrates from a tree that never answers.
The bump routes that meeting into ensure_running's existing
keep-or-restart prompt.

Clients now also consume the machine-tree capability bit before any
tree traffic: a connected peer without it (a server with no home
directory keeps serving files and panes) classifies as a distinct
'unserved' state that is logged once and skipped, instead of a refused
round trip per operation.
2026-07-30 01:21:39 +08:00
l0ng-ai ec94fc5540 fix(ui): read a deleted workspace's kill list before the removal blanks the mirror
delete_workspace fired WorkspaceRemove first, and fire_workspace_op folds
the removal into the machine mirror synchronously on its way out — so the
kill list stop_workspace_keeping then read off that mirror was always
empty, and 'Delete Workspace' ended zero of the sessions its confirm
prompt promised to end. The kill list is now read before the op fires,
and both destructive paths receive it explicitly so the ordering is a
signature rather than a convention.
2026-07-30 01:15:57 +08:00
l0ng-ai 1045e1d07c fix(ui): kick every local window's sync when the local link comes up
A window built while the local control link was still dialing parks as
Unprimed { dirty } — start_prime's unreachable arm leaves the retry to
"the reconnect-triggered save", but the local link supervisor never
triggered one. On a first launch (window built before the auto-spawned
daemon binds its socket) nothing else re-enters sync_window until the
next structural change, so quitting before one loses the window's
layout: the machine never heard of it.

Reproduced end-to-end on a scratch daemon: fresh launch, no user
action, quit — the relaunch came up empty. With the link supervisor
calling tree_sync::on_link_up on connect, the same launch syncs the
tree within one pump tick.
2026-07-30 01:04:43 +08:00
l0ng-ai 978e1cc67d docs(core): finish pointing the last session.json references at views.json 2026-07-30 00:42:16 +08:00
l0ng-ai bdb53a7c59 fix(ui): a tree-driven tab rebuild keeps the native-SSH split it cannot name
A native-SSH pane opened inside a remote workspace's window runs in
this client's own daemon and is deliberately absent from the remote
machine's tree (its local id would collide with an unrelated remote
pane). The TabRestructured rebuild therefore had no leaf for it and
dropped its view on the floor: the local session kept running,
invisible from every surface — a true orphan only the daemon's log-only
sweep would ever mention.

The rebuild now sets such leaves aside while harvesting reusable views
and appends each back as a fresh half-and-half split on the right once
the tree's own panes are built. The old split geometry is unknowable
from the delta (the tree never held it), so the appended shape is the
one a split created it in; the next save changes nothing, because the
diff already lowers a remote window without its ssh leaves.

The resync path (a delta that fails to apply, a replaced server) still
rebuilds the whole window from the tree and drops such views — that
path discards every view it has by design, and is left as a known
residual. TerminalView grows a test-only ssh-marked pane constructor so
the kept-split property is pinned by a gpui test.
2026-07-30 00:41:10 +08:00
l0ng-ai 9cac4b863b refactor(ui): rename RemoteConnections to HostLinks
Purely mechanical, plus the doc sentences that carry the model: the
table holds one control link per machine, and the local machine is a
machine like any other — its link just lives in its own global
(LocalLink) because it is in-process rather than wire-backed. The old
name framed the table as remote-only plumbing, which the tree
migration made false in spirit: local and remote windows speak the
same operations over whichever link their machine answers on.
2026-07-30 00:37:26 +08:00
l0ng-ai 382a46ac29 refactor(server): retire the opaque workspace record store
Clients stopped sending WorkspaceList/Get/Put/Delete when the tree
migration landed, so the coexistence scaffolding comes out:

- core::workspace_store is deleted. Attachment and the data-directory
  resolution (TTY7_DATA_DIR, XDG fallback chain) move into
  core::machine, which was already their only consumer; Attachment
  loses its vestigial serde derives (it never crosses disk or wire).
- The control dialect drops the four record verbs, the ReplyOk::Json
  payload they answered with, and the WorkspaceChanged event. Their
  serde names (and the workspace-store capability bit) are recorded as
  burned rather than reserved by any mechanism — the dialect has no
  numbered slots to hold, so a comment at each site is the guard, plus
  the handshake test asserting the bit never reappears.
- host::server loses Services.workspaces, the verb arms, the
  per-connection store subscription and its WorkspaceChanged forwarder,
  and the store half of attach/detach/teardown. Attachment data now
  lives solely in the tree: a workspace the tree does not list records
  no data half (the registry's live handles still move, so takeover
  behaviour is unchanged), and it appears the moment the workspace
  does. Services::with_workspaces/and_machine collapse into
  with_machine; control_services becomes a single match.
- The attach/takeover tests move onto MachineStore wholesale, attaching
  to workspaces created in a real tree; the record-store round-trip and
  fan-out tests go (tests/machine_tree.rs has carried the tree
  equivalents since the verbs landed), and tests/workspace_store.rs is
  deleted with the serde_json dev-dependency that existed only for it.
  machine.rs gains the two guarantees the old suite held uniquely: an
  attachment dies with its workspace structurally, and the default path
  resolution ends at the documented file.
- The GUI's dead WorkspaceChanged arm and every stale doc reference go.
2026-07-30 00:35:49 +08:00
l0ng-ai 86c711b2ba refactor(ui): client persistence shrinks to pure window views
The client file stops carrying layout. session.json's Workspace — id,
name, a whole embedded Session, geometry, open, last_active, host —
becomes WindowView { id, window, open, last_active, host } in a fresh
views.json (no migration by design; an old session.json is simply
ignored, and its panes revive from the machine tree like any daemon
restart). Everything the embedded layout used to answer already moved
to the per-machine mirror, so this deletes the write half:

- WorkspaceStore::claim answers only the id; record shrinks to
  record_geometry. claimable_session / record_session — the
  reachability-gated layout cache — go entirely, and with them the
  one-time empty-tree import in finish_hydration: with no cached copy
  there is nothing to import, and the machine answering "no tabs" is
  the layout.
- The user-set name is purely the machine's fact now. rename /
  rename_locally leave the store; the chip and switcher renames fire
  WorkspaceRename directly (tree_sync::rename_workspace), the
  WorkspaceRenamed delta needs nothing from the window because the
  mirror already applied it, and WorkspaceCreate seeds no name.
- forget_pane_ids / blank_pane_ids and the layout-derived getters
  (display_name, dominant_repo, first_cwd, pane_count, pane_ids) are
  deleted with their tests — each had grown a mirror-side twin.
- switch_workspace always hydrates: with the tree as the only layout
  source, restore-off governs what launch comes back to, not what a
  deliberate switcher pick shows.

The retired opaque record store loses its one test that asserted its
file parses as a client Workspaces document — that coupling is the
thing this migration ends, and the store itself is next to go.
2026-07-30 00:00:37 +08:00
l0ng-ai 2b1ad406f7 feat(ui): a per-machine mirror of each daemon's tree feeds the read surfaces
The switcher, the Window menu, the title bar, the rename seeds, the
stop/delete confirmation and the liveness sweep all answered their
questions (display name, subject path, pane ids, pane count) from the
client's cached copy of the layout. The machine's tree owns the layout
now, so a new per-host MachineMirrors global holds each machine's last
pulled tree — filled by a MachineGet whenever a control link comes up
(and for free off every hydration, which already pulls the whole
machine), advanced by the same Layout delta stream the windows consume,
plus explicit notes for this client's own operations, which origin
exclusion keeps out of that stream.

The readers move over wholesale. A machine not pulled yet reads as
not-knowing rather than a stale guess: pickers show the shared fallback
for a beat (against the local daemon the pull lands within a frame),
and the pane-count prompt says the machine could not be asked instead
of counting against a cache. tree_display_name moves out of the remote
picker into the mirror as display_name_of — it was always the tree
flavour of Workspace::display_name, and now everything shares it.

This is the read-model half of retiring the client's layout cache; the
persistence shrink to pure view state follows on top of it.
2026-07-29 23:44:01 +08:00
l0ng-ai e931323f30 fix(ui): close the review findings on the tree migration
Review fixes, worst first:

- Pane ids never alias across daemon restarts: the pane registry seeds
  its counter past everything the persisted tree references. A fresh
  process minting from 1 handed new shells ids that dead leaves still
  claimed — the tree marked the wrong pane live, revival stalled forever
  on 'already part of this machine's tree', and an attach by the stale
  id stole another workspace's stream. Ids are names now, not slots.
- An empty window only licenses WorkspaceRemove once it is *informed*:
  a window whose hydration has not answered is empty because it is
  waiting, and closing or swapping it mid-pull was deleting populated
  trees. Remote workspaces also hydrate regardless of the restore
  setting — their panes are running sessions, not a saved layout, and
  the restore-off swap used to open them empty-and-authoritative and
  close every tab on the machine.
- Tabs whose panes are all still spawning are *held*, not pruned: they
  are invisible in the desired tree without being absent, and the Full
  diff was closing them (spending the records the landing spawns'
  PaneReplace needed) on every remote revival.
- A preempted window stays passive under deltas: applying the usurper's
  TabCreated/TabRestructured attached to their fresh panes and stole the
  streams they were typing into. The mirror is dropped instead; taking
  the workspace back re-pulls it whole.
- Delta TabClosed tracks the active tab by identity (closing a tab to
  the left no longer shifts focus and pushes the wrong active tab back).
- The hydrate/resync path drops the op queue like desync does, so ops
  computed against an abandoned mirror cannot drain after the snapshot.
- A rebuilt remote tab no longer matches a native-SSH leaf's *local*
  pane id against remote ids; delta-applied ratios clamp to the GUI
  band; async completions use get_mut so a forgotten window's sync state
  is not resurrected.
2026-07-29 23:24:25 +08:00
l0ng-ai d698490283 docs(core): drop a stale reference to the retired record verbs 2026-07-29 23:02:24 +08:00
l0ng-ai 642518696f refactor(ui): a local daemon restart rebuilds from the tree too
The tree file survives the restart and the fresh daemon force-clears
every pane's live flag, so the resync path already expresses exactly
what the hand-rolled saved-session rebuild did: every leaf revives as a
fresh shell in its recorded cwd with its agent resumed. The pull waits
out the local link reconnecting to the fresh daemon.
2026-07-29 23:01:28 +08:00
l0ng-ai 6bbf533d7e refactor(core): retire the client-side pane-identity defenses
The machine tree made this whole family unnecessary, so it goes rather
than lingers: daemon_instance stamps (a restarted daemon's tree says
live=false about every pane — a fact, where the stamp was a heuristic),
forget_stale_pane_ids on both layers, dedupe_pane_ids (the daemon
refuses a pane appearing twice in its tree, so there is no duplicate to
mop up client-side), the claim/record instance plumbing, and the
whole-record halves of the storage split (to_remote_json,
apply_remote_json, REMOTE_OWNED_FIELDS, CLIENT_OWNED_FIELDS, and the
store's apply_remote / remote_payload), together with their tests.

forget_pane_ids stays for now: it clears the client's cached copy, which
still serves as the one-time import fallback until the view file slims
down to pure view state.
2026-07-29 22:59:02 +08:00
l0ng-ai 6feba961e0 feat(ui): remote workspaces read and write the machine tree like local ones
Local and remote are now the same shape end to end. A remote workspace
opens empty unconditionally (connected or not) and is filled by the same
tree hydration a local window uses; the connect supervisor's landing
replaces the opaque-record refresh with it — a blinked link relinks the
pane streams and hydrates whatever opened empty meanwhile, a replaced
server process resyncs the window from the tree, whose force-cleared
live flags are what make every leaf revive. The remote picker lists
workspaces from MachineGet, deriving names from the tree the way a
local workspace derives its own; creating one lets the hydration's
WorkspaceCreate mint it on the machine; the record push, pull, refresh
(WorkspaceChanged) and remote delete paths are gone client-side.

Windows that have not yet seen their machine's tree sync additively: a
window that opened empty ahead of its pull may add tabs but never prunes
ones it has not displayed, so its ignorance can no longer read as 'close
everything' — the diff takes an explicit scope, and only hydration (or a
deliberately authoritative open, like restore-off) grants the full one.
2026-07-29 22:54:49 +08:00
l0ng-ai 31e690cde7 feat(daemon): report panes the machine tree no longer references
With the tree now populated by clients' semantic operations, the daemon
can finally see panes nothing references. A periodic sweep reports them —
log-only, deliberately: an unreferenced pane is not proof of a leak (a
native-SSH pane opened inside a remote workspace's window runs in this
daemon while belonging to the other machine's tree), and reclaiming one
wrongly kills a session the user is looking at. The sweep's interval
doubles as a grace period: a pane is reported only after being
unreferenced across two consecutive looks, so an adoption still in
flight is never flagged. Reclamation can be layered on once the log has
shown the false-positive rate is zero.
2026-07-29 22:40:06 +08:00
l0ng-ai 71a74ba936 feat(ui): live windows apply the machine's incremental layout deltas
The pump's event drain now lands ControlEvent::Layout instead of debug-
logging it: each delta advances this client's mirror (by the same
surgery the server ran) and then the live window showing the workspace —
renames, regrouping, moves, active-tab changes and ratio drags in place;
TabCreated by building the tab and attaching its (writer-spawned, so
live) panes; TabRestructured by rebuilding the one tab while reusing the
views of panes the window already shows, because re-attaching a pane
this window holds would steal its own stream. Origin exclusion means
every delta arriving is another client's edit, and applying it to window
and mirror in one step leaves the next local diff with nothing to echo.

A delta that will not apply cleanly — a tab the mirror never heard of, a
drifted window — falls back to re-pulling the workspace and rebuilding
the window from the authoritative tree, the same single recovery path
every other failure already uses.
2026-07-29 22:37:43 +08:00
l0ng-ai 779a32805d feat(ui): local windows restore by asking the daemon's tree
The read path: opening a known local workspace no longer rebuilds from
session.json synchronously. The window opens empty and a background pull
(MachineGet — the workspace's structure joined with the pane registry,
which is where the revival facts live) rebuilds it the moment the daemon
answers; against the local daemon that is milliseconds, so the empty
state is effectively one frame — the same shape a remote workspace's
connect-driven rebuild has always had.

The lowering from tree to window is the revival decision: a leaf whose
pane record says live re-attaches by id, a dead one lowers to an id-less
leaf carrying the record's cwd, SSH spec and agent resume — the exact
shape that makes the existing builder spawn a successor and type the
agent's --resume. The save that follows diffs the successor against the
mirror and sends PaneReplace, spending the old record; revival needed no
op code of its own.

Restored tabs keep their daemon tab ids (SessionTab grows a never-
persisted tree_id), so the first save addresses the daemon's tabs instead
of churning them. A tree with nothing for the workspace falls back once
to the client's cached layout, whose adoption re-populates the tree
through the ordinary diff — the whole of the best-effort import.
2026-07-29 22:30:49 +08:00
l0ng-ai 1e605afb55 feat(ui): windows speak semantic tree operations for every structural change
The write path of the client migration: each window now keeps a mirror of
what the daemon's tree holds for its workspace, and save_session — the
funnel every structural change already passes through — diffs the window
against that mirror and sends the recovered operations (TabCreate,
PaneSplit, PaneClose, PaneReplace, TabMove, ratio and label ops) over the
workspace's control link: the LocalLink for this machine, the machine's
RemoteConnections entry otherwise. Consecutive saves differ by exactly one
user action, so the diff recovers that action rather than re-shipping the
layout; changes no single op expresses rebuild the affected tab whole,
matching the delta contract's own granularity.

The mirror advances by running the server's own tree surgery (PaneNode's
split/remove/replace are public now), and any disagreement — a refused op,
a dropped link — resolves by one shared recovery path: drop the queue,
re-pull WorkspaceTree, re-diff. Fresh spawns are invisible until their
pane id lands; land_pane's save is when their create goes out. GUI tabs
carry a client-minted TabId, and a primed mirror re-points tabs it
recognizes by their panes, so a rebuilt window adopts the daemon's tabs
instead of churning them.

Workspace-level facts ride along: focus touches, renames, and deletions
now reach the machine's tree too, and the divider drag finally persists
the ratio it lands on (it previously reached disk only as a passenger on
the next structural change).

session.json is still written in parallel; it retires with the read-path
migration.
2026-07-29 22:23:13 +08:00
l0ng-ai 7c3e42999f feat(control): let clients mint workspace and tab identities on create
A window names its workspace — in the registry, the view file, and any
operation it queues — before its first round trip completes, and the same
holds for a tab the moment the user opens it. Making the daemon the only
minter would force every client to hold its edits until a reply carried
the real id back. Ids are uuids, so a client-minted one is as unique as a
daemon-minted one; WorkspaceCreate and TabCreate now carry an optional
client id, keep it when it is free, and refuse a duplicate rather than
adopt it. Absent (older callers, tests) the daemon mints as before.
2026-07-29 22:08:45 +08:00
l0ng-ai 324e1d1543 fix(core): review hardening for the machine-tree foundation
Findings from a correctness review of the new daemon-owned tree,
applied together:

- A dead pane can no longer be resurrected in the tree by its own last
  output. On Windows the exit monitor reports the death while the
  reader is still draining ConPTY's buffered bytes, and the death
  report is latched; the reader's 'output is proof of life' publish now
  asserts liveness only while the pane state still says alive.
- Delta delivery is ordered. Mutations were serialized by the state
  lock but delivered after releasing it, so one writer's deltas could
  overtake another's and leave every mirroring client on the losing
  state with no cue to re-pull. A notify-order mutex now spans each
  mutation and its own fan-out; cheap, because subscriber callbacks are
  enqueue-only by contract.
- Implicit active-tab changes broadcast. tab_create's activation and
  the close paths' heal now emit ActiveTabChanged, so a client applying
  deltas never re-implements the server's heal rule; the one
  inexpressible case (no tabs) needs no delta because it is a fact,
  not surgery.
- The coarse agent status no longer drives disk writes: it flips per
  hook event and is display-only, so it is outside the changed-facts
  gate and merely rides along when a load-bearing fact changes.
- control_services reports which stores it serves on stderr again —
  tty7-server configures no log sink, and 'no machine tree' was
  invisible exactly where it matters, on a headless box.
- The local link's first connect attempt is immediate instead of one
  backoff step late; the observation-slot test withdraws its store so
  it cannot swallow later tests' observations; and locked()'s poison
  rationale now says what is actually guaranteed.
2026-07-29 21:49:22 +08:00
l0ng-ai 0d58eef4bc feat(control): attachment and takeover ride the machine tree too
WorkspaceAttach / WorkspaceDetach (and the hello-names-a-workspace
shorthand) now record their data half on whichever workspace stores the
server carries: the retired record store, the machine tree, or — on a
full daemon while clients migrate — both, since they describe the same
workspace. The behavioural contract is untouched and now survives the
record store's retirement: newcomer always wins, the displaced session
is pushed Preempted (and closed only when its link was dedicated), and
a preempted session's tidy-up detach cannot evict the usurper — the
token check lives in the tree's runtime-only attachment exactly as it
did in the store's. A server carrying neither store answers the same
refusal a store-less server always has.

WorkspaceId gains FromStr (the inverse of its Display) because the
attach verbs predate the typed tree and carry the id as a string. The
end-to-end test drives a takeover on a server serving the tree and no
record store at all, asserting the tree's own attachment record moves
with it.
2026-07-29 21:21:48 +08:00
l0ng-ai f89aae957a feat(ui): hold a supervised control link to the local daemon
The GUI now dials this machine's own daemon over the control dialect,
exactly as it does a remote one: one machine, one daemon, one control
link. The link lives in its own global rather than RemoteConnections —
inserting it there would register a wire-backed Host for this machine
(local files and git must keep going through the in-process LocalHost)
and would break the HostId::LOCAL-never-holds-a-control-connection
invariant. No routing either: the daemon's control socket is right
here, so connecting is a Unix connect plus a ControlHello.

Supervised on its own forever loop at the remote pump's cadence,
because that pump deliberately parks when the last remote workspace
closes and a purely local session is the common case. Each turn also
drains the shared control-event queue, so local pushes (Layout deltas,
Preempted) are delivered under HostId::LOCAL even with the remote pump
stopped; the observer install is shared with the remote supervisor so
whichever comes up first, reader threads never find nobody listening.
Reconnects ride the same 1/2/4/…/30s backoff a remote machine gets,
with ensure_running first — the daemon is the GUI's own child, and a
cold start legitimately races its listener.

Unix-only like the control listener it dials; on Windows the loop
compiles to a supervision no-op and the pane path is untouched.
2026-07-29 21:17:45 +08:00
l0ng-ai b48bb0ada1 feat(daemon): pane facts flow from the pane server into the machine tree
The tree's pane records are only worth reviving from if they hold what
the machine itself observed, so the pane server now publishes into the
MachineStore the daemon serves: the reader thread reports OSC 7 / probed
cwd changes and the sniffer's agent facts (identity, native session id,
launch argv, coarse status) after each chunk that changed them, and
DeathReporter::report flips the record to live == false however the
death was noticed — that flag is the client-visible 'awaiting revival'
state, and it now comes from the process that owns the PTYs on the very
event, not only from the next restart.

The store rides a process-wide slot (installed by control_services,
same shape as the control event observer) so the three pane-spawn paths
need not thread it through; without one installed, observing is a
no-op, which keeps unit tests and tree-less servers quiet. Facts are
published outside the pane state lock and only on a real change, so the
reader's hot path pays two clones and a compare. AgentFacts.status
tightens from a free string to the existing AgentStatus enum while no
wire client depends on it.
2026-07-29 21:10:57 +08:00
l0ng-ai 57d72c91ad feat(control): machine-tree verbs and incremental Layout deltas
Teach the control dialect the semantic operations the machine tree
serves: MachineGet / WorkspaceTree pulls, WorkspaceCreate / Rename /
Remove / Touch / SetActiveTab, TabCreate / Close / Rename / Move /
SetGroup, and PaneSplit / Close / SetRatio / Move / Replace. Replies
carry the daemon's own tree types (a created workspace or tab comes
back whole; close operations answer the pane ids that left the tree so
the caller can kill their PTYs), and every operation broadcasts a
ControlEvent::Layout delta to every connection but the writer's — the
same origin-exclusion mechanism the record store uses, one delta at a
time instead of whole-record last-writer-wins.

The server advertises a new 'machine-tree' capability bit only when it
actually carries a MachineStore; both daemons now do, alongside the
retired opaque record store, which keeps serving unchanged while
clients migrate. Delta fan-out rides its own bounded queue and
forwarder thread per connection, so a peer that stopped reading stalls
nobody's edit; the drop-on-overflow tradeoff is documented against the
keepalive that reaps such a peer and the full pull every reconnect
starts with.

The request/reply/event enums lose their Eq derive: split ratios are
f32. End-to-end tests drive the shipped tty7-server binary over real
pipes: capability advertisement, tree ops landing in the server's own
file, dead-pane revival across a real process restart, and delta
delivery between two live clients.
2026-07-29 21:01:58 +08:00
l0ng-ai 15c18356f0 feat(core): daemon-owned machine tree with semantic operations
Add core::machine: the workspace/tab/pane tree a machine's daemon owns
outright, replacing the client-owned-schema model of the opaque record
store. Leaves hold a pane id and nothing else; every fact about a pane
(cwd from OSC 7, title, ssh spec, agent identity) lives once in the
pane registry, which is what makes revival sound: a reopened store
force-clears every live flag, so after a daemon restart the tree itself
says every leaf is awaiting revival — no client-side instance stamps or
id-reuse heuristics required.

Operations (workspace create/rename/delete/touch/set-active-tab, tab
create/close/rename/move/regroup, pane split/close/set-ratio/move/
replace) validate against the held tree, persist atomically, roll back
on a failed write, and broadcast incremental LayoutDelta events with
origin exclusion so a writer never hears its own echo. Persisted to
machine.json beside the old store's file, serde with #[serde(default)]
throughout so the daemon can keep evolving the schema, corrupt files
quarantined instead of overwritten.
2026-07-29 20:55:16 +08:00
l0ng-ai bbeeec51d7 refactor(daemon): share one run_daemon between tty7 and tty7-server
Extract the control-listener-plus-pane-server startup from tty7-server
into tty7_core::daemon::server::run_daemon, and point both binaries at
it. The local daemon now serves the control dialect exactly like a
remote one: one machine = one daemon, whichever binary happens to be
running it.

The bound control socket (and a bind failure) is still reported on
stderr with the historical 'tty7-server:' prefix — a headless server's
log file is off by default, and the remote_router test reads that exact
line back to prove the client derivation and the server bind agree.
2026-07-29 20:54:01 +08:00
l0ng-aiandl0ng-ai bed22d899e Keep workspaces whole: remote reopen/restart recovery, and cross-workspace restore guards (#257)
* feat(remote): keep a remote workspace whole across reopens and restarts

Reopening a remote workspace — or coming back to one whose `tty7-server`
had been replaced — landed on a screen of `tty7 — disconnected` panes with
their coding-agent conversations gone. Several independent holes added up
to that; this closes them together, and picks up the surrounding work the
same session produced.

**Telling a restarted server from a blinked link.** `ControlHelloOk` now
carries an `instance` minted once per server *process*. Nothing else in
the handshake changes across a restart — `build` and both dialect numbers
survive it — so a reconnect had no way to know its `pane_id`s were dead.
It does now: a different instance rebuilds the window from its layout
(same tabs and splits, fresh shells in the saved cwds) instead of
re-attaching to a process that is gone. An absent instance means *unknown*
and is never read as a restart.

**An attach can now fail.** `Attach` has no synchronous reply, so the
client returned `Ok` unconditionally and the daemon's `Error` frame was
read much later by the reader thread, which has no arm for it — the pane
then landed in the *link is down* state instead of falling back to a fresh
shell. The client now reads far enough into the reply to classify it on
the kind byte (the snapshot behind it can be megabytes) and hands those
bytes to the reader thread, so a successful attach loses none of its
replay. Local and remote attaches get different waits: the local one is on
the UI thread.

**The agent session survives to be resumed.** `TerminalView` raises
`AgentSessionChanged` when the pane's agent reports a new native session
id, so the layout on file catches up instead of waiting for the user to
happen to open a tab. A pane that is still connecting now carries its
agent through `PendingSpawn` — a save landing in that window used to write
`agent: null` over the record — and `land_pane` sends `--resume` when the
attach turned out to need a fresh shell.

**Ending sessions says so on file.** "End Sessions" kills the panes and
then drops their ids from the record, pushing the cleared layout to the
machine that owns it (design §10: the remote's copy wins, so a local-only
clear would be undone by the next open — the open this exists for).

**The new-tab dropdown lists the window's machine.** `Host::shells` and a
`Shells` control request (dialect v2) make the "+" menu a property of the
machine the window is bound to. A remote window filled from this
computer's `/etc/shells` offered `/bin/zsh` on a box whose zsh is
elsewhere, and every pick failed to spawn.

**An install reports its bytes.** The download and the SFTP upload each
report progress, relayed to the client over the routed connection as a
`RoutePrompt::InstallProgress`, and painted as a bar under the machine's
row in the switcher. ~8 MB across two hops behind the word "connecting…"
was indistinguishable from a hang.

**The installer compares dialects, not version strings.** `tty7-server
--protocol` prints what a binary speaks without starting it, so a connect
adopts an already-running server it can talk to rather than prompting
about a build difference and uploading 8 MB the machine did not need.

**Switcher.** A machine's `⋯` menu holds "New Workspace" (it was a row
under every machine, pushing the list a quarter of a card down) and a new
"Disconnect", which drops the connection and leaves the windows open and
read-only. The suspension lasts exactly as long as that machine has a
window on it.

Also drops three design/contract docs for the now-shipped remote-workspace
work.

* fix(session): stop one workspace's panes from being restored into another

A restart put a copy of one workspace's seven tabs — cwds, layout and
recorded agent sessions — in front of another workspace's own tabs, and
auto-resumed every one of those agents a second time: six `claude
--resume <id>` pairs running in parallel against the same conversations,
one set per window. The record-level corruption that seeded it is still
unattributed, but every mechanism that let it propagate, amplify, or go
unnoticed is closable, and this closes them.

**Panes now know their owner.** `Spawn` can carry the workspace the pane
is created for; the daemon stores it immutably and reports it in
`List`'s `PaneInfo.owner`. Restore refuses to re-attach a pane another
workspace owns (`pane_attachable`) — before this, a saved id landing on
somebody else's live pane attached silently, which is how one window
could pick up another's shells. The field rides a new `SPAWN_OWNED`
frame with a struct payload (the legacy spawn payloads are positional
tuples an old daemon cannot grow), gated on a new `pane-owner` feature
string: a client only sends it to a daemon that advertises it, so the
legacy kinds stay byte-for-byte what old daemons expect. A pane with no
recorded owner stays attachable by anyone — that is the pre-field
behavior, not a new risk.

**Saved pane ids are bound to the daemon process that issued them.**
`DaemonVersion` now carries an `instance` minted once per process (the
local twin of the control hello's), the GUI caches it at the
`ensure_running` handshake, and each local workspace records it as
`daemon_instance` beside its layout. Claiming a workspace whose ids came
from a different instance blanks them first: daemon pane ids restart
from 1, so after a reboot every saved id points at whatever unrelated
shell holds the number now, and the aliveness check cannot tell a
survivor from a squatter. A blank on either side means "cannot tell" and
never trips it. Unlike the duplicate-claim case below, this path keeps
the agent resume — the pane is genuinely gone with its daemon, and the
fresh shell resuming the conversation is the feature.

**A duplicate claim loses its agent resume along with its pane id.**
`dedupe_pane_ids` kept the loser's layout *and* its
`agent_session_id`, so the blanked leaves took restore's spawn-fresh
path and auto-typed `claude --resume` for conversations the winning
workspace's panes were still running — the doubling above. The winner
keeps the panes and the resume; the loser keeps only cwds.

**Cross-workspace saves are caught at the write.** Every terminal view
remembers the workspace whose window created it, and `save_session`
logs an error naming both ids if a window ever records a pane created
for a different workspace — the tripwire for the still-unattributed
seed corruption, so a recurrence is caught in the act instead of
reconstructed from `session.json` archaeology days later.

Wire compatibility both ways: `PaneInfo.owner`, `DaemonVersion.instance`
and `Workspace.daemon_instance` are `#[serde(default)]` struct fields
(old peers' JSON decodes, new fields are ignored by old readers), and
`SPAWN_OWNED` is feature-gated as above. `daemon_instance` is
client-owned in the design-§10 storage split — it names the local
daemon, and the field-census test pins the classification.

* fix(session): resume the agent when a local pane dies mid-restore

`session_to_pane` decided whether to send a coding agent's `--resume`
from `restore.is_none()` — i.e. from whether the pane looked alive when
the restore started. But `alive_panes_on` runs one `List` at the top of
the restore, while the attaches happen per leaf afterwards. A pane that
exited in between failed its attach, fell back to a fresh shell inside
`spawn_shell_terminal_in`, and then landed in the `restore.is_some()`
arm: an empty shell with its conversation dropped.

`ShellParts.restored` already answers this exactly, and the remote path
already reads it in `land_pane`. Carry it onto `TerminalView` so the
synchronous local path can read it too, and branch on that instead of
re-deriving the answer from a set that may be stale by the time it is
used.

No behaviour change on the paths that were already correct: a view that
was never restoring anything reports `restored: false`, which is the
same answer `restore.is_none()` gave them.

* fix(remote): check the server instance against the record, not just memory

A remote workspace's pane ids were only guarded against server restarts
by `RemoteLinks::instances`, an in-memory map. On the first connect after
the client starts, every machine is a first sighting, so `server_restarted`
answers false — and a `tty7-server` that was replaced while the client was
closed sails straight through. Its pane ids restart from 1, so the saved
ones now name unrelated shells, and the reconnect attaches to them: the
exact id-reuse failure the local side already guards against.

`Workspace::daemon_instance` was local-only for the stated reason that a
remote server's identity is tracked live per connection. That tracking is
correct but not sufficient — it cannot survive the client restart that
makes the question worth asking.

So the field now means the same thing on both sides: which process minted
the pane ids in this record. `WorkspaceStore::serving_instance` picks the
local daemon or the far machine's server depending on the workspace, and
`finish_attempt` compares it per workspace before deciding to re-attach or
rebuild. It stays client-owned: it records what *this* client last saw, so
two clients on one remote workspace each keep their own and neither may
overwrite the other's.

An unreachable machine still records nothing, which is what keeps a good
stamp from being erased with `None` — that would disarm the next check.

Also in these three files: the §N references to the deleted design docs,
cleaned up as part of the sweep in the following commit.

* docs: drop the references to the deleted design documents

The three documents this branch removed were cited ~280 times: `design
§10`, `contract §8`, `§17` and friends in comments, five references by
file path in code and manifests, five in CI workflows and one in the
release skill. Every one of them now points at nothing.

Rewritten rather than merely stripped, because most were not decoration:
"design §10 makes the remote's `workspaces.json` the authority" becomes a
statement in its own right, and the several that carried a Chinese phrase
from the document as their justification say the same thing in English
instead. Where the reference was purely parenthetical it is simply gone.

Not touched: `PRD §7.1`, `brief §8` and the like, which name documents
this branch did not remove and were already external before it, and the
`RFC 4648 §10` test-vector citation, which is a real specification.

The `host boundary` CI job loses `(§10.6)` from its name. It is not one of
the required checks, so branch protection is unaffected.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-29 19:15:19 +08:00
7194236985 fix(terminal): prevent fullwidth punctuation overlap and speed OSC mark scanning (#250)
* fix(terminal): stop wide glyphs overlapping after fullwidth punctuation

gpui's apply_force_width_to_layout tells a base glyph from a zero-advance
combining mark by whether the shaped x advanced past half the forced
width, and CJK fullwidth punctuation fails that test (U+FF08 advances
~0.47 em against a 0.6 em half-slot). In a batched wide run the glyph
after such a character was classified as a mark and painted on top of
it. Shape each wide glyph on its own line instead: the first glyph of a
line is unconditionally a base, so the heuristic never misfires.

* perf(terminal): intern wide-segment strings via char_string

Each wide glyph now shapes alone, so its text is a single-char string —
reuse the char_string memo instead of allocating a fresh String per cell
per frame. The interned SharedString is also what keys gpui's line
layout cache, so a CJK-dense repaint allocates nothing.

* perf(terminal): skip MarkScanner's Text state ahead with SIMD memchr

The scanner runs over every batch the client receives, and ordinary
output — where the only byte that matters is ESC — dominates each one.
Skip to the next ESC with memchr instead of stepping per byte, exactly
as tty7-core's OscTokenizer already does: measured on an 8 MB batch of
plausible output, 1.6 GB/s became 8.3 GB/s.

Declare memchr for the root crate — it left with the OSC tokenizer's
move down to tty7-core, and this is the first use since.

* fix(terminal): advance segment_row past each wide glyph

The unbatching change dropped the `col += 2` along with the batching
loop it lived in, so the wide-glyph arm pushed its segment and looped on
the same column forever, growing `segs` until allocation failed — the
6 GiB abort on the Windows CI runner, and a machine-freezing memory
climb under a local `cargo test`.

---------

Co-authored-by: lizhi <lizhi20@xiaomi.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: l0ng-ai <ysdpk123@gmail.com>
2026-07-29 18:32:19 +08:00
zerolover 148cb2f82a fix(linux): set window icon and application ID (#254)
Set app_id "tty7" on every platform (WM_CLASS on X11, desktop-entry match on Wayland) and attach a 256px _NET_WM_ICON to X11 windows. macOS and Windows already get their icons from the bundle / exe resource.
2026-07-29 16:54:58 +08:00
l0ng-aiandl0ng-ai f660ddb1cc fix(ui): safe delete prompt default, platform-aware reveal labels, and searchable agent settings (#255)
* fix(ui): make Cancel the default button on the file-tree delete confirmation

The file tree's delete prompt was the only destructive prompt in tty7 with
the destructive action first. On macOS (NSAlert) and Windows (TaskDialog)
the first button is the Return-key default, so pressing Return deleted -
including recursive folder deletion. Linux uses gpui's fallback renderer,
which is click-only, so the swap only reorders the buttons there.

Safe option first, matching every other destructive prompt; the literal
"Cancel" is what gpui maps to PromptButton::Cancel and the Escape key.

* fix(ui): stop saying "Finder" on Linux and Windows

The file-tree context menu and the SFTP job tooltip hardcoded
Finder-flavoured labels; only the right panel's Info row was
platform-conditional. Extract that conditional into
right_panel::reveal_label() and use it at all three sites, so the action
reads "Reveal in Finder" on macOS and "Open Folder" elsewhere.

Side effect of sharing the helper: the SFTP tooltip's "Show in Finder"
becomes "Reveal in Finder" on macOS, converging a third name for the
same action.

* fix(settings): index the Grok Build agent row and re-align drifted index titles

The Grok Build agent renders a settings row but had no search-index
entry, so the row was unreachable by search. The other five agent
entries carried mechanism suffixes ("Claude Code hooks", "Pi
extension") that no longer match the rendered rows, which are titled by
HookAgent::display_name(); the index said "Option acts as Meta" where
the row says "Option (⌥) acts as Meta".

Align the titles with the rows, keep the mechanism words as search
keywords, and extend the index tests: the pinned-title list gains the
Option row, and a new test derives the Agents entries from
HookAgent::ALL so adding or renaming an agent without updating the index
fails the suite.

* no-mistakes(review): add plain search keywords for Option-acts-as-Meta entry

* no-mistakes(document): document copy fixes in changelog and apply rustfmt

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-29 16:19:43 +08:00
l0ng-aiandl0ng-ai 6f842c3007 fix(file-tree): stop a watcher event repainting a window with nothing to draw (#249)
* fix(file-tree): stop a watcher event repainting a window with nothing to draw

Issue #243 made two claims. The flicker was fixed independently on main by
4814d94 "fix(remote): keep file-tree listings on screen while they refresh",
which reached the same mechanism separately and went further on two axes. This
is only the other claim — the one in the issue's title, and the one still true
on main: `file_tree_apply_fs_events` ended in an unconditional `cx.notify()`, so
every batch from a watcher that is recursive over the root repainted the whole
window. The overwhelming majority of those batches name a directory the tree has
never listed and does not show: `.git` internals, build output, `node_modules`.
A window with nothing new to draw was being asked to draw, several times a
second, for as long as anything under the root was being written.

* `invalidate_dir` reports whether it reached anything the tree holds, and a
  batch that reached nothing returns without repainting.
* What it did reach is re-read from the callback itself, rather than by
  notifying in order to buy the paint that would have re-read it.
* `land_load` returns `Landed { superseded, changed }` and the caller repaints
  only on `changed`, so a file rewritten in a directory on screen costs no
  frames. `TreeEntry` gains `PartialEq` for that comparison.
* The `.gitignore` whole-cache branch is scoped by `gitignore_reaches_tree`:
  patterns at `D/.gitignore` govern `D` and below, so unless a tracked directory
  sits under `D` there is nothing to refresh. Its `cx.notify()` is deliberately
  kept when the branch *is* taken — `invalidate_all` restarts the search and
  only a paint re-walks it. `InFlight::pending_keys` is new, for the in-flight
  half of that test.

Measured, not asserted. `Tty7App::render` feeds a per-thread draw counter under
`cfg(test)`, and gpui's test build redraws dirty windows from inside
`flush_effects` — so a headless window plus that counter answers "does this
reach render idle?" with no compositor and without the reporter's Wayland
session. Supporting seams: `test_window::harness` / `harness_with_pane` and
`terminal::view::quiet_test_pane`. `render_probe::arm` takes a draw budget so a
repaint-loop regression fails the test instead of hanging the suite — the loop
lives inside one `flush_effects` call and nothing outside it can interrupt it.

Render idle is measured as "stops drawing", not "never draws again": settling
legitimately costs a last frame as the final listing lands, so the count is
taken over a second interval once the first has absorbed that tail. Confirmed
rather than assumed — the count is 1 at 3s and still 1 at 12s.

Numbers, each taken by reverting the hunk it belongs to and re-running:

    five writes under an unlisted directory      5 draws -> 0
    five rewrites of a displayed file           10 draws -> 0
    five .gitignore writes under node_modules   10 draws -> 0
    marks left unread after a gitignore refresh  1      -> 0

The four controls — a settled panel on a non-empty, an empty and a hidden-only
directory, and a real change still arriving — pass either way, which is the
point: they were never the bug. `untracked_paths_leave_no_bookkeeping_behind`
also passes either way, because main's `invalidate_dir` already only marks what
is cached; it is a guard, not a fix.

Two things stated rather than faked. The end-to-end through a real OS watcher
did not survive: the watcher moved into the host layer, which a synthetic
`file_tree_apply_fs_events` cannot drive. For the same reason the gitignore test
asserts the marking and the re-read rather than the recomputed `ignored` flags,
which the host owns — and says so where it stops.

This commit replaces four earlier ones on this branch, squashed because they
were written against the single-host `file_tree` that no longer exists and could
not be replayed onto it. What each contributed:

* 744a08d fix(file-tree): stop the watcher blanking the panel and repainting for
  nothing — the original. Its flicker half is dropped in favour of 4814d94; its
  render-idle half is what this commit is.
* a88544c no-mistakes(review): scope gitignore refresh to the tree and cover
  in-flight loads — the `gitignore_reaches_tree` scoping, kept. Its other half,
  covering in-flight loads in the whole-cache refresh, is not carried: main's
  `invalidate_all` already stales them via `InFlight::invalidate_all`.
* 0ff3b64 no-mistakes(document): correct stale watcher comment in file_tree docs
  — folded into the doc comments here.
* f0e33a4 no-mistakes(document): drop tracked AGENTS.md, move render-probe caveat
  into source — the deletion is moot on this base (the file was never added),
  and the caveat it relocated is in `render_probe`'s doc comment.

Separately and deliberately not fixed here: on macOS the watcher reports paths
through `/private/var` while the cache is keyed by the root as handed in, so a
root reached via a symlink never matches and its changes are missed. Pre-existing
and invisible on Linux; the tests canonicalize around it and say so.

1484 tests pass across the workspace, fmt clean, clippy unchanged from
origin/main's baseline at 57 warnings. The app was not built, launched or driven;
visual acceptance is the owner's.

Refs #243.

* no-mistakes(review): correct watch scope claims, repaint on moved root, skip hidden-panel reads

* no-mistakes(review): exclude the SFTP column from the tree-drawn gate, fix stale docs

Also carries the correction the earlier messages on this branch owe the reader.

The first commit's message (and the CHANGELOG entry it shipped with) asserted
that the file tree watches its root **recursively**, and justified the whole
change on the traffic that supposedly produced: `.git` internals, build output,
everything under `node_modules`. That was wrong, not merely imprecise. The watch
is non-recursive — `sync_watch`'s own doc says so, it covers roots plus expanded
directories, and `WatchedDirs::translate` enforces it per backend. The recursive
watcher belonged to the older single-host design; the premise was carried across
the port to the host-keyed tree without being re-checked.

What is actually reachable, and all this now claims: the tree hears about a
change in a directory it is *displaying*, and a file's contents being rewritten
reports exactly as loudly as a file appearing. Comparing the re-read against
what is already on screen is what stops that repainting a window with nothing
new to draw.

Measured against that reachable case only, by reverting the comparison and
re-running: five rewrites of a file in a displayed directory cost 10 frames and
now cost 0. The figures the first message quoted for writes under an unlisted
directory and for `.gitignore` writes under `node_modules` are withdrawn — the
tests behind them synthesised watcher events this watch cannot deliver, so they
described scenarios the system cannot produce. Those tests have been removed or
reframed as guards on the predicate rather than evidence of a live symptom.

The other corrections in this round:

* A `.git` create or delete cleared the repo-root cache and then took the new
  early return, so nothing re-resolved it and a moved repository root no longer
  re-rooted the tree on an idle window. That regression came in with this change
  and is closed.
* The watcher-driven re-read was not gated on the tree being drawn, so a hidden
  panel did filesystem work it never used to do. It is gated now.
* That gate then assumed the Files tab always draws the local tree, which it
  does not: a connected native-SSH pane substitutes the SFTP browser and the
  local tree is never rendered. The predicate accounts for that too.
* Two stale statements of the recursive premise survived the first sweep, in
  `assets.rs` and `code_editor.rs`, and are corrected.

The SFTP-substitution case is covered by a test at the predicate; the
substitution itself is a render-path branch with no headless seam, so what is
asserted is the predicate's answer rather than the panel's output.

* no-mistakes(review): gate watcher re-read on listings drawn, fix stale docs

* no-mistakes(review): delete withdrawn recursive-watch CHANGELOG entry duplicated by rebases

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-29 16:09:04 +08:00
l0ng-aiandl0ng-ai 9c00648875 fix(ui): keep the window-drag arm alive across a repaint, and make every header draggable (#252)
* fix(ui): make every header draggable, and keep the arm alive across a repaint (#221)

Two changes to the same code, which is why they land together.

Five rows that stand in for the title bar — the tab rail's top zone, the
settings page's top strip, the detail panel's top zone, and the code and
diff overlays' headers — armed their drag with an `Rc<Cell<bool>>`
allocated inside the render function. A redraw between the press and the
first drag event handed the next frame's listeners a fresh, zeroed cell
while the press had written to the old one, so the whole hold was dead
until you released and tried again.

The press itself schedules that redraw: these rows carry `on_double_click`,
and gpui calls `window.refresh()` on mouse-down for any element with a
click listener. So a drag only survived if the first move beat the next
vsync — 16ms at 60Hz, 8ms on ProMotion. A mouse press physically nudges the
pointer and often won that race; a trackpad press is a finger pushing down
without translating, and almost never did. That is the trackpad-vs-mouse
split the issue reports. The terminal's cursor blink (a 530ms `cx.notify()`
loop) disarms it on its own even with no press at all.

`window_move_gesture` now holds the flag in `window.use_keyed_state`, which
survives frames — where gpui-component's own `TitleBar` has always kept it,
and why the ordinary caption strip was never affected. Keyed rather than
`use_state` because one builder serves several call sites and `use_state`'s
`CodeLocation` id would collide when two of these rows are on screen at
once (the rail's top zone plus an overlay header is a real combination).

A longer-lived flag has to be cleared explicitly, so releasing outside the
row disarms too; with a per-frame cell the frame boundary did that for free.

Nothing else about these rows changes — same hit boxes, same geometry, same
`WindowControlArea::Drag`, same double-click.

Grabbing the window by a header is a property of the whole app, not a
per-surface feature, so a user never has to learn which rows are draggable.
Written down beside `window_move_gesture`, along with the two things it
takes beyond arming the gesture: non-controls inside a header take no hit
box (the rule #202 set for the "duo" mark, so the drag falls through them),
and a header whose contents *do* take hit boxes by design needs a floor on
its flexible spacer.

- `panel_title` — the detail panel's section header, shared by Info,
  Outline, Changes, Files and the remote Files browser — is draggable now.
  Its one un-`occlude()`d control (SFTP's refresh tile) gains the wrapper
  every control on a drag row needs, or Windows' HTCAPTION eats its clicks.
- The horizontal tab strip keeps a bare 80px slice of caption. Its spacer
  was a `flex_1` with no minimum, so it collapsed to exactly 0px once the
  chips saturated the row (~7-8 tabs on a 1440px window), leaving only three
  6px gaps and a hairline above and below the chips to grab — the "the
  region that works seems very small" half of the report. The chip row's
  fixed-chrome reserve is corrected to match: a stale flat 100px, sized when
  the corner held a 30px "+" and a 30px "⋯", becomes the ~137px the corner
  actually occupies plus the handle. Chips reach their minimum width and
  truncate a tab or two sooner, and the window is always grabbable.
- The rail's top-zone spacer gains the same floor.

`ui::app::window_drag_tests` drives the real `title_bar_drag` row through
gpui's test platform, where `start_window_move` is `unimplemented!()` and a
panic is therefore a reliable "the window would have moved" detector. It
pins the invariant (press → repaint → move still drags), that a press alone
does not, that a release disarms, and that two rows on screen keep separate
arms. A control test keeps the old per-frame-cell pattern alongside and
asserts it still loses the drag to the identical event sequence — without
it, the invariant test could pass for the wrong reason.

* no-mistakes(review): occlude resize handles; correct chip-reserve arithmetic

* no-mistakes(document): reorder changelog sections; record non-draggable header exclusions

* no-mistakes(document): make panel grab-handle docs version-neutral and platform-accurate

* no-mistakes(document): make workspace_head panel-width doc version-neutral

* docs(changelog): re-file Unreleased entries after the rebase onto main

The rebase onto 64403cf applied every hunk without a conflict and still
produced a wrong file, which is the failure mode worth naming: this
branch's "reorder the Unreleased sections" commit moved its own entries
to Added -> Changed -> Fixed, and replaying that on a main whose
Unreleased had grown three new entries wedged this branch's ### Changed
and ### Fixed headings into the middle of main's ### Added list.

The result had two of *other people's* entries — "Fork an agent session"
and "Copy Session ID", both Added, both from #211 — orphaned under this
branch's ### Fixed, and a duplicate ### Changed / ### Fixed pair further
down. Git had nothing to complain about; the text merged cleanly and the
meaning did not.

Restored to main's structure with this branch's two entries filed under
the headings they belong to. No entry text changed on either side; all
seven Unreleased entries are present, verified against the union of both
parents.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-29 14:57:21 +08:00
l0ng-ai 12d8cf549b fix(ci): read the AppImage version from [workspace.package]
The crate split left the root manifest leading with `version.workspace =
true`, so `grep -m1 '^version'` returned that line verbatim and the sed
fell through unchanged. Every AppImage since was named
`tty7-version.workspace = true-linux-x86_64.AppImage`, which broke the
nightly publish job at `sha256sum -c` — the space-split name resolved to
three missing files.

bundle-linux.sh, bundle-macos.sh and bundle-windows.ps1 were already
anchored on `= "`; this was the one that got missed, and the only one
without a guard to catch the miss.
2026-07-29 10:37:56 +08:00
l0ng-aiandl0ng-ai 64403cf860 feat(terminal): expose the prompt editor's soft newline as a rebindable InsertNewline action (#246)
* feat(keymap): make the prompt editor's soft newline a bindable action

Shift+Enter and Opt/Alt+Enter have inserted a literal newline into the
command editor since the multi-line prompt editor landed in 54825d3, and
only a plain Enter submits. But the chords were hardcoded in the editor's
key handler, so `InsertNewline` was not a name the keymap knew: nothing to
put in `keybindings` in config.json, no row in Settings -> Keybindings, and
no way to move the gesture to a chord of your own.

Register it like every other action. `InsertNewline` is Terminal-scoped
(the handler lives on the terminal surface, as ClearScrollback and the find
trio already are) and the "enter" arm of the editor dispatcher no longer
looks at modifiers at all — the keymap dispatches the action before the key
reaches the dispatcher, so there is one implementation rather than two that
can drift.

The action ships with both of today's chords. A binding spec cannot express
alternatives — whitespace in a spec means a sequence, `ctrl-b n`-style —
and the effective table holds exactly one keystroke per action, so the
table carries shift-enter and alt-enter is installed alongside it, but only
while the action still sits on its default. Rebind it and both old chords
are retired, which is what moving a binding is supposed to mean.

The prompt editor alone answers it. With a foreground application on the
pane, the search field focused, or a completion menu / reverse search
holding the keyboard, the handler propagates, so the chord takes the exact
path it took before this action existed — a full-screen program still
receives Shift+Enter as its own chord under the Kitty protocol. Plain Enter
still submits, and the secondary-enter / secondary-shift-enter window
bindings are untouched.

Refs #182

* fix(prompt): let the newline chords through an open completion menu

Follow-up to the InsertNewline action, from review findings, resolved
against Warp as the reference implementation.

The action declined whenever a completion menu was open, on the theory
that propagating preserved the old behaviour. It did for Shift+Enter,
which used to reach the picker's accept-line arm, but not for Alt+Enter:
the picker branch is gated on `!m.alt`, so Alt+Enter skipped it entirely
and fell through to the enter arm's newline. Propagating sent it to an
enter arm that now unconditionally submits, so Alt+Enter with a menu open
ran the command.

Warp does not let the popup take these chords at all. Only a bare Enter
reaches the popup-acceptance path (a fixed "enter" binding routed into
`input_enter`, where menus and the completion popup consume it); Shift+
Enter, Alt+Enter and Ctrl+J dispatch their own actions, which the editor
resolves as a newline against `EnterSettings` without the popup ever
seeing them. Its TUI has no popup in the input's dispatch path at all.

So insert unconditionally and close the menu — a newline ends the word
being completed, and a menu still filtered on that word would be stale.
Shift+Enter with a menu open therefore changes from accept-line to
insert, deliberately and in the direction of the reference; Alt+Enter
goes back to inserting, as it did before the action landed. Plain Enter
keeps accepting the highlighted candidate.

Also repeat the dispatcher's per-key state resets (`editor_goal_col`,
`last_word_nav`), which the inline branch inherited by running inside
`handle_editor_key` and the action bypassed — the same reason
`commit_text` repeats them for the IME path.

Shift+Alt+Enter, which the old `(m.shift || m.alt)` test caught by
accident, is left submitting: gpui matches modifiers exactly, and Warp's
key table has no arm for that chord either. Recorded in a comment and a
test so it is not "restored" later as a missing default.

Refs #182

* no-mistakes(review): scope rebind's NoAction to its context; see extra chords

* no-mistakes(document): document rebindable InsertNewline chords; fix rustfmt and changelog link

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-29 01:05:08 +08:00
l0ng-aiandl0ng-ai 1f09a62568 fix(ui): give corner-filling children their own radius (#236) (#244)
* fix(ui): stop filled children squaring off rounded corners

The cursor-shape toggles (Block / Bar / Underline) reported in #236 look
rough because the selected segment's fill covers the whole corner of the
track it caps, and its outer edge is a hard, unantialiased vertical cut.
The track's own border arc is drawn correctly and antialiased — it just
floats *inside* that square, so the corner reads as a stair-step.

The controls were relying on `overflow_hidden` to shape their end
segments' fills to the track's rounding. It cannot do that.
`gpui::ContentMask` is a bare axis-aligned `Bounds`; `Style::overflow_mask`
builds it from the element's bounds shrunk by the border widths and drops
`corner_radii` entirely, and every shader applies it as a hard
`clip_distances < 0` discard. So the mask only ever cuts a square, and it
never antialiases the cut. A container's own corners come from somewhere
else — the quad shader's SDF, `saturate(0.5 - distance)` — which is why a
plain rounded card renders smooth while anything with a filled child in
its corner does not. That divergence is the whole bug, and the reporter's
screenshot shows both halves of it: the corner with the selected fill is
square, the corner without one is a clean arc.

The fill has to carry the radius itself, so it goes down the SDF path too.
It sits one border-width inside the track, so the concentric radius is
`outer - border`. `ui::rounding` states that rule once, with the
constants and the corner-assignment helpers, and unit-tests the
invariants (inset is strictly tighter than the outer radius, clamps at
zero, only the end segments cap the track).

Applied to every place a child paints a fill into a rounded corner:

* the segmented controls (the reported one, plus the others `segmented`
  serves),
* the −/value/+ steppers' hover fills — those glyph boxes also had to be
  pinned to the track's content height, because a padded auto-height box
  measures 31px against a 22px content box and its rounded corner would
  land 4½px outside the visible strip,
* the theme picker's flush-mounted previews,
* the diff overlay's card headers and the row that closes a card.

Not reproducible locally: this is a rendering-geometry defect, not a
platform one, but it is most visible at a device pixel ratio of 1, where
the clip's hard edge is a whole physical pixel. Verified by reading the
gpui mask/shader source and the reporter's screenshot pixel by pixel, and
by the geometry tests; the on-screen result is left for visual
acceptance.

Refs #236

* no-mistakes(review): round diff card header when body is empty

* no-mistakes(document): point Unreleased changelog link at v26.7.6

* no-mistakes(document): untrack AGENTS.md per gitignore dev-tool convention

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-29 00:34:40 +08:00
l0ng-aiandl0ng-ai 9ca3319239 feat(agents): fork an agent session and copy its session id (#241)
* feat(agents): fork an agent session, and copy its session id

A coding-agent conversation is a single thread: to try a risky direction you
either lose the one that got you there, or you don't try it. Every agent tty7
resumes already knows how to branch — `codex fork <id>`, `claude --resume <id>
--fork-session`, `opencode --session <id> --fork`, `grok --resume <id>
--fork-session` — but nothing in tty7 reached them, so the capability was
invisible from the terminal that already knows every pane's session id.

Fork is a per-agent capability beside the existing resume table
(`CLIAgent::fork_command`), not a Codex special case: it is the same `match
self` shape, it reuses the same id validation and the same launch-flag replay,
and four installed agents qualify today. Every command was checked against that
CLI's own `--help`; agents with no fork tty7 could verify return `None` and are
never offered the action, since a guessed flag shape would only ever produce a
usage error in the pane.

Flag replay needed one correctness fix to survive this. A forked pane's own
argv *is* a fork command, so relaunching it would replay the stale subcommand
and id (`codex fork <old>` → an old id as a positional prompt) or double the
modifier (`--fork-session --fork-session`). `codex fork` now sheds its
subcommand exactly as `codex resume` did, and `--fork-session` / `--fork` join
their agents' stale session-targeting lists. That also settles restore: a forked
pane restores through `resume_command`, which now drops the fork flag — a
restart continues the fork rather than branching it again.

Placement follows where the user asked from. A pane-level ask is spatial, so
the pane right-click menu offers Split Right / Left / Down / Up (pane splits
gained a `before` slot for the Left/Up half, which the tree had no way to
express). A tab-level ask isn't, so the tab context menu — inherited verbatim by
the sidebar rows, which is where the request came from — opens the fork in a new
tab with no placement question. The bare action behind the palette, the File
menu and Settings → Keybindings takes the tab-level meaning.

The three ways a fork can't run all surface rather than no-op: no session id
yet (hooks not installed) and a remote pane (the command would shell the *local*
agent) render the row disabled instead of hiding it, so the capability stays
discoverable, and the action paths that have no row to grey out say so in a
notification. Forking mid-turn is allowed but announced — agents fork from the
persisted transcript, so the turn in flight is absent from the copy — and the
parent is untouched either way.

Copy Session ID sits beside Copy Working Directory. Codex has no
copy-or-duplicate subcommand, so "copy the session" is the id: paste it into
`codex resume`, a bug report, or another tool.

Deliberately not built: any reading or writing of an agent's own session files.
tty7's exposure stays the public CLI contract plus the hook payload's session
id, so a change to Codex's rollout format or its version-numbered SQLite index
costs at most a visible shell error. Forked tabs also look exactly like their
parent, by decision — "Rename Tab" is the answer.

Closes #211

* no-mistakes(review): perf(terminal): compute fork menu enablement at menu-open time

* no-mistakes(document): docs: correct fork action surfaces, label, and remote limits

* fix(agents): label forking the same for every agent

The fork row said "Branch Session" on Claude Code and "Fork Session"
everywhere else, on the strength of a source comment claiming "Claude Code
calls it branching". It does not. `claude --help` documents the flag as
`--fork-session`, described as "When resuming, create a new session ID instead
of reusing the original"; the only occurrences of "branch" in its help are an
unrelated git-branch review option. The claim came from otty's own UI wording,
which I mistook for Claude's vocabulary and then wrote into the source as
fact — so the comment goes with the special case rather than being left behind
as a false statement about someone else's tool.

The split was also inconsistent with itself: Grok takes the identical
`--fork-session` flag and was already labelled "Fork Session". Every agent that
has the capability calls it forking — `codex fork`, `--fork-session` on Claude
Code and Grok, `--fork` on OpenCode — so one wording covers all four.

`fork_label` keeps returning `Option<&'static str>`: it is still the UI's single
capability gate (`None` = no verified fork command, no row offered), and
per-agent wording stays expressible should one ever genuinely diverge.

Generated commands are untouched — the existing table test still pins
`claude --resume <id> --fork-session` and the other three verbatim.

Also drops the two doc sentences that promised the per-agent label, and the
stale "Branch Session" mentions left in comments; no occurrence survives
anywhere in the tree.

* no-mistakes(review): fix(agents): fork the pane the tab menu row named

* no-mistakes(document): rewrap fork menu comment after label unification

* fix(agents): repoint Pi's token-gate comment after the rebase

Rebasing #211 onto #240 moved the session-id token gate out of
resume_command and into the shared session_command_flags helper, so
Pi's comment pointing at "the token gate above" no longer names
anything. Comment only; the gate itself is unchanged.

* no-mistakes(document): correct fork placement rationale in menus and changelog

* chore: untrack AGENTS.md per gitignore dev-tool convention

tty7 keeps agent-memory files out of the repo: `/CLAUDE.md` is already
ignored, and on disk it is a symlink to `AGENTS.md`, so tracking the
target defeated the convention. Ignore `/AGENTS.md` alongside it and drop
the tracked copy; the file stays on disk, where the notes belong.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-29 00:23:49 +08:00
l0ng-ai a88a8b6515 test(host): bound watch coalescing by elapsed windows, not a constant
The burst case asserted at most 4 batches for 50 writes, which really
asserts the machine finishes 50 writes inside 400ms. A loaded Linux
runner spread the burst over ~1.4s, so the coalescer correctly emitted
one batch per 100ms window and the test went red on main.

Measure how long the burst actually took and allow ceil(burst /
WATCH_COALESCE_WINDOW) + 2 batches. A host that stops coalescing still
fails: dropping the window to 0ms turns the case red.
2026-07-28 23:42:39 +08:00
l0ng-ai 9246340c43 Merge pull request #242 from l0ng-ai/feat/ssh-settings-redesign
refactor(ssh): rebuild Settings → SSH as a host library
2026-07-28 23:12:47 +08:00
l0ng-ai c3cfeb5daa Merge remote-tracking branch 'origin/main' into feat/ssh-settings-redesign 2026-07-28 22:56:14 +08:00