Commit Graph
66 Commits
Author SHA1 Message Date
a4972d32d8 feat(core): daemon-owned workspace tree — semantic ops, incremental deltas, thin clients (#260)
* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* docs(core): drop a stale reference to the retired record verbs

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* docs(core): finish pointing the last session.json references at views.json

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: thomas <thomas@thomass-Mini.lan>
2026-07-30 12:25:38 +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
l0ng-ai c469e10312 Merge remote-tracking branch 'origin/main' into feat/remote-workspace
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	src/core/config.rs
#	src/ui/pane.rs
2026-07-28 18:03:55 +08:00
l0ng-ai 1996f2ae12 chore(release): v26.7.6 2026-07-28 14:52:21 +08:00
thomasandClaude Fable 5 5cf767c9d9 chore: finish the resvg dedupe — bump gpui-component to 0.47
resvg 0.47 landed in tty7 (#227) and the gpui fork (#237), but
gpui-component still declared its own resvg = 0.45.1, keeping a
legacy resvg/usvg/tiny-skia 0.45/0.11 stack in the tree. The fork now
pins 0.47 (l0ng-ai/gpui-component@2264ff99 — no source changes needed;
its only resvg user, the Windows native-menu rasterizer, uses APIs
unchanged across the bump), so this moves the pin and drops the last
duplicate: the lockfile now carries a single resvg/usvg/tiny-skia
stack at 0.47/0.12, and `cargo tree -i resvg@0.45.1` matches nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:40:25 +08:00
thomasandClaude Fable 5 c04f0ab8f6 chore: dedupe the resvg stack with the gpui fork
PR #227 bumped tty7's direct resvg to 0.47 while the gpui fork still
pinned 0.45, so the tree compiled two resvg/usvg/tiny-skia stacks. The
fork's tty7 branch now carries resvg 0.47 (l0ng-ai/zed@3aac3ef); move
the gpui pin there so gpui's SVG renderer and tty7's tray-icon
rasterizer share one 0.47 stack again.

gpui-component still declares its own resvg 0.45.1 (semver-incompatible
with 0.47), so one legacy 0.45 stack remains until that fork catches up
- noted in the manifest comments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:18:04 +08:00
thomas 2c18d6b7f4 Merge origin/main into dependabot/cargo/sha2-0.11.0 2026-07-28 11:42:19 +08:00
thomas 2794be9ff3 Merge remote-tracking branch 'origin/main' into dependabot/cargo/resvg-0.47.0 2026-07-28 11:31:12 +08:00
thomas d13aca58f9 Merge remote-tracking branch 'origin/main' into dependabot/cargo/cargo-minor-patch-ef56ca0698 2026-07-28 11:24:05 +08:00
l0ng-ai 208454e202 feat(remote): remote workspaces — a window that is one machine
Split the framework-free half of tty7 into `tty7-core` and add a headless
`tty7-server` built on it, so a workspace's filesystem, git and session state
can live on another machine while the GUI stays where it is.

- `crates/tty7-core`: wire protocol, session daemon, PTY, native SSH engine and
  the domain model, with no gpui dependency. Module paths are unchanged.
- `crates/tty7-server`: the same daemon with no GUI attached, linked fully
  static against musl and pushed onto the remote box. One dependency, on
  purpose — a second one the GUI also needs belongs in core.
- `Host` trait + `HostId`/`HostRegistry`: every fs/git/watch call a workspace
  makes goes through the machine it belongs to. `LocalHost` answers on this
  box, `RemoteHost` over a routed control connection.
- `ui::host_ops`: the GUI's single door to a `Host`. Host calls block, so all
  of them run on the background executor with the result landed on the UI
  thread; de-duplication, staleness and error reporting live here rather than
  at each call site. Enforced by a CI grep.
- Connect flow: home page → pick a configured SSH host → the machine's own
  workspace list → a window bound to one workspace on it. Workspace switcher
  groups by machine, this computer included.
- CI: static musl builds of `tty7-server` for x86_64/aarch64 via
  cargo-zigbuild, a host-boundary grep, and version stamping factored out of
  the nightly workflow. Both new jobs are non-required so branch protection
  does not wedge open PRs.

Design and the interface contract it was built to are in
`docs/2026-07-27-remote-workspace-{design,impl-contract}.md`.
2026-07-28 10:59:46 +08:00
thomasandClaude Opus 5 0be3b67640 fix(render): stop italic CJK rendering as unrelated CJK on Windows
Every character came out as a different character, one for one, consistently
— it read as a broken locale or a mangled encoding, and it was neither.

Hack, the bundled default, has no CJK, so those cells are shaped through the
font-fallback chain. gpui's Windows backend then threw away the face
DirectWrite shaped the run with and looked a fresh one up by family, weight and
style. That round trip mapped DirectWrite's italic to oblique — the enum is
numbered OBLIQUE = 1, ITALIC = 2, and the mapping had them the other way around
— so an italic fallback face resolved to a request for an oblique one, and a
family with no oblique face (Maple Mono NF CN, first in our Windows chain) came
back as its upright face instead. The glyph indices were right; the outlines
they indexed belonged to a different face, at a fixed glyph-id skew.

Fixed upstream in our gpui fork by registering the face DirectWrite actually
chose rather than re-deriving one, which also closes a latent use-after-free in
the same cache: it keyed fonts by a raw pointer to a face nothing held a
reference to, so a released face could be aliased by any later allocation.

Bumps the fork pin; no tty7 code changes. Covered there by two tests in
`gpui_windows::direct_write` — one asserting a shaped run's glyphs round-trip
through the font id the run reports, one asserting every font-face cache key is
owned by the font it maps to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 08:42:08 +08:00
dependabot[bot] 4da5670882 deps: bump sha2 from 0.10.9 to 0.11.0
Bumps [sha2](https://github.com/RustCrypto/hashes) from 0.10.9 to 0.11.0.
- [Commits](https://github.com/RustCrypto/hashes/compare/sha2-v0.10.9...sha2-v0.11.0)

---
updated-dependencies:
- dependency-name: sha2
  dependency-version: 0.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-27 09:34:04 +00:00
dependabot[bot] 612e2aafe5 deps: bump resvg from 0.45.1 to 0.47.0
Bumps [resvg](https://github.com/linebender/resvg) from 0.45.1 to 0.47.0.
- [Release notes](https://github.com/linebender/resvg/releases)
- [Changelog](https://github.com/linebender/resvg/blob/main/CHANGELOG.md)
- [Commits](https://github.com/linebender/resvg/compare/v0.45.1...v0.47.0)

---
updated-dependencies:
- dependency-name: resvg
  dependency-version: 0.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-27 09:33:40 +00:00
dependabot[bot] 679ce82eea deps: bump the cargo-minor-patch group with 4 updates
Bumps the cargo-minor-patch group with 4 updates: [ignore](https://github.com/BurntSushi/ripgrep), [tokio](https://github.com/tokio-rs/tokio), [tray-icon](https://github.com/tauri-apps/tray-icon) and [libc](https://github.com/rust-lang/libc).


Updates `ignore` from 0.4.26 to 0.4.31
- [Release notes](https://github.com/BurntSushi/ripgrep/releases)
- [Changelog](https://github.com/BurntSushi/ripgrep/blob/master/CHANGELOG.md)
- [Commits](https://github.com/BurntSushi/ripgrep/compare/ignore-0.4.26...ignore-0.4.31)

Updates `tokio` from 1.53.0 to 1.53.1
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.53.0...tokio-1.53.1)

Updates `tray-icon` from 0.24.1 to 0.24.2
- [Release notes](https://github.com/tauri-apps/tray-icon/releases)
- [Changelog](https://github.com/tauri-apps/tray-icon/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/tray-icon/compare/tray-icon-v0.24.1...tray-icon-v0.24.2)

Updates `libc` from 0.2.186 to 0.2.189
- [Release notes](https://github.com/rust-lang/libc/releases)
- [Changelog](https://github.com/rust-lang/libc/blob/0.2.189/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/libc/compare/0.2.186...0.2.189)

---
updated-dependencies:
- dependency-name: ignore
  dependency-version: 0.4.31
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-patch
- dependency-name: tokio
  dependency-version: 1.53.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-patch
- dependency-name: tray-icon
  dependency-version: 0.24.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-patch
- dependency-name: libc
  dependency-version: 0.2.189
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-27 09:33:15 +00:00
l0ng-ai 0030b98faa chore(release): v26.7.5 2026-07-27 14:00:22 +08:00
l0ng-ai f5b4a65ec4 fix(terminal): render emoji presentation sequences at their real width
An emoji written as base + U+FE0F rendered wrong twice over. Both halves
came from the variation selector arriving as a zero-width combining mark
after the column budget was already spent.

Width, in the `alacritty_terminal` pin (bumped to the fork's b79e704):
`input` reserves columns one `char` at a time, so a base whose East
Asian Width is Neutral -- U+2764 in `❤️`, U+1F5C2 in `🗂️`, U+26A0 in
`⚠️` -- kept the single column it was given, its glyph bled over the
next cell, and every column after it on the line shifted left by one.
The fork re-scores the sequence with `UnicodeWidthStr`, which is where
UTS #51's width-2 rule lives, and widens the cell to match.

Presentation, here: `snapshot_cell` copied only `cell.c` into
`RenderCell`, so `cell.zerowidth()` was dropped before the shaper ever
saw it. `❤` and `❤\u{FE0F}` reached gpui as the same string and picked
the same text-presentation face -- a black heart where every other
terminal shows a red one. `RenderCell` now carries the marks and a new
`RowSeg::Cluster` shapes them with their base. That restores every
combining mark, not just the selectors: `e` + U+0301 was being dropped
the same way.

A marked cell never joins a batched run. Marks add characters without
adding columns, which is exactly the correspondence `force_width` uses
to pin one glyph per column in a `Run` or `Wide` segment.

Fixes #203.
2026-07-27 10:35:58 +08:00
l0ng-ai ecb5ab1ed7 fix(input): keep Option chords out of the IME when Option is Meta
Option-as-Meta had no effect for anyone typing with a CJK input source.
The whole setting was dead for them: with Pinyin selected, macOS reports
Option chords as printable text (Option+B composes the special
character), so gpui routed them to the IME before the key handler ran.
The IME committed the composed character and swallowed the event --
on_key_down never ran, and reshape_option_keystroke never got a say.
Switching to ABC made it work again, which is why this looked
intermittent. Verified on the wire: with Pinyin active Option+F/Option+B
put c692 / e288ab on the PTY where ESC f / ESC b belong.

The routing decision lives in gpui's macOS backend and is asked once per
view, with no keystroke in hand, so it could not answer "IME for text,
but not for this chord". gpui now comes from our fork, whose one commit
passes the keystroke to prefers_ime_for_printable_keys; the default
implementation ignores it, so no existing handler changes behavior. The
terminal answers per key: an Option chord with the setting on stays on
the dispatch path, everything else still prefers the IME.

Gated on the setting deliberately. With Option-as-Meta off the chord is
text input and the IME is the right owner -- it is what makes dead keys
(Option+E then E -> e-acute) compose at all.

The fork is wired in with [patch] on the source rather than by editing
the gpui pins, because gpui-component declares its own gpui from the
upstream URL and a pin swap would put two incompatible copies of gpui in
the tree. Fetching a repo that size needs the git CLI; cargo's built-in
libgit2 transfer times out partway through.

Fixes #177
2026-07-26 20:31:32 +08:00
l0ng-ai 311d9285ff fix(deps): pin a patched alacritty to survive deep keyboard-mode pushes
Enabling `kitty_keyboard` (#184) made an upstream `alacritty_terminal` bug
reachable from any foreground program. `push_keyboard_mode` caps its stack by
removing from `title_stack` instead of `keyboard_mode_stack` -- a copy-paste
slip from `push_title` that compiles because both are `Vec`s and the removed
value only feeds a `trace!`.

Two consequences. Each overflowing push silently drops a saved window title, so
a later XTPOPTITLE restores the wrong one. And once the title stack is empty,
`Vec::remove(0)` panics: 4097 unpopped `CSI > 1 u` pushes -- roughly 20KB of
output -- kill the `tty7-remote-reader` thread and freeze the pane. The depth
cap never trimmed `keyboard_mode_stack` at all, so it was doing nothing.

Hostile output is not required. A TUI that pushes without popping (per redraw,
per keypress) reaches 4096 on its own in a long session.

Pin our fork of Zed's fork instead: `tty7` is Zed's `fcf32fe` plus the one-word
fix. No `[patch]` section is needed -- tty7 is the only crate in the tree that
depends on `alacritty_terminal`, so a plain URL/rev swap cannot split it into
two incompatible copies the way the gpui pin would.

The regression test guards the pin rather than our own code: it pushes past the
4096 cap and then queries, using the reply as a liveness probe. Verified to fail
against the unpatched rev.

Still present on alacritty master as of 852e971. Drop the fork once it lands
upstream.
2026-07-26 19:11:39 +08:00
l0ng-ai 40003ff423 chore(release): v26.7.4 2026-07-26 10:27:55 +08:00
l0ng-ai 268bf7d7e5 chore(release): v26.7.3 2026-07-25 15:38:52 +08:00
l0ng-ai 73588663a0 fix(chrome): give every icon tile the same soft hover fill
The chrome tiles — sidebar collapse, the detail-panel toggle, the "⋯" menu,
the panel's own tabs — had no hover state at all. `chrome_tile_variant` set
one, but gpui-component derived the custom variant's hover from its resting
`color` and ignored the colour the caller named; that resting colour is
transparent, and transparent shaded by transparent is still transparent. The
fork now reads `hover`/`active` (l0ng-ai/gpui-component@469ab7a, picked up in
Cargo.lock), so the value here paints for the first time.

With it painting, the old `sidebar_accent.opacity(0.55)` was too faint to see
on a light background. Hover is now that fill at full strength — a 12% mix,
exactly #E2E2E2 on the default light theme, the same grey the sidebar rows
use — and selected/pressed drop one step to `list_active` so a lit toggle
still reads as on under the pointer.

The sidebar's "+" was a `ghost()` button, whose hover is a heavier grey mixed
a different way; it joins the tiles so the whole chrome answers alike.
2026-07-24 18:25:02 +08:00
l0ng-ai 9a4d818b78 refactor(editor): drop the LSP client entirely
Opening a `.rs` file in the code panel silently spawned rust-analyzer,
which then indexed the whole workspace — hundreds of megabytes of RAM and
a busy core — with no setting to turn it off. A terminal emulator should
not do that to its user on a click, and rather than add a flag to disable
something nobody asked for, the integration goes.

Removed: the JSON-RPC client and reader thread (`ui::lsp`), the per-server
registry, the completion / hover / definition providers installed on the
buffer, document sync (didOpen/didChange/didSave/didClose), diagnostics,
Go to Definition (F12), Find References (⇧F12) and its drawer, and the
status bar's server indicator. With them go the `lsp-types`, `ropey` and
`url` dependencies — all three were used only by this code (they remain in
the lock file as transitive deps of gpui-component and gpui, which is
expected).

Kept, and deliberately so:

- **Syntax highlighting**, which is tree-sitter, not LSP: gpui-component's
  `tree-sitter-languages` feature, `InputState::code_editor(language)` and
  `language_for_path` are all untouched. It is static, in-process, and
  costs nothing beyond parsing the open buffer.
- ⌘S save, dirty tracking, the external-change watcher and its conflict
  banner, markdown preview, soft wrap, and open-from-the-file-tree.

The module header now records *why* there is no language server, so the
next person to reach for one finds the reasoning instead of a gap.

Net −975 lines.
2026-07-24 17:56:02 +08:00
l0ng-ai fd1062f564 fix(right-panel,editor): restore the git dependency and address review findings
The branch had `gpui-component` pointed at a sibling checkout by absolute
path, which is why every CI job failed at manifest load. Point it back at
the fork's `tty7` branch (now carrying the custom-button label-color fix
the chrome tiles depend on) with the `tree-sitter-languages` feature, and
re-lock.

Review fixes on top:

- **Changes tab churned.** `right_panel_invalidate` dropped the cached
  diff on every `GitStatusCache` notification — including unrelated
  repos' — so the list blanked to "Loading…" and spawned a fresh
  `git diff` several times a second while a pane produced output.
  Replaced by `right_panel_refresh_changes`, which compares branch and
  totals first and re-probes in place, mirroring the diff overlay.
- **Changes tab could wedge on "Loading…".** A probe dropped because the
  cwd changed mid-flight left `diff_cwd` set and `diff` empty, and the
  render path only spawns when the cwd *changes* — so nothing re-probed.
  Spawn when nothing is cached and nothing is in flight.
- **Find references blocked the UI thread.** `cx.spawn_in` runs on the
  main thread; the up-to-200 `read_to_string`s for the row previews now
  run on the background executor, as the comment already claimed.
- **LSP frames could be lost or reordered at startup.** `send` checked
  `ready` outside the `queued` lock, so a frame could park behind a
  handshake that had just finished and never go out. `ready` now flips
  under that lock in `mark_ready_and_flush`.
- `MarkScanner`'s ESC-in-payload branch bypassed the payload cap, so a
  stream of bare ESCs inside an unterminated OSC grew the buffer without
  bound.
- The file tree's search frontier used `Vec::remove(0)`; a wide tree made
  that quadratic. `VecDeque`.
- `procs()` documented a pane check it didn't make; it takes the pane id
  and makes it.
- Four doc comments had been orphaned onto newly inserted functions
  (`pty`, `smooth_scroll`, `foreground_agent`, `file_expanded`).
2026-07-24 17:07:07 +08:00
l0ng-ai 649fdef51e Merge branch 'main' into worktree-code-panel
# Conflicts:
#	src/core/config.rs
#	src/ui/mod.rs
#	src/ui/tab_sidebar.rs
#	src/ui/tab_strip.rs
2026-07-24 15:15:08 +08:00
l0ng-ai 9c90044c05 chore(deps): resync Cargo.lock with Cargo.toml and lock it in CI
Two lockfile-only dependabot bumps (#139, #140) raised resvg to 0.47.0 and
sha2 to 0.11.0 in Cargo.lock without touching Cargo.toml, which asks for
`resvg = "0.45"` and `sha2 = "0.10"`. Under cargo's 0.x rules the minor
version is the major, so neither requirement accepts the locked version and
the lockfile has been self-contradictory ever since:

    $ cargo metadata --locked
    error: cannot update the lock file ... because --locked was passed

Nothing failed loudly — CI never passed `--locked` — so the cost landed on
contributors instead: every local cargo invocation rewrote the lock, leaving
a permanently dirty working tree to discard before each commit.

Resyncing drops the duplicates too. gpui-component already pulls resvg
0.45.1, so the tree no longer builds two copies each of resvg, usvg,
tiny-skia, tiny-skia-path, kurbo, svgtypes, roxmltree, imagesize and
polycool.

CI now builds and tests with `--locked` so the next such drift fails in the
PR rather than in a working tree. The release and nightly workflows keep
their unlocked builds on purpose: both stamp Cargo.toml's version and depend
on cargo refreshing the lock's root entry.
2026-07-23 16:26:21 +08:00
l0ng-ai b98e9c4ea5 chore(release): v26.7.2 2026-07-21 18:50:41 +08:00
l0ng-ai ac27b4a821 Merge pull request #139 from l0ng-ai/dependabot/cargo/cargo-minor-patch-ce6b99fb33
deps: bump the cargo-minor-patch group with 8 updates
2026-07-21 12:24:53 +08:00
l0ng-ai c5b97fdfa9 Merge pull request #140 from l0ng-ai/dependabot/cargo/sha2-0.11.0
deps: bump sha2 from 0.10.9 to 0.11.0
2026-07-21 12:24:45 +08:00
dependabot[bot] e91e706138 deps: bump resvg from 0.45.1 to 0.47.0
Bumps [resvg](https://github.com/linebender/resvg) from 0.45.1 to 0.47.0.
- [Release notes](https://github.com/linebender/resvg/releases)
- [Changelog](https://github.com/linebender/resvg/blob/main/CHANGELOG.md)
- [Commits](https://github.com/linebender/resvg/compare/v0.45.1...v0.47.0)

---
updated-dependencies:
- dependency-name: resvg
  dependency-version: 0.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-20 09:35:36 +00:00
dependabot[bot] 33bebccaa0 deps: bump sha2 from 0.10.9 to 0.11.0
Bumps [sha2](https://github.com/RustCrypto/hashes) from 0.10.9 to 0.11.0.
- [Commits](https://github.com/RustCrypto/hashes/compare/sha2-v0.10.9...sha2-v0.11.0)

---
updated-dependencies:
- dependency-name: sha2
  dependency-version: 0.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-20 09:35:10 +00:00
dependabot[bot] 6848054da3 deps: bump the cargo-minor-patch group with 8 updates
Bumps the cargo-minor-patch group with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [regex](https://github.com/rust-lang/regex) | `1.12.4` | `1.13.1` |
| [uuid](https://github.com/uuid-rs/uuid) | `1.23.4` | `1.24.0` |
| [keyring](https://github.com/open-source-cooperative/keyring-rs) | `4.1.4` | `4.1.5` |
| [tokio](https://github.com/tokio-rs/tokio) | `1.52.3` | `1.53.0` |
| [jieba-rs](https://github.com/messense/jieba-rs) | `0.10.2` | `0.10.3` |
| [anyhow](https://github.com/dtolnay/anyhow) | `1.0.103` | `1.0.104` |
| [serde](https://github.com/serde-rs/serde) | `1.0.228` | `1.0.229` |
| [serde_json](https://github.com/serde-rs/json) | `1.0.150` | `1.0.151` |


Updates `regex` from 1.12.4 to 1.13.1
- [Release notes](https://github.com/rust-lang/regex/releases)
- [Changelog](https://github.com/rust-lang/regex/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/regex/compare/1.12.4...1.13.1)

Updates `uuid` from 1.23.4 to 1.24.0
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.23.4...v1.24.0)

Updates `keyring` from 4.1.4 to 4.1.5
- [Release notes](https://github.com/open-source-cooperative/keyring-rs/releases)
- [Commits](https://github.com/open-source-cooperative/keyring-rs/compare/v4.1.4...v4.1.5)

Updates `tokio` from 1.52.3 to 1.53.0
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.52.3...tokio-1.53.0)

Updates `jieba-rs` from 0.10.2 to 0.10.3
- [Release notes](https://github.com/messense/jieba-rs/releases)
- [Commits](https://github.com/messense/jieba-rs/compare/v0.10.2...v0.10.3)

Updates `anyhow` from 1.0.103 to 1.0.104
- [Release notes](https://github.com/dtolnay/anyhow/releases)
- [Commits](https://github.com/dtolnay/anyhow/compare/1.0.103...1.0.104)

Updates `serde` from 1.0.228 to 1.0.229
- [Release notes](https://github.com/serde-rs/serde/releases)
- [Commits](https://github.com/serde-rs/serde/compare/v1.0.228...v1.0.229)

Updates `serde_json` from 1.0.150 to 1.0.151
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.150...v1.0.151)

---
updated-dependencies:
- dependency-name: regex
  dependency-version: 1.13.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-minor-patch
- dependency-name: uuid
  dependency-version: 1.24.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-minor-patch
- dependency-name: keyring
  dependency-version: 4.1.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-patch
- dependency-name: tokio
  dependency-version: 1.53.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-minor-patch
- dependency-name: jieba-rs
  dependency-version: 0.10.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-patch
- dependency-name: anyhow
  dependency-version: 1.0.104
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-patch
- dependency-name: serde
  dependency-version: 1.0.229
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-patch
- dependency-name: serde_json
  dependency-version: 1.0.151
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-20 09:34:47 +00:00
l0ng-ai 88c694df9e feat(terminal): smart double-click selection with CJK segmentation
- Double-click expands to the whole URL, email, file path, sci-notation
  number, identifier chain, OSC 8 hyperlink run, or matching bracket/quote
  pair containing the clicked word. Candidates only ever grow the plain
  word selection, so nothing regresses below the stock word behavior.
- Chinese segments with jieba's dictionary on all platforms; Kana/Hangul
  use CFStringTokenizer on macOS. The table builds lazily on a background
  thread so the first double-click never pays the cost.
- Latin words glued to CJK text narrow to the clicked script's sub-run
  instead of selecting the mixed blob.
- Bracket pairs (ASCII and full-width) and symmetric quotes (parity
  matched) select through their match, in the grid and prompt editor alike.
- Shift+click extends the existing grid selection instead of restarting.
- Word separators are configurable (word_separators, shared by grid and
  prompt editor); new 'Smart selection' toggle in Settings > Terminal.
2026-07-17 19:11:52 +08:00
l0ng-ai 96ff361209 chore(release): v26.7.1 2026-07-17 16:42:28 +08:00
l0ng-ai 6af47dfb53 feat(brand): "Duo" logo refresh — mint panes mark across all icon assets
Replace the orange window-and-cursor identity with the "Duo" mark: two
offset session panes (mint #3FDD8C behind, ink #17171A in front) with a
prompt chevron, on a cream tile following the Big Sur icon grid.

- app-icon.svg is the master; app-icon.png, tty7.icns and favicon.ico
  are re-rendered from it (rsvg-convert + iconutil + PIL)
- logo.svg/logo.png/logo@256.png carry the tile-less transparent mark
- tray.svg redrawn as the same mark in template form: solid front pane
  with the chevron masked out, back pane at partial alpha
- bare (non-bundled) macOS binaries now set the Dock icon at runtime
  via NSApplication.setApplicationIconImage, so cargo dev/run shows the
  logo instead of the generic executable icon
- README version badge and the social preview switch to the new brand
  green; social preview copy re-aligned with the current README slogan
2026-07-17 16:31:41 +08:00
l0ng-ai 2f1978618d refactor(code-panel): per-tab panel state, diff-overlay style
The panel's open files, tree roots/expansion/selection, and visibility now
live on Tab.code (same contract as Tab.diff_overlay): only the active tab's
panel renders, switching tabs shows that tab's own panel (or none), and
closing the tab drops its state. Hiding via Esc keeps the tab's open files.

Shared infrastructure stays app-global: directory-listing and gitignore
caches (path-keyed, tab-agnostic), the LSP registry, and single watchers
over the union of every tab's roots / open files. External-change reloads
and diagnostics now fan out to every buffer of the path across tabs.
2026-07-17 11:18:25 +08:00
l0ng-ai 5d58aae9d1 Merge remote-tracking branch 'origin/main' into worktree-code-panel 2026-07-17 10:26:06 +08:00
l0ng-ai acb461a094 feat(code-panel): local file tree, code editor panel, and LSP client
- File tree (left column): lazy per-directory listing with notify-driven
  refresh, gitignore chain matching (dimmed italics), keyboard nav, inline
  new-file/new-folder/rename, context menu (open / cd / insert path /
  attach-to-agent / copy path / reveal / delete), multi-root from the active
  tab's pane cwds, rows draggable into the terminal as ExternalPaths.
- Code editor (right column): gpui-component CodeEditor mode (tree-sitter
  highlighting, line numbers, folding, find/replace), file tabs with dirty
  markers, cmd-S save, external-change reload with conflict banner, markdown
  preview, soft-wrap toggle.
- LSP: stdio JSON-RPC client per (server, workspace root) for rust-analyzer /
  gopls / pyright / tsserver / clangd; completions, hover, diagnostics,
  same-file cmd-click definitions, F12 cross-file goto, shift-F12 references
  drawer.
2026-07-17 10:25:45 +08:00
l0ng-ai e1c0ed332e chore(release): v26.7.0 2026-07-16 23:40:31 +08:00
thomasandClaude Opus 4.8 ab91f910f3 feat(terminal): paste clipboard images as a file path off macOS
Pasting a screenshot into a coding-agent pane (Claude Code &co.) did
nothing on Windows/Linux. The clipboard-image branch only forwarded SYN
(0x16), relying on the agent to read the OS clipboard itself when it sees
Ctrl+V. That works on macOS, but Claude Code silently drops raw
screenshots off macOS (anthropics/claude-code#26679), so nothing landed.

gpui already hands us the image bytes via ClipboardEntry::Image, so off
macOS we now stage the image to a temp file and paste its shell-escaped
path — the same route drag-and-drop uses, which agents attach reliably.
Windows screenshots arrive as BMP (CF_DIB), which agent vision rejects,
so those are transcoded to PNG; PNG/JPEG/GIF/WebP pass through untouched.
The temp filename is keyed on gpui's content hash so re-pasting one image
reuses a single file. macOS keeps the higher-fidelity SYN path, and a
staging failure there falls back to SYN too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:10:27 +08:00
l0ng-ai 040f4e35a1 feat(tray): system tray icon with agent status menu
A cross-platform tray / menu bar status item: the icon flips to an
attention state when any coding agent blocks on input, and its menu
lists agent panes (brand avatar + status dot, click to reveal),
switches the notification policy, forces an update check, and offers
Quit and Stop Daemon alongside the session-keeping plain quit.

macOS/Windows use tray-icon (muda menus, main-thread NSStatusItem);
Linux deliberately uses ksni (pure-Rust SNI over zbus) instead of
tray-icon's GTK+libappindicator backend so the AppImage stays lean,
with a slow-backoff retry for SNI hosts that appear after login.
Bitmaps are rasterized at runtime with resvg (already in the tree).

Gated by show_tray_icon (default on) with a Settings toggle; the 1s
foreground poll re-reads it, so toggles and config.json hot-reloads
apply live.
2026-07-16 14:08:03 +08:00
l0ng-ai b079f91caf chore(release): v0.17.0 2026-07-16 11:54:10 +08:00
l0ng-ai f9dba021fc style(ui): macOS Sonoma-style popup panels — pill highlight, taller palette search, whole-row viewport
- Bump gpui-component fork (562341a, ff8af95): menus get a 6px rounded pill
  highlight inset by 5px panel padding, inset hairline separators, a 10px
  panel radius with a floatier shadow; searchable lists get a taller
  Spotlight-style search row with 15px query text.
- palette: card matches the menu panel (10px radius, shadow_xl); rows are
  fixed-height inset pills; the list viewport holds a whole number of rows
  so the card's bottom edge never cuts the last visible row mid-height.
2026-07-16 10:25:23 +08:00
l0ng-ai 8d4b067a82 chore(release): v0.16.1 2026-07-15 23:00:52 +08:00
l0ng-ai f678469aef chore(release): v0.16.0 2026-07-15 22:06:23 +08:00
l0ng-ai 1756bcc3a5 chore(release): v0.15.0 2026-07-15 19:48:01 +08:00
l0ng-ai ed244680bc chore(release): v0.15.0-beta.1 2026-07-15 16:48:16 +08:00
ayamirandl0ng-ai e44855c3b6 feat(ssh): support Unix GSSAPI auth (#81)
* feat(ssh): support gssapi auth

* fix(ssh): pin the russh patch to an exact rev + fail on a stalled gssapi context

- [patch.crates-io] now pins rev 0d1d073 instead of tracking the fork's
  branch: russh is the credential-handling SSH protocol layer, and a
  moving branch would let `cargo update` silently pull unreviewed code.
  Documented the removal condition (upstream russh PR #737 releasing).
- gssapi_step: an incomplete context with no output token used to claim
  GssapiStep::Complete without a MIC, which servers reject with an opaque
  failure; return an error naming the stall instead.
- auth.rs module doc: include gssapi-with-mic in the Auto ordering.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-15 14:32:42 +08:00
l0ng-ai 7bc388f923 chore(release): v0.14.0 2026-07-14 17:05:42 +08:00
l0ng-aiandl0ng-ai 41fbed1e30 fix(ui): SSH auth-sheet polish + softer primary buttons (#79)
* fix(ui): SSH auth-sheet polish + softer primary buttons

- Remember toggle: real Checkbox instead of a full-width ghost button whose
  selected-state fill read as a grey bar across the whole card
- auth sheet: tighten to the shared sheet padding (p_4 / gap_3), width 420
- dead-SSH pane: replace the top-left chip + reconnect notice (which overlaid
  the daemon's red failure line printed at top-left) with a single
  bottom-centered 'Disconnected — ⌘⇧R · Reconnect' bar, clear of the output
- drop the connecting/authenticating top-left SSH chip entirely (the tab
  status dot carries the phase; the buffer shows connect progress)
- soften primary buttons app-wide: fill from foreground nudged ~20% toward
  the background (a dark charcoal, not pure black) via the primary /
  button_primary token family

Pairs with l0ng-ai/gpui-component@9484cf9 (checkbox: instant check, no fade),
picked up by the Cargo.lock bump.

* fix(ui): SFTP parent row is a ".." directory entry, not a "Go up" action

The leading go-up row used an ArrowUp icon + muted "Go up" label, reading as a
toolbar action stranded in the list. Style it like a directory entry — Folder
icon + ".." name in the foreground ink — matching the rows below (the
file-manager/WinRAR convention). Click still goes to the parent.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-14 16:24:45 +08:00
l0ng-aiandl0ng-ai 1b613e90e2 feat(ssh): native russh connection manager (profiles, auth, forwarding, SFTP) (#74)
* feat(ssh): profile model, keychain vault, and ssh_config import (WS1 data layer)

Add the connection-manager data layer per PRD §7:

- core::ssh_profile: the SshProfile model (connection/auth/forwarding/session/
  advanced fields, uuid ids), HostPort/AuthMode/ForwardRule/Algorithms, and
  QuickConnect parsing (parse_quick_connect / to_connect_string, IPv6-bracket
  and @-in-username aware) plus %h/%r identity-file placeholder expansion.
- core::keychain: a CredentialStore trait over the OS keychain (keyring 4.x)
  with an in-memory test store, endpoint-keyed entries (tty7-ssh / tty7-ssh-key
  per PRD §7.2), and a secret-free CredentialRef persisted in config.
- core::ssh_config: import_profiles/merge_imported resolve common ssh_config
  fields (HostName/User/Port/IdentityFile/ProxyJump/ProxyCommand/ForwardAgent)
  with first-match-wins incl. wildcard fallbacks; Match/canonicalize skipped.
  discover_profiles is untouched. Import is repeatable/idempotent.
- Config gains #[serde(default)] ssh_profiles: Vec<SshProfile>.

Unit tests cover quick-connect parsing (IPv6/@/port bounds), placeholder
expansion, profile+config serde round-trip through disk, ssh_config import
parsing, and keychain mock behavior.

* feat(ssh): native russh session engine in the daemon (WS2)

Add a native (pure-Rust) SSH path for daemon panes, replacing shell-out
`ssh` for managed connections. A russh shell channel is bridged into the
existing pane byte pipeline so it is indistinguishable from a local PTY:
the reader thread, 8 MiB replay ring, OutputGate backpressure, and OSC
7/133 sniffer are reused unchanged. Only the handle-owning methods
(resize→window-change, kill→channel close, foreground pgid→None) dispatch
on a new PaneBackend seam.

Engine (`src/daemon/ssh/`):
- Per-daemon tokio runtime owning all russh connections; the rest of the
  daemon stays std-threads and crosses in via blocking Read/Write adapters
  over bounded/unbounded channels (backpressure reaches the SSH window).
- Connection registry keyed by host/port/user/proxy/jump chain with reuse
  (new tab = new channel, no re-auth) and documented blast-radius semantics.
- Transports: direct TCP, ProxyCommand (%h/%p/%r substituted), SOCKS5,
  HTTP CONNECT, and jump host via direct-tcpip (multi-level chains).
- Auth (Tabby-ordered): none-probe, publickey (multi-identity, %h/%r,
  .pub-misconfig skip, encrypted-key passphrase), agent, password,
  keyboard-interactive (zero-prompt quirk, password auto-fill).
- known_hosts: plaintext + hashed (HMAC-SHA1) + @revoked + @cert-authority
  skip; append preserves the file. Self-contained SHA-1/HMAC/base64.
- Interactive prompt broker: AuthPrompt/AuthResponse/SshStatus over the
  pane's connection; blocks auth with a 120s per-prompt timeout.

Protocol (`daemon::protocol`):
- New kinds: SPAWN_NATIVE_SSH(14), AUTH_RESPONSE(15) client->daemon;
  AUTH_PROMPT(13), SSH_STATUS(14) daemon->client. New kind so a pre-WS2
  daemon rejects rather than mis-spawns.
- NativeSshSpec wire type (redacted Debug + without_secrets), prompt/host-key
  enums, RemoteKind::NativeSsh.

Session restore: `SessionPane::Leaf.ssh_spec` (secret-free) so a dead
native pane can be respawned by WS6; live panes reattach for free.

Docs: `docs/ssh-native-architecture.md` (protocol, broker flow, the
connection-registry API WS4/WS5 use, and the forwards/X11/SFTP seams).

Tests: known_hosts parse/check/append, spec serde + redacted Debug,
ProxyCommand %h/%p substitution, blocking adapter EOF + backpressure,
connection-key identity, prompt-broker delivery/cancel. Full suite green.

* feat(ssh): GUI auth/host-key sheets, known_hosts management, spec resolution (WS3)

Workstream 3 of the native SSH connection manager: the GUI side of the
russh auth/host-key flow, known_hosts hardening + management, and pre-connect
credential resolution.

Client prompt plumbing (terminal/remote.rs):
- Handle DaemonMsg::AuthPrompt / SshStatus in the reader loop: queue prompts
  per pane (banners ride the same queue, id 0) and cache the spawn phase, waking
  the view. take_auth_prompt / has_pending_auth / ssh_phase / ssh_endpoint /
  auto_supplied_password accessors; respond_auth writes ClientMsg::AuthResponse.
- spawn_native_ssh client entry (retains endpoint + stored-password flag for the
  sheet), and list/delete_known_hosts one-shots.
- TerminalView emits AuthPromptReady; Tty7App subscribes at the single leaf
  build site (new_terminal) and drains prompts into the sheet.

In-pane auth sheets (ui/ssh_prompt.rs): password (masked + remember), key
passphrase (remember by key-content hash), keyboard-interactive/2FA (echo/no-echo
rows), unknown-host confirm, and a red CHANGED-key MITM warning whose default
action is ABORT — trusting requires typing "yes" (never auto-accept). Pure,
unit-tested state machine (PromptModel + submit/keychain decisions) under a thin
gpui layer; sheet keyed to the raising pane so tab switches never misroute it.

FR-A6: password_submit deletes the stored keychain entry ONLY in the
stored-password rejection path (a Password prompt after an auto-supplied
password) when the user declines to remember — a plain failed attempt never
clears a credential.

Pre-connect resolution (ui/ssh_connect.rs): build_native_ssh_spec resolves a
profile into a self-contained NativeSshSpec — keychain password/passphrases,
jump_host profile chain (cycle-guarded), identity placeholder expansion, proxy
precedence. The single place secrets enter a spec. (WS6 wires the UI entry.)

known_hosts hardening (daemon/ssh/known_hosts.rs): OpenSSH glob (*/?) + negation
matching, case-insensitive host compare, plus list/delete management preserving
the file byte-for-byte elsewhere. New protocol pair: ClientMsg::ListKnownHosts
(16) / DeleteKnownHost (17), DaemonMsg::KnownHostsList (15); daemon server
handlers; Settings "SSH → Known hosts" section + global verify_host_keys toggle.

Tests: known_hosts wildcard/negation/case/list/delete(byte-preserving); reader
surfaces AuthPrompt/SshStatus; spec builder password/jump/cycle/proxy/verify;
prompt state machine incl. the FR-A6 matrix; protocol round-trips.

* feat(ssh): SFTP file panel and transfer engine (WS5)

Add native-SSH SFTP on top of the WS2 russh engine.

Daemon (src/daemon/ssh/sftp.rs):
- One cached russh_sftp SftpSession per SshConnection (keyed by
  ConnectionKey, validated by Arc identity + liveness), reused across panes
  and transparently re-opened if the subsystem channel dies while the
  connection lives.
- list dir (symlink follow-stat to classify targets), stat, mkdir, remove
  file, recursive remove dir, rename, chmod, readlink.
- Background upload/download jobs: 256 KiB chunks, recursive dirs, temp-file
  upload (<name>.tty7-upload-<rand> then rename-over-target), mode
  preservation on download, cancellable, poll-based progress with a latching
  job state machine.

Protocol (src/daemon/protocol.rs): client kinds 30-34
(SftpList/SftpOp/SftpTransferStart/Cancel/List), daemon kinds 30-33
(SftpEntries/SftpOpResult/SftpTransferStarted/TransferProgress). Round-trip
tests for every new message.

Client (src/terminal/remote.rs): one-shot RemoteTerminal::sftp_* helpers.

UI (src/ui/sftp.rs): a right-docked slide-in panel for the focused native-SSH
pane -- breadcrumb bar, filter, dir-first entry list, toolbar (up / refresh /
new folder / upload / go-to-shell-cwd for FR-T4), per-row download / rename /
delete / chmod / follow-symlink, Finder drag-and-drop upload (on_drop
ExternalPaths) plus a file-picker fallback, and a bottom transfer tray that
polls progress every 500ms off the main thread. New ToggleSftp action +
keymap arm + palette 'SFTP Panel' entry.

Tests cover protocol round-trips, path utilities (join/parent/basename,
unicode), temp-name generation, entry classification, dir-first sort/filter,
breadcrumb split, and job state-machine transitions. No real-sshd needed.

* feat(ssh): native port forwarding — Local/Remote/Dynamic + loopback (WS4)

Add the WS4 port-forwarding engine on top of WS2's native russh session
engine. Forwards ride a pane's shared SshConnection (no ControlMaster
socket), keyed per pane for the UI and torn down on pane death.

Daemon engine (src/daemon/ssh/forward.rs):
- Local (FR-F1): TCP listener -> per-conn direct-tcpip -> bidirectional
  bridge with exact EOF/close propagation.
- Dynamic/SOCKS5 (FR-F1): hand-rolled minimal SOCKS5 (no-auth greeting,
  CONNECT for IPv4/IPv6/domain; BIND/UDP rejected) -> direct-tcpip.
- Remote (FR-F1): tcpip_forward global request + RemoteForwardTable
  consulted by the client Handler's server_channel_open_forwarded_tcpip;
  unmatched channels rejected; cancel_tcpip_forward on teardown.
- SshForwardRegistry keyed by pane_id; auto-teardown from DaemonPane::drop
  (covers the FR-C2 blast radius when a shared connection drops).
- Preconfigured forwards (FR-F2) established post-auth in run_session;
  failures are non-fatal (ForwardStatus::Error rows, never a killed session).
- Native loopback one-click (FR-F4): EnsureLoopbackForward branches on
  RemoteKind::NativeSsh to a Local direct-tcpip forward, same reply shape.

Protocol: AddForward/RemoveForward/ListForwards (client kinds 20-22) ->
ForwardList (daemon kind 20); ManagedForward/ForwardStatus wire types.

Client: RemoteTerminal::{add,remove,list}_forward one-shots; view.rs
can_forward_loopback also accepts native panes.

UI (src/ui/forwards.rs): native panes show managed forwards (L/R/D badge,
bind -> target, description, status, delete) + an add form with a segmented
kind selector, alongside the existing loopback list; shell-out panes
unchanged.

X11 (FR-X2) left as a documented seam in daemon::ssh::handler (P1).

Tests: SOCKS5 handshake (v4 reject, v5 CONNECT ipv4/domain/ipv6, BIND
reject), bridge EOF both directions, registry add/remove/teardown, and
protocol round-trips for the new messages.

* style: cargo fmt across ssh connection-manager workstreams

* feat(ssh): UX integration — native connect, palette entry, profile editor, session UX (WS6)

Make the SSH connection manager reachable and alive from the UI:

- Native SSH spawn keystone: TerminalView::new_native_ssh + Tty7App
  connect paths. Saved profiles connect via the native russh engine;
  use_system_ssh profiles fall back to the frozen shell-out path (FR-C5).
- Unified palette entry (FR-P3): saved profiles (frecency-ordered) +
  ~/.ssh/config aliases + live QuickConnect all in the root flow. Enter
  connects; Cmd-Enter / -> opens the profile editor. Per-profile frecency
  (count + last-used) persisted in config and used to rank rows.
- Profile editor (FR-P1/P5): full-window page like Settings, list + edit
  views with progressive disclosure (4 core fields; collapsed jump host,
  forwards, and advanced sections incl. the use_system_ssh compat toggle
  with its disabled-features note). Import from ssh_config, duplicate,
  delete, copy user@host:port, connect.
- Session UX (FR-E1..E4): in-pane phase-coloured SSH status strip with the
  reconnect notice; per-tab status dots in the strip and sidebar;
  warn-on-close confirm sheet (global toggle + per-profile override);
  RestartSshSession (Cmd-Shift-R) reconnecting a dead pane in place; and
  session-restore respawn of dead native panes (re-resolving secrets from
  the profile, else prompting).
- Actions/keymap/palette wiring for OpenSshProfiles and RestartSshSession.

* feat(ssh): consolidate paths — russh default, freeze system-ssh compat (WS7)

Make native russh the default for every non-compat SSH entry point and
confine the shell-out `ssh` path to a frozen compat escape hatch (PRD §3.1).

Entry-point routing (ui::app):
- Typed "SSH: Add Connection…": a bare `user@host[:port]` now takes the
  native QuickConnect path; only arg-bearing `ssh … -flags` lines (and bare
  tokens that only name a config alias) fall to the compat shell-out.
- `~/.ssh/config` alias rows route through a documented `open_compat_alias`
  funnel (same funnel as `use_system_ssh` profiles) and their palette
  subtitle now reads `~/.ssh/config · system ssh`.
- `open_managed_ssh_spec` documented as the single compat funnel; its only
  callers are the three deliberate escape hatches.

Freeze audit: module-level freeze notes on `SshSpec`,
`build_managed_ssh_command`/`SPAWN_MANAGED_SSH`, and `daemon::forward`
(ControlMaster loopback). Verified `daemon::forward` is reachable only from
compat panes (server branches `EnsureLoopbackForward` on `RemoteKind`); no
non-compat code depends on shell-out.

FR-C5 compat gating with a visible reason: SFTP toggle on a compat pane now
opens a short "unavailable" notice instead of silently no-op'ing; the Ports
panel shows a muted compat-mode line; managed L/R/D add-form stays
native-only.

Docs: Path policy section in ssh-native-architecture.md (WS6/WS7 seams
marked resolved); SSH connection manager feature section in README +
README.zh-CN.

* fix(ssh/sftp): harden downloads — path-traversal guard, atomic temp, scoped retry

Three SFTP fixes, all in the download/session path:

- Security (P0): reject server-supplied directory-entry names that aren't a
  single normal path component before using them as a local path component.
  A recursive download built `lpath.join(name)` straight from entry names, so
  a malicious/compromised server could return `..`, `a/b`, or an absolute
  `/etc/...` and escape the destination for arbitrary local file write with
  server-chosen mode bits (CVE-2019-6111 class). New `safe_local_name` guard is
  applied in both the download walker and the `remote_size` pre-pass so the size
  denominator matches what is actually transferred.

- Correctness: download to a per-file `<local>.tty7-download-<rand>` temp then
  rename over the target on success; on error/cancel remove the temp and leave
  any pre-existing target intact. Mirrors the upload temp+rename discipline so a
  failed download never truncates a local file in place. preserve_mode still
  applies to the final file.

- Correctness: `with_session` now retries the one re-opened-session attempt only
  on a transport/channel failure, not on a logical SFTP error (permission
  denied, no such file). A server status code returns directly instead of
  wasting a second identical round-trip.

Adds unit tests for safe_local_name, download_temp_path, and is_transport_failure.

* fix(ssh/known_hosts): @revoked takes precedence over an earlier trusted line

check_in_str returned Known on the first exact match, so a later @revoked line
for the same host+key was never reached and a revoked key could read as trusted.
Scan for revocation in a first pass across the whole file (a matching @revoked
line rejects the key regardless of a trusted match elsewhere), then run the
normal known/changed resolution. Adds a unit test with a trusted line followed
by a @revoked line for the same host+key asserting Revoked.

* fix(daemon/transport): tighten Unix socket perms now it carries SSH secrets

The daemon socket now conveys NativeSshSpec cleartext secrets, but the socket
file was left at umask-default perms, so a co-local user could connect. On Unix,
chmod the socket file to 0600 (connecting requires write permission on the node,
so this is the access boundary) and chmod the config dir to 0700 — but only when
the socket lives in the config dir tty7 owns, never the overlong-path fallback
under a shared $XDG_RUNTIME_DIR / temp dir. Best-effort: log at warn and continue
on failure. Windows loopback+token path is untouched (it already authenticates).

* fix(ssh): self-heal reuse of a connection whose transport silently died

mark_dead() only runs from Drop, but a parked forward/loopback accept loop holds
an Arc<SshConnection>, so a dead connection's Drop never runs and is_alive()
stayed true. A reconnect for the same ConnectionKey reused the dead russh handle,
the first channel-open errored, and the whole reconnect failed until forwards
were torn down.

Two complementary fixes:

- is_alive() now also consults the russh handle's own liveness via a non-blocking
  try_lock + handle.is_closed() (the session task ending closes its command
  sender), catching the stale-flag case cheaply.

- run_session treats the first shell-channel open on a *reused* connection as a
  liveness probe: on failure it marks the connection dead, evicts its registry
  slot, and reconnects fresh once (a fresh connection failing there is a real
  error). Preconfigured forwards now establish after this probe, on the
  confirmed-live connection. open_connection returns a `reused` flag to drive this.

Adds a unit test that evicting a key from the registry map clears its slot. The
end-to-end reuse-after-death path needs a live server, so it stays covered by E2E.

* resolve ssh_config aliases natively

Expand the ssh_config resolver to map the russh-mappable directives onto an
SshProfile: ConnectTimeout, ServerAliveInterval/CountMax, Ciphers, MACs,
KexAlgorithms, HostKeyAlgorithms, Compression, ForwardX11,
StrictHostKeyChecking (no -> verify_host_keys=false), and
LocalForward/RemoteForward/DynamicForward. Algorithm +/-/^ modifier syntax is
dropped rather than mis-applied; Match/canonicalize stay unevaluated.

Add resolve_alias_to_profile(_from) returning a transient in-memory profile
(fresh id, no group/credential) plus the raw ProxyJump target, so a config
alias can connect over the native engine.

* remove system-ssh compat mode; unify loopback on the native tunnel

There is no longer a shell-out `ssh` path. Every SSH entry point resolves to
the native russh engine:

- Delete the `use_system_ssh` profile field (old config.json still loads: the
  struct is `#[serde(default)]` with no `deny_unknown_fields`) and its
  profile-editor switch/note.
- Route `~/.ssh/config` aliases and typed connect lines to native. The typed
  parser now yields a transient profile + raw ProxyJump (native spec data), not
  a shell-out SshSpec; an unparseable line surfaces a dismissable inline banner
  instead of silently shelling out. Alias ProxyJump resolves recursively into a
  nested jump chain (config alias hops or user@host:port), with a cycle guard.
- Delete the FR-C5 compat gating UI (SFTP notice, forwards hint): SFTP and
  managed forwards are available on every native pane.
- Delete the daemon shell-out path: protocol `SshSpec`/`SPAWN_MANAGED_SSH`,
  `ShellSpec.ssh`, `build_managed_ssh_command`/`ssh_control_*`, and
  `daemon::forward` (the ControlMaster `ssh -O forward` engine).
- Loopback one-click forwards are native-tunnel-only (`direct-tcpip`):
  `can_forward_loopback` gates on `RemoteKind::NativeSsh`; the server
  Ensure/List/Close handlers drop the ControlMaster branch.
- `RemoteContext.control_path` is removed; the reader skips foreground-ssh
  detection for a pane already tagged `NativeSsh`. Foreground-ssh detection for
  a manually-typed `ssh` in a shell stays (status/label only).

* docs: native russh is the only SSH path

Rewrite the architecture doc's path policy (no shell-out / ControlMaster; the
sole path is russh; ~/.ssh/config aliases resolve natively, best-effort, with
Match/canonicalize/GSSAPI unsupported and no fallback), update the loopback
seam row, and drop compat-mode mentions. Sync the README (EN + zh-CN) SSH
sections to the single native path.

* fold SSH profile editor into Settings

Manage saved SSH profiles under Settings -> SSH instead of a parallel
full-window page, for UX consistency with the rest of the app.

The SSH settings section is now one scrollable page with three blocks:
Profiles (the saved-profile list plus an inline edit form, moved from the
standalone editor), then Known hosts, then the security toggles (verify
host keys / warn-on-close). The edit form keeps the same progressive
disclosure (name/host/user/auth up front; collapsible Jump host / Port
forwards / Advanced) and every field the old editor exposed, saving
through the same update_config path.

The edit form's widgets live in a lazily-built SshProfileForm on
SettingsState, rebuilt (a fresh input set) each time a profile is
selected so the section never carries N profiles' inputs at once.

Entry points now open Settings at the SSH section: the OpenSshProfiles
action and the "SSH: Manage Profiles..." palette entry via a new
open_settings_section helper; a profile row's edit affordance preselects
that profile via open_ssh_profile_in_settings; "save as profile" from a
quick-connect via open_ssh_profile_new_from_target. The palette connect
flow (Enter to connect, frecency) is untouched.

Deletes src/ui/profile_editor.rs, its module registration, and the
Tty7App profiles_editor field / overlay mount / render path.

* SSH pane: tunnel + SFTP icon buttons

Replace the top-right "Ports N" text chip with two minimalist icon
buttons for a connected native-SSH pane: a tunnel icon
(IconName::ExternalLink) that toggles the port forwarding panel and an
SFTP icon (IconName::Folder) that toggles the file browser. Both carry a
hover tooltip; the tunnel icon shows a small count badge when one or more
forwards are active.

The buttons are gated to a connected native pane via a new
active_connected_native_ssh_pane helper (RemoteKind::NativeSsh +
SshPhase::Connected), so a foreground `ssh` or a still-connecting session
shows only the top-left status strip. The forwards / SFTP panels
themselves are unchanged, and the ToggleSftp hotkey / palette entry stay
as an additional entry point. Status (strip / tab dots) stays separate
from actions (the buttons).

* fix(ssh): hide the in-pane SSH status chip once connected

The tab status dot already carries connection state and the top-right
tunnel/SFTP icons signal the pane is SSH, so a connected-state chip just
floats over the shell output. Keep the strip only while connecting and for
the post-drop reconnect notice.

* SFTP: per-row actions in a right-click context menu

* Settings SSH profiles: clean rows with hover ⋯ / right-click menu

* Settings SSH: two-column master-detail layout

* style(ssh settings): soften Add/Save buttons off the heavy primary fill

Match the existing soft-sheet convention (Duplicate-to-Edit, About's update
button): a solid near-black `.primary()` fill is too jarring against the
mostly-outline settings sheet. Use the subtle default fill instead.

* feat(ssh): 'Forget password' entry in the profile ⋯ menu

Deletes the keychain-stored password for the profile's endpoint
(user@host:port); the profile is untouched and the next connect re-prompts.
No-op when nothing is stored. Surfaces a window notification. Credentials are
endpoint-keyed, so this matches only when the profile pins an explicit user.

* SSH tunnel: merge loopback into a single unified forwards list

The tunnel panel stacked two parallel forwarding systems: a general
Local/Remote/Dynamic managed-forwards list and a separate
loopback (localhost links) section with its own add form, list, and
Refresh button. A loopback forward is just an auto-created Local forward
(127.0.0.1:<ephemeral> -> 127.0.0.1:<port>) minted when the user
Cmd-clicks a localhost:PORT link, so the separate UI and its parallel
backend bookkeeping were redundant.

Backend: ensure_loopback now registers the auto-forward in the same
managed registry as establish (a normal Local ManagedForward with a
'localhost link -> :<port>' description), so it shows up in
list(pane_id). It still returns the resolved local port in the existing
LoopbackForward reply shape, so the wire protocol is unchanged. Dedup is
preserved: a live auto Local forward to the same target is reused. The
parallel LoopbackEntry map and list_loopback/close_loopback are removed;
the ListLoopbackForwards/CloseLoopbackForward handlers stay wire-
compatible (now empty/no-op).

UI: delete the loopback section (form, rows, Refresh, empty state) and
its panel state/handlers. The single section is renamed 'Port
forwarding' and now includes the auto localhost forwards as Local rows.

* feat(ssh tunnel): X-icon close + editable forwards

- Panel close is now an X icon button (matching the SFTP panel) instead of a
  text button.
- Each forward row gains Edit: it loads the forward into the add form; Save
  re-establishes it (remove old + add new) so you can change bind/target ports
  like VSCode's remote tunnels. Cancel leaves edit mode.

* fix(ssh forward): free the listening socket synchronously on remove/teardown

* feat(sftp): tabby-style bottom panel — off-thread ops, new file, path input, transfers tray

Redesign the SFTP panel from a right-docked strip into a bottom-docked
panel modelled on tabby:

- Move blocking daemon round-trips (list / readlink / one-shot ops) onto a
  background executor so navigation never freezes the UI; a nav generation
  counter discards stale replies, and a loading flag distinguishes an
  in-flight listing from a genuinely empty directory.
- Add a CreateFile SFTP op (OPEN with CREATE|EXCLUDE) plus a "New file"
  toolbar action and inline edit form.
- Replace the breadcrumb toolbar with a compact ghost-icon action cluster
  and an always-visible search box; double-clicking the breadcrumb switches
  to a "type a path" text input (Enter navigates, Esc/blur cancels).
- Lead the list with a "Go up" row; enter directories on double-click
  (downloads stay explicit via the right-click menu).
- Rework the transfers tray: dismiss/auto-reopen on new jobs, a pinnable
  history view, and "Show in Finder" for finished downloads.

* fix(ssh): platform-split agent connect — russh connect_env is Unix-only

AgentClient::connect_env dials $SSH_AUTH_SOCK over a Unix-domain socket and
does not exist on Windows, breaking the windows-msvc build. Split try_agent
per platform (Unix keeps connect_env; Windows dials the OpenSSH agent named
pipe, honoring SSH_AUTH_SOCK as an override) and share the identity loop via
a stream-generic try_agent_identities.

* fix(ssh): review fixes — data-loss, security, and lifecycle bugs

Daemon/SFTP:
- user Rename no longer routes through rename_over: a refused overwrite was
  silently deleting the existing destination file
- recursive download/upload/size walkers classify children by lstat attrs and
  skip symlinks (cyclic links looped forever; a link to / copied the world)
- flush/shutdown failures now abort a transfer before the temp→target rename
  commits a truncated file over a good one
- the top-level download entry name passes the same safe_local_name guard as
  walked names (hostile server '..'/absolute names escaped ~/Downloads)

Host keys:
- a known host presenting a key type absent from known_hosts now raises the
  changed-key warning instead of the benign first-connect prompt
- verify_host_keys=false still hard-rejects @revoked keys (OpenSSH parity)
- known_hosts delete writes temp+rename instead of truncate-in-place

Auth:
- keyboard-interactive rounds are capped and a rejected stored password is
  no longer auto-refilled forever (users can now type the right one)
- host-key/auth prompts pause the connect timeout (a slow 'trust this
  fingerprint?' click no longer kills the connection under it)
- identity paths expand a leading ~ so keychain passphrase store/resolve
  works for ~/.ssh/... paths; keychain write failures are logged

Forwarding:
- duplicate remote forward registration is refused instead of overwriting the
  live entry (whose rollback then unroutably stranded the original forward)
- forwarded-tcpip port-only fallback no longer guesses between two bindings
- accept loops retry transient errors (EMFILE/ECONNABORTED) with backoff
  instead of dying while the UI still shows 'listening'

GUI lifecycle:
- native-SSH spawn failures return an error surfaced as a notification
  instead of panicking the app (incl. against a stale pre-SSH daemon, which
  now gets the same restart-once retry as local spawns)
- a dead native-SSH pane lingers for in-pane reconnect (PRD FR-C2/E4)
  instead of auto-closing with its diagnostic
- a second pane's auth prompt is left queued while another sheet is active
  (was popped and dropped → broker timeout) and picked up on dismiss

ssh_config:
- HostName %h expands to the alias; # only comments whole lines (a # inside
  a ProxyCommand value is literal)

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-14 12:54:40 +08:00