Commit Graph
36 Commits
Author SHA1 Message Date
Gabiandl0ng-ai e47b49dfdd fix(bundle): declare macOS TCC privacy keys for child processes (#323)
* fix(bundle): declare macOS TCC privacy keys for child processes

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

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

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

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

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

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

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

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

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 11:15:22 +08:00
l0ng-ai e1531cdea6 revert(windows): drop the taskbar status dot (#377)
The per-window taskbar overlay badge (#355, for #199) is removed, and with
it the in-flight follow-up that was making its green "finished a turn"
state reachable: the feature is not wanted. Nothing shipped — the badge
only ever existed in Unreleased — so this is a plain removal rather than a
deprecation, and its CHANGELOG entry goes with it instead of gaining a
"Removed" counterpart.

What goes: `ui::taskbar` and its `ITaskbarList3::SetOverlayIcon` poll, the
`taskbar_status_icon` config flag and its Settings → Window & Tabs row and
strings, `Tty7App::taskbar_signals`, `TerminalView::shell_busy` /
`RemoteTerminal::shell_busy` (the overlay was their only caller), the
`raw-window-handle` dependency and the `Win32_UI_WindowsAndMessaging`
feature it needed, and the feature docs in both languages. A stale
`taskbar_status_icon` left in someone's `config.json` is ignored, as any
unknown key is.

The tray badge and the in-window status dots are untouched; they were
always the ones the taskbar was mirroring.
2026-08-06 21:40:25 +08:00
27bb1864df feat(windows): taskbar status overlay per window (#355)
* feat(windows): taskbar status overlay per window (#199)

Stamp a colored status dot on each window's taskbar button using the
same palette as the in-window agent dots:
- blue while a shell command or agent is working,
- amber when an agent is waiting on the user,
- green when work finishes while the window is unfocused (cleared on activation).

Adds a `taskbar_status_icon` setting (default on, Windows only) and a
Settings -> Window & Tabs row. The overlay is updated by a foreground
poll that aggregates agent status and shell busy state across each
window's panes, diffing against the current taskbar badge and only
calling ITaskbarList3::SetOverlayIcon when the badge changes.

Includes unit tests for overlay priority and the done-while-unfocused
edge tracking.

* fix(taskbar): retry a failed overlay instead of caching it as drawn

Four fixes on top of the overlay:

- A failed SetOverlayIcon was still recorded in `shown`, so a badge the
  taskbar never took was remembered as drawn and never retried. Stamp now
  reports success, and a failure drops the interface so the next tick
  re-creates it — which is also what an Explorer restart needs.
- `create_failed` was a permanent latch: one CoCreateInstance failure
  killed the badge for the whole process, though Explorer may simply not
  be up yet when the first window opens. Use the tray's attempts/cooldown
  backoff instead, which this module otherwise copies.
- The overlay's accessibility description was hard-coded English in an
  app that localizes everything else. Reuse the panel and tray strings.
- Render the dot at 32px, not 16. SetOverlayIcon wants 16x16 at 96 dpi,
  so at 150%/200% scaling the shell upscaled a 16px icon; `tray::icon`
  already renders at 32 off macOS for the same reason.

Also drops the Win32_Graphics_Gdi feature: CreateIcon, DestroyIcon and
HICON all live in Win32_UI_WindowsAndMessaging, and the build and the
taskbar tests pass without it.

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

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: l0ng-ai <ysdpk123@gmail.com>
2026-08-06 10:12:41 +08:00
l0ng-aiandl0ng-ai 2fa518a767 refactor(settings): rescope the About page (#350)
About had grown three sections that change system state and that nobody
looks for under "About": a PATH install, a registry write, and a daemon
restart. Two of them move out.

The `tty7` CLI goes to Agents. That page already describes tty7 <-> agent
integration in one direction (hooks reporting session status); the CLI is
the other direction, and its own description leads with "so scripts and
coding agents can drive tty7". The Loading and Unavailable arms there no
longer return early, since the CLI toggle is about this GUI's own host
rather than whichever machine the hook rows describe.

The Windows Explorer context menu goes to the installer, which is where
VS Code and Git for Windows put theirs: writing shell verbs is an
install-time decision, not a runtime preference. A task checkbox drives
new `--register-explorer-menu` / `--unregister-explorer-menu` flags, so
the key layout stays in core::explorer_context_menu instead of being
copied into the .iss. `status()` existed only to paint the settings UI
and goes with it. The uninstaller unregisters unconditionally: an install
that registered once and was later upgraded without the box ticked still
holds keys that would otherwise point at a deleted exe.

Server restart stays — it is about the app itself.

Also fixes localization the About section had skipped: eight hardcoded
English strings in the update block now have keys, and the orphaned
SettingsCheckUpdatesDesc key (which still claimed "tty7 never updates
itself", contradicted by the macOS in-app updater) is reused for a
one-line description in place of a 60-word account of the updater's
internals.

Finally, terminology in the Chinese UI. hook, agent, worktree, diff and
fork are read and spoken in English by Chinese developers, so translating
them lost more than it gained. Scrollback was worse than a style
question: 回滚 means rollback, the opposite direction. 窗格 for pane is
kept — that one is standard.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-05 17:42:21 +08:00
8a342f2ca9 feat(ui): GUI localization for en and zh-Hans (#303)
* feat(ui): add GUI localization for en and zh-Hans

* feat(ui): localize search placeholders and relative time

* feat(ui): localize palette, switcher, and sftp strings

* feat(ui): localize home shortcut labels

* feat(ui): localize tray, ssh prompt, and editor strings

* feat(ui): add plural/select i18n helpers and localize sftp/settings labels

* feat(ui): localize settings search, forwards panel, and file tree

* feat(ui): localize code editor and right panel

* feat(ui): localize stop/delete workspace confirmations with plural support

* feat(ui): localize diff overlay with plural-aware summary

* feat(ui): localize pending pane, worktree prompt, and home time strings

* feat(ui): localize app menus, tray, tab strip/sidebar, and remote status strings

* feat(ui): localize switcher, file_tree, machine_mirror fallback strings

* feat(ui): localize ssh prompts, theme presets, host error wrapper, and finish remote strings

* feat(ui): localize command palette strings

* feat(ui): localize app.rs notifications, prompts, placeholders, and parse errors

* feat(ui): localize remaining theme, switcher, settings, and sftp strings

* style: cargo fmt

* feat(ui): add language selector to settings

* fix(ui): refresh locales across windows

* refactor(ui): make GUI language selection explicit

* fix(ui): localize Explorer settings after merge

* fix(ui): keep persisted theme names out of the GUI locale

A theme's name is data, not chrome: it is written into the theme YAML and
matched back with `trim_end_matches(" (custom)")`. Translating it meant a
Chinese GUI forked "Nord" into "Nord(自定义)", the next fork stacked a second
suffix on it, and the name stayed Chinese after switching back to English. The
derived-name fallback had the same problem. Both are English again.

Also in this pass:

- Give each test thread its own locale override. The locale is process-wide and
  tests run in parallel, so the two tests that switched to zh-CN could flip the
  language out from under another thread's English assertions.
- Rebuild the menu bar when gui_language changes in config.json, the way the
  in-app picker already does — otherwise the menus kept the old language.
- Document the values the setting actually accepts. The docs still described
  `auto` and `zh-Hans`, which sanitize() resets to `en`.
- Put the English words back into the Chinese search keywords for the language
  setting; the other 58 keyword sets keep them.
- Drop the unused is_zh_hans helper.

---------

Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-03 23:48:29 +08:00
l0ng-ai c396e85e0c docs(skill): launch workers interactively and wait on --changed
The taught loop started its worker with `claude -p`, but headless print
mode never stops to ask, so the `waiting` state steps 3-4 are built on
could not arrive. Step 2 now launches interactively.

Every wait after a send passes `--changed`, with the reason spelled out:
without it the loop re-reads the state it just walked in on. The exit-1
"worker died" branch is documented too, and both guards are asserted in
the skill-content test.

Also gives the install round-trip test a Drop guard, so a panic cannot
leave `CLAUDE_CONFIG_DIR` set for whatever runs next in the process.
2026-08-02 09:49:59 +08:00
yetoneandl0ng-ai e6bdf44f3c feat(cli): session CLI for scripting and agent orchestration (#248)
* feat(cli): `tty7 wait` + the agent-coordination note

The two pieces of the original session-CLI PR that main's own CLI
doesn't cover, rebuilt as a minimal delta against it.

`tty7 wait %N --until waiting,done --timeout 600` blocks until a pane's
agent reaches a requested state — the orchestration primitive that lets
one agent sleep until its peer blocks on a permission prompt or
finishes a turn, instead of screen-scraping. A poll of `AgentStates`
rather than an `events` subscription on purpose: a one-shot stateless
question composes into scripts, survives a server restart mid-wait, and
needs no cursor management. Agentless-but-live panes read as idle via
the machine tree; a dead or vanished pane reads as exit, which ends
every wait (matched only when asked for). Timeout exits 124, the
`timeout(1)` convention.

The coordination note is discovery for the whole CLI: a marked,
idempotent block describing the verbs, installed into
~/.claude/CLAUDE.md (always; CLAUDE_CONFIG_DIR honored) and
~/.codex/AGENTS.md (only when ~/.codex exists). A one-time "Let your
agents coordinate?" prompt fires the first time a pane detects a coding
agent; a Settings → Agents switch drives the same install/remove, with
state read from the files themselves. Uninstall strips exactly the
marked block; an unterminated block is left alone rather than truncated
at a guess.

* feat(agents): replace the global note with an orchestration skill

Per review: global instructions tax every session's context and hand
every agent — workers included — the ambient authority to orchestrate
its neighbours. The common shape is primary → workers: one agent owns
decomposition, dispatch, waiting and aggregation; workers just do
bounded tasks.

A Claude Code skill fits that exactly. `core::orchestration_skill`
installs ~/.claude/skills/tty7-orchestration/SKILL.md — only its
one-line description rides in context until the user or the primary
agent explicitly invokes it, and workers never see it. The body can
therefore afford the full delegation loop (tab new → send → wait →
answer-or-capture → pane close) instead of a token-starved cheat
sheet.

The file is wholly tty7-owned: install is a plain overwrite (also the
version-refresh path), and uninstall keys on an ownership marker so a
user's hand-written skill under the same name is refused, not deleted.
Gone with the global note: the first-agent-detected prompt, its config
flag, and the CLAUDE.md/AGENTS.md writers — the Settings → Agents
switch now drives the skill install instead.

---------

Co-authored-by: l0ng-ai <ysdpk123@gmail.com>
2026-08-02 09:37:09 +08:00
l0ng-ai 0c9f4baa3a fix(cli): make the PATH install reversible, honest, and safe to migrate
Follow-up on the review of #277. Seven fixes, no change to what the feature
is for.

An AppImage copy is now claimed with a marker file instead of being inferred
from "am I an AppImage right now". Keying off the runtime meant that a user
who moved from the AppImage to the tarball hit their own copy, read it as
somebody else's binary, and never got another install for as long as that file
sat there.

The Windows uninstaller takes {app} back out of HKCU\Environment. Nothing did
before: the entry is written by the app at runtime, so Inno never knew it
existed and every uninstall grew the user's PATH by one dead entry. Unix has no
equivalent hook and still leaves its symlink behind; that is now stated in the
module docs rather than left to be discovered.

An occupied candidate directory no longer ends the scan, and every platform now
reports whether the install actually wins the lookup. `Occupied` on
/opt/homebrew/bin used to mean giving up while ~/.local/bin sat free, and
Windows — which appends to PATH and so never collides — reported `Installed`
even when an existing tty7 earlier on PATH kept beating it. A new
`InstalledShadowed` names the winner.

`cargo run --release` no longer repoints the developer's real tty7 at a build
tree. `cfg!(debug_assertions)` only covered the debug half of that.

The Windows registry PATH is read, matched, and written as UTF-16 throughout.
It went through `to_string_lossy` before, so a value the registry holds but
Rust cannot represent as a String would have been written back with U+FFFD in
place of its characters — the exact PATH corruption the surrounding code is
careful to avoid.

Two tests mutated $HOME and $PATH while the rest of the binary's tests ran
beside them, and src/ui/home.rs mutates $HOME too. `candidate_dirs` takes home
as a parameter, `place` takes its mode, and the PATH-joining and registry-
joining rules are pure functions — so no test in this module touches the
environment any more. 5 tests become 11, and the Windows joining logic is
covered on every platform.

Also: the config flag reaches Settings → About and both features docs instead
of being config.json-only, startup reads config.json once instead of twice, and
the CLI's strip failure warns like its sibling instead of being swallowed.
2026-07-31 16:49:22 +08:00
thomasandClaude Fable 5 794ae89d24 feat(cli)!: drop the attach verb and the design doc
The CLI's user is the coding agent; run/send/capture/events cover it.
Workspace-level ws attach/detach stays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 11:36:25 +08:00
thomasandClaude Fable 5 b10a40a581 docs: attach is deferred — the CLI's primary user is the coding agent
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 10:31:59 +08:00
thomasandClaude Fable 5 9f88bf223b docs: tty7 CLI end-state design
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 09:17:05 +08:00
f6fa34a016 feat(ui): make the sidebar diff preview optional and bound its cost on large working trees (#247)
* feat(diff): make the sidebar diff preview optional and bound its cost

Clicking a sidebar row's `+N −N` opens the working-tree diff overlay. On a big
tree that could stall the window, and not everyone wants an in-app diff viewer
in the first place.

Two halves, matching the report.

The setting: `sidebar_diff_preview` (Settings → Window & Tabs, on by default,
persisted in `config.json`). Off, the branch and the counts stay exactly where
they are and read exactly the same; they lose only the pointer cursor and the
`toggle_diff_overlay` handler, so the press falls through to ordinary tab
activation. Both come off one value — `diff_click_cwd` — so they cannot get out
of step.

The performance work. All five of the reporter's hypotheses held up against
v26.7.6, and each fix is measured on a 300-file / 90 000-line / 4.5 MB diff
(release, macOS arm64):

1. The full diff was buffered before parsing — `git_status::git` uses
   `Command::output()`. Now streamed line by line through the new
   `git_status::git_lines` into an incremental `DiffParser`: peak transient
   buffer 4 552 060 bytes → 50 bytes, at ~1.7× the parse CPU (3.97 ms →
   6.76 ms) on the background thread, where it never touches a frame.

2. The snapshot was deep-cloned per holder inside `this.update`, i.e. on the
   UI thread. Now shared behind `Arc`: 2.41 ms → 11 ns per holder.

3. The element tree is not virtualized — confirmed, not cured. Rendering is not
   being redesigned here; instead the element count is bounded (see 4) and
   `MAX_RENDERED_FILES` caps the cards built at all, with a "… and N more" line
   for the tail.

4. Auto-collapse was per file, and counted only +/− while the rendered body
   also has context lines. Added `AUTO_COLLAPSE_TOTAL_LINES` over *retained*
   lines: sixty forty-line files, none individually large, went from 2400
   side-by-side rows to zero, under a summary saying the diff is too large to
   render efficiently and pointing at expanding individual files or `git diff`.

5. The Changes panel probed independently and kept its own snapshot. Both now
   go through `spawn_shared_diff_probe`, which dedupes by cwd and installs one
   `Arc` into every watcher; opening the overlay while the panel already shows
   that repo now paints from the panel's snapshot instead of re-probing.

Plus a repo-wide retention budget (`MAX_TOTAL_LINES`, `MAX_FILES_WITH_HUNKS`):
90 000 lines / 6.2 MiB of line text → 20 000 / 1.2 MiB. The `+N −N` totals
deliberately escape every cap — they are compared against `--numstat` to detect
staleness, so a capped total would disagree forever and re-probe in a loop.

Small diffs are untouched: a forty-file, twelve-lines-each tree is not
oversized and still opens expanded, asserted directly.

Not verified: anything requiring the GUI. No frame timings, no visual check of
the oversized banner or the settings row, and `AUTO_COLLAPSE_TOTAL_LINES` is a
judgement call anchored on row count rather than a measured frame budget.

Refs #239.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(diff): bound the untracked list and stop a moving default flipping toggles

Five findings from the review of the previous commit, all confirmed against the
source before acting.

The untracked list escaped every bound the previous commit added. `git ls-files
--others` reports the whole tree of anything not yet ignored, so a fresh clone
before `node_modules` / `target` / `.venv` reach `.gitignore` answers with tens
of thousands of paths — read through the buffering helper, retained without a
cap, ignored by `oversized()`, and rendered one non-virtualized row each. That
reaches the overlay without going through the diff at all, which is why the diff
budget never saw it. It is now streamed through `git_lines`, capped at
`MAX_UNTRACKED`, counted toward the oversized threshold, and rendered at most
`MAX_RENDERED_FILES` rows with a "… and N more" tail. The reported count stays
the true total via `untracked_total` — same split the diff side already makes
between what is retained and what is counted, because a count that shrank with
the budget would read as files having disappeared.

The overlay's expand state was an inversion set — "files flipped away from their
default" — which was correct while the default was per-file and stable. The
repo-wide `collapse_all` moves the default for every file at once, so any
refresh crossing the oversized threshold inverted every explicit choice
simultaneously: the two files the user had opened snapped shut and the rest
sprang open. It now stores absolute intent (`HashMap<String, bool>`), answered
before the default is even computed, so nothing about the snapshot can reach it.
Covered by a test that asserts an explicitly opened and an explicitly closed
file both survive a transition in either direction while an untouched file
follows the default.

The Changes panel dropped freshly landed snapshots. `install_diff_snapshot` only
wrote `right_panel.diff` when the panel was the one waiting, so a probe the
overlay started was discarded even when the panel sat on that exact repo — the
overlay rendered the new snapshot while the panel kept the old one, same window,
same repo. The wait (`diff_pending`) and the data (`diff_cwd`) are now claimed
separately: with probes deduped per cwd there is at most one in flight, so there
is no out-of-order overwrite to guard against.

The oversized banner reported `retained_lines()` as "diff lines", which after
the budget fires is what was kept, not what changed — it read "20000 diff lines"
directly under a header showing the exact +90000/-0. It now states
loaded-of-total and names which cap ate the difference, and lists each axis that
tripped the threshold so a big untracked list never reads as a claim that the
diff is big.

And the Changes panel deep-copied every untracked path String on every frame, on
the UI thread, for two `len()`/`is_empty()` reads — the same cost class the
`Arc` switch removed from the probe path.

995 tests pass, fmt clean, no new clippy warnings. The app was not built,
launched, or driven; visual acceptance is the owner's.

Refs #239.

* no-mistakes(review): cap Changes panel rows and fix oversized banner truncation notice

* no-mistakes(review): drive banner per-file truncation off parser flags

* no-mistakes(document): correct changelog cost count and overlay-trigger docs

* fix(diff): reconcile the diff-overlay work with the host-aware git refactor

The rebase onto main lands this change on top of the remote-workspace work
(#235, #242), which moved the git helpers into `tty7-core` and made every read
take the pane's `Host`. Adapting rather than papering over:

- `sidebar_diff_preview` moves to the core `Config`, where the struct now lives.
- The diff and untracked reads go through `git_status::git(host, cwd, ..)`.
- The shared probe, its in-flight set, and `install_diff_snapshot` key on
  (`HostId`, `PathBuf`) — the same path on two machines is two work trees — as
  does the Changes panel's `diff_pending`.
- `diff_click_cwd` became generic over what identifies a repo, so the setting
  gate did not need to learn about hosts.
- Main's newer card rounding reads `truncated`, which is an `Option` here now.

The streaming diff read is deliberately absent at this commit: `Host::git` is
buffered, so it is restored on top of a streaming host API in the next one.

Refs #239.

* feat(host): stream git reads whose size scales with the work tree

`Host::git` returns a fully-buffered `Output`, and the remote implementation
round-trips one over the wire. That is the right shape for the reads tty7 does
constantly — `rev-parse`, `symbolic-ref`, `--numstat` — all of which answer in
bytes. It is the wrong shape for `git diff HEAD`, whose output scales with the
work tree rather than with anything the UI can show: this repository's own
`git log -p -n 400` is 8.3 MB, and the diff overlay keeps a small fraction of it.

So `Host` grows a second entry point rather than changing the first. `git_lines`
delivers the same invocation a line at a time, and its **default implementation
buffers** — every host gets it for free, nothing that works today changes shape,
and it stays inside the "every git read funnels through the host" invariant
instead of becoming a way around it. Overriding it is an optimisation, never a
behaviour change: a test asserts the streamed and buffered reads yield identical
lines.

The local host reads straight off the pipe. The remote host adds
`ControlRequest::GitStream`, answered with `ControlEvent::GitChunk` pushes and a
terminating `GitEnd` carrying the exit status — the shape `WatchOpen` already
proved out.

Two protocol details worth the reader's attention:

The **client** picks the stream id, which is why the reply carries none. Ids
only need to be unique within a connection and a connection has one client, so
choosing it client-side lets the receiver be registered *before* the request
goes out. A server-assigned id arrives in the reply, leaving a window where a
chunk that overtook it reaches a client with no entry for that id and is dropped
under the unknown-id rule — silently losing the front of the diff. `WatchOpen`
needs a whole deferred-start mechanism to close that window; this does not.

The feature is **advertised and checked**, not assumed. A server predating
`GitStream` cannot decode the variant, and an undecodable frame ends the
connection — so sending it blind would not degrade, it would disconnect. Servers
advertise `git-stream`; a client that does not see it uses the buffered `Git`,
which is the path every remote pane used before this existed. Covered by a test
against a peer advertising only `control` and `host-rpc`.

Chunks carry newline-terminated line data batched to ~64 KiB, not verbatim
slices of stdout: the server reads through `git_lines` itself, so line content
survives exactly while `\r\n` and a missing final terminator are normalised
away. The only consumer is line-oriented. Framing per batch rather than per line
is what keeps a 90 000-line diff from becoming 90 000 frames.

Re-measured on the rebased code, against real git output, release build
(the previous figure was taken before the host refactor and no longer holds):

  buffered: 8 269 409 bytes resident, read 817 ms + parse 12 ms
  streamed: peak transient chunk 64 KiB, read+parse 557 ms

Lower peak memory *and* faster end to end — parsing now overlaps with git
producing output instead of waiting for all of it. The earlier synthetic
measurement showed streaming costing ~1.7x CPU; that was an artifact of reading
a warm page-cached file, where there was nothing to overlap with.

The app was not built, launched, or driven; visual acceptance is the owner's.

Refs #239.

* fix(host): make unsubscribing a watch take effect at the drop, not after it

CI's Windows job failed `watch_drop_unsubscribes`: an event for a file created
*after* the subscription was dropped still reached a consumer holding a clone of
the receiver.

Tearing the watcher down is not instantaneous. The OS backend runs its own
thread, and on Windows a `ReadDirectoryChangesW` completion can fire during
teardown, reach the event closure while `raw_tx` is still alive, and be
forwarded by a coalescer that has not yet noticed the disconnect. So "dropped"
meant "stops delivering shortly", which is not what the subscription promises —
and for a remote host it is the difference between releasing a server-side watch
and leaking one.

The handle now closes the delivery channel in its own `Drop`, before any of that
unwinds. Batches already queued stay readable — `close` stops sends, not
receives — which is the one thing a consumer racing its own drop may legitimately
still see, and exactly what the conformance test allows for.

Not this branch's bug: the change here is to git reads, not watches. But main is
flaky in the same family — it failed the sibling `watch_coalesces_within_window`
eleven hours ago and was hardened for that one — so this fixes the cause rather
than loosening the test.

Refs #239.

* refactor(host): make the streaming git read part of the protocol

Remote workspaces have never shipped a release, so there is no deployed server
to negotiate with. The `git-stream` feature flag, the `has_feature` check and
the buffered fallback behind it were all guarding against a peer that cannot
exist — dead code that would have to be maintained, and read by the next person
as evidence that older servers are out there.

`ControlRequest::GitStream` is simply part of the control protocol now. Buffered
`Host::git` stays exactly as it was, for the many reads that answer in bytes and
have no reason to stream.

The remote test that proved the fallback becomes one that proves the stream:
the peer serves `GitStream`, splits a line across two chunks, and the client
reassembles it — the case the reassembly exists for.

Refs #239.

* no-mistakes(review): fix remote git-stream deadlock, chunk encoding and stray docs

* no-mistakes(review): stop git-stream batch growing after a send failure

* no-mistakes(document): note git-stream protocol delta in remote-workspace contract doc

* fix(host): bound a git stream's wait, its lines, and its concurrency

Three ways the streaming git read could still hold or hang more than it
should, all found reviewing #239's implementation.

A stream is answered by pushes, so neither of the failure paths the rest
of the client relies on covers it: the request deadline was satisfied by
the immediate `Unit` reply, and keepalive watches the link, which stays
up while a server-side git wedges on a network filesystem. The reader
parked forever, on one of a small pool of blocking threads, and the diff
probe it belonged to never released its per-repo claim — so that
repository's overlay and Changes panel were stuck on "Loading…" for the
life of the process. `git_lines` now waits `GIT_STREAM_IDLE_TIMEOUT`
between chunks. Between, not across: a slow-but-alive read must be
allowed to take as long as it takes, which is why a total deadline would
be the wrong instrument. Draining moved to `drain_git_stream` so all
three exits are reachable from a test without waiting out two minutes.

"Incremental" bounded the number of allocations but not the size of any
one of them: a line is only complete at its newline, so a work tree with
a minified bundle rebuilt the whole-output peak inside `LineSplitter`,
on both ends of a remote link and in the server's outgoing batch. Lines
are now capped at `MAX_LINE`, and what is cut says so in the line itself
rather than silently shortening a rendered diff. That also bounds the
server batch, which makes `GIT_STREAM_CHUNK_MAX` a frame backstop rather
than the only thing standing between a bundle and a 32 MiB payload.

Finally, `GitStream` is the one request that spawns a thread outside the
bounded worker pool, so nothing counted them.  `MAX_CONCURRENT_GIT_STREAMS`
per connection now does, with the slot returned by a guard so a refusal,
a failed spawn and a panicking read all give it back — a leaked slot
would be a permanent refusal, not a transient one.

* fix(diff): stop the overlay re-walking the tree per frame, and re-probe a folded-in refresh

Two things the shared-probe work left on the render path.

The overlay asks six whole-snapshot questions while building its element
tree — oversized, totals, retained lines, budget fired, per-file cap
fired, untracked count — and each accessor walked `files` on its own,
`oversized` walking the hunks too. `files` is deliberately uncapped (only
hunks are), so that was six walks over a list whose length is the size of
the working tree, on the UI thread, on exactly the tree this module
exists to keep responsive. `DiffSnapshot::stats` answers all six in one
pass and the per-question accessors are gone, so nothing can drift from
it. Computed rather than stored, because the snapshot is built by hand
with `..Default::default()` throughout the tests and a cached count would
read as zero for every one of them.

Deduping probes per repository is what makes one `git diff` answer every
watcher, but a probe describes the tree as it was when it *started*. A
refresh triggered after that — a command finished, an agent turn ended —
folded into the running probe and was answered with a snapshot already
known to be stale, with nothing left to trigger another look: the
overlay's own re-check is gated on `loading`, which the landing clears,
and the `GitStatusCache` change that would have re-armed it has been
spent. A folded-in request is now remembered and re-issued when that
probe lands. It converges rather than loops, because a quiet tree never
sets the flag.

* fix(diff): bound the stream queue, and stop two thresholds answering the wrong question

Review follow-ups on the sidebar-diff branch. Five findings, four of them
about a bound that was claimed but not held.

The remote streaming read bounded both ends and not the middle. The reader
thread serves the whole connection, so it cannot wait on a slow consumer —
parking it there stalls every other reply and the keepalive with it — and an
unbounded queue was the price. That reassembles the whole diff in a channel,
which is the peak the buffered read was replaced to avoid, one container
further along. The queue is now bounded instead of back-pressured: each chunk
is charged to the stream's arrears, the drainer credits them back, and a
stream 32 MiB behind is cut loose with an error rather than served. Real
back-pressure would need credit-based flow control in the dialect; this is
not that, and says so.

`oversized` counted untracked paths on its file axis, and collapsing every
file body removes no untracked rows — that section has no bodies to fold. A
tree with an un-ignored node_modules and three edited files hid the three
cheap things, kept the expensive one, and told the reader their working tree
was too large to render. The untracked list is bounded where it is built:
MAX_UNTRACKED on retention, MAX_RENDERED_FILES on rows.

AUTO_COLLAPSE_TOTAL_LINES counted the context lines git prints around every
hunk — four to six retained per line actually changed — against a threshold
set as if it were reading `+N -N`. It fired on trees whose header said 400.
8000, compared against the 20000 the parser stops retaining at, since
collapsing everything is the heavier of the two interventions and should not
arrive first by much.

A probe that could not run produced an empty file list, which renders as
"Working tree clean" — a claim about the repository, made because a read
timed out. Newly reachable, too: a stream can be refused or go silent where a
buffered read could only arrive or error. DiffSnapshot::read_failed keeps the
two apart.

Also: StreamStop's doc comment had been glued onto StreamSlot, leaving the
enum undocumented and the guard described twice; the overlay header asked
totals() beside stats() rather than through it; and the watch-teardown fix
riding along on this branch was in neither the PR body nor the CHANGELOG.

Tests: the queue budget both ways (a stream that outruns it is cut loose, a
larger one that is drained is not), the untracked axis, an ordinary
context-heavy afternoon sized to fail against the old threshold and pass
against the new, and empty-because-broken against empty-because-clean. Each
was checked to fail against the behaviour it replaces. 1603 pass, 0 fail;
fmt clean; no new clippy warnings in the touched files.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:26:55 +08:00
l0ng-aiandl0ng-ai bed22d899e Keep workspaces whole: remote reopen/restart recovery, and cross-workspace restore guards (#257)
* feat(remote): keep a remote workspace whole across reopens and restarts

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: drop the references to the deleted design documents

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

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

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

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

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-29 19:15:19 +08:00
l0ng-aiandl0ng-ai 64403cf860 feat(terminal): expose the prompt editor's soft newline as a rebindable InsertNewline action (#246)
* feat(keymap): make the prompt editor's soft newline a bindable action

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

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

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

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

Refs #182

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

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

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

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

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

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

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

Refs #182

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

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

---------

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

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

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

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

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

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

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

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

Closes #211

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-29 00:23:49 +08:00
l0ng-ai 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 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
yetone c69db5fa83 feat(theme): add One Dark Pro built-in theme
Add One Dark Pro as a ninth built-in, slotted alphabetically among the
dark themes: background #282c34, foreground #abb2bf, the classic One
Dark syntax palette for the normal ANSI slots and One Dark Pro's bright
variants for the bright ones.

Two seeds deliberately diverge from the VS Code theme's terminal set:

* The accent is the editor cursor/focus blue #528bff, not the syntax
  blue #61afef — the accent doubles as the switch's checked track, and
  #61afef sits at the same luminance as the #abb2bf knob (1.11:1).
* The normal red is the classic #e06c75, not the Pro terminal #e05561 —
  conditioned for AA the latter lands within 37 channel-distance of the
  orange-yellow #d18f52, under the 40 separability floor danger/warning
  must clear.

Also bumps the theme count in README and docs (eight → nine).
2026-07-27 17:18:37 +08:00
l0ng-ai 8bdcaf4290 fix(fonts): name a CJK and emoji fallback the host platform actually ships
The default `font_fallbacks` list was macOS-only -- Menlo, Hasklug Nerd
Font Mono, Maple Mono NF CN, Apple Color Emoji. Fallbacks resolve by
family name against installed fonts, so off macOS the whole chain matched
nothing and every glyph the primary lacked was left to the platform's own
cascade. Bundled Hack maps 1548 codepoints and zero ideographs, so on
Windows that was every Chinese character in every pane, and every emoji.

The fall-through is not only a matter of which face you get. `element.rs`
pins each wide cell to `2 x cell_width`, and Hack advances 0.60205em, so
a two-column slot is 1.2041em -- while every stock CJK face (Microsoft
YaHei, PingFang SC, Noto Sans CJK) advances 1.0em. `force_width`
left-aligns, so the ideograph hugs the left of its slot and the remaining
0.2em shows up as a gap on the right of every character. Measured on
Windows at font_size 15: left bearing 1.49px, right bearing 4.90px.

Branch the defaults per platform, keeping Maple Mono NF CN first
everywhere -- 0.6em Latin, 1.2em CJK, the one exact two-cell fit against
Hack (bearings 3.06px / 3.62px, ink centered). It stays referenced by
name only, never bundled, at ~20MB per weight.

Changing `Config::default` alone would reach nobody who already has a
`config.json`, which is every existing user. So `fallback_chain` appends
the platform's stock faces the same way it already pins Hack: a fallback
is consulted only after everything ahead of it has missed, so appending
can never displace a face the user chose, and the file is never rewritten.

Verified by driving two builds against one config naming only absent
macOS faces: before, the CJK line differed from an explicit Microsoft
YaHei chain by 3571 pixels (the cascade picked something else); after, it
is pixel-identical.
2026-07-26 20:59:11 +08:00
l0ng-ai 7aeca8adf7 fix(links): open links on Ctrl+click on Windows and Linux
The link modifier was `mods.platform`, which gpui maps to ⌘ on macOS but
to the Win/Super key elsewhere — a key the OS mostly swallows, so on
Windows and Linux neither the hover underline nor click-to-open could be
triggered at all. `config.json`'s own docs already promised "⌘/Ctrl-click";
this is the implementation catching up.

Use `Modifiers::secondary()` (⌘ on macOS, Ctrl elsewhere) at all three
sites: the click handler, the hover probe, and the app-level modifier
tracking that pushes the state down to background panes. Not
`platform || control` — that would steal ⌃-click on macOS, where it means
"right click".

Ctrl+click still falls through to mouse-tracking TUIs when there's no
link under the cursor, since `open_link_at` reports whether it consumed
the click.

Fixes #183
2026-07-26 20:36:05 +08:00
l0ng-ai c76ef87d02 feat(agents): wire the rich status channel into Grok Build
Grok Build exposes a Claude Code-shaped hook surface, so tty7 can now
install into it and give grok panes live session status and
resume-after-restart, not just a brand chip.

- Owned hook file at ~/.grok/hooks/tty7.json (grok loads every JSON file
  there; global hooks need no folder-trust grant), so the user's own
  hooks are never touched.
- Read camelCase payload keys: grok's envelope sends sessionId, and
  without it restore loses the id --resume needs.
- Relabel events that arrive through grok's Claude-compat scan of
  ~/.claude/settings.json, keyed on the GROK_HOOK_EVENT var its hook
  runner injects — otherwise a grok pane reports Claude Code, and having
  both integrations installed emits every turn under two identities.
- Resume via `grok --resume <id>`, stripping the flags that would fight
  the injected id (--resume/--load/--continue/--session-id/--fork-session)
  or relocate the session (--worktree/--worktree-ref).

Notification is subscribed with a matcher for elicitation_dialog only.
Grok dispatches its permission_prompt notification before the permission
system decides, so it fires on essentially every tool call, auto-approved
ones included; escalating that to the amber "needs you" state would flash
the pane and fire a desktop notification on every tool a turn runs.
2026-07-25 19:17:05 +08:00
thomasandClaude Opus 5 443f04f3c6 fix(prompt): stop swallowing ^J, and let ^R go to the shell on request
The local command editor consumed every Ctrl chord at the prompt, matched
or not, so two things the shell owns quietly stopped working (#163).

^J and ^M carry accept-line's control codes — Enter by another name — but
fell into `apply_readline_ctrl`'s no-op arm, so the keys did nothing at
all. Route them through the same path Enter takes, via a shared
`accept_line`, so the completion picker and the history menu treat them
identically.

^R was recognized, but only ever opened tty7's own history menu, with no
way back to a `bindkey`ed widget (fzf, percol). Add `history_search`
(default on, Settings → Terminal → Keyboard): with it off, the edited
line is handed to the shell and the raw ^R follows it, so whatever is
bound there answers. The "shell integration never engaged" notice stays
quiet in that case — ^R reaching the PTY is then the point, not a gap.

`handoff_tab_to_shell` generalizes to `handoff_line_to_shell(chord)` to
carry the ^R handoff; the Tab path is a thin wrapper over it and its
behavior is byte-for-byte unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 15:07:31 +08:00
l0ng-ai 6753b9697b fix(completion): fall through to shell completion, dir-only candidates, opt-out (#136)
Three fixes for tty7's Tab completion:

- Tab is no longer swallowed when the engine has no candidates: the
  locally edited line is handed off to the shell (text shipped raw,
  cursor walked back, Tab sent) and the local editor suspends until the
  next prompt cycle, so shell-native completion (compsys, fzf-tab, ...)
  answers instead. The handoff release keys off a new entered-prompt
  cycle counter rather than the raw Prompt-frame seq, so same-prompt
  redraws (PS1-embedded 133;B re-emissions) cannot re-engage the editor
  while zle still holds the handed-off text.
- cd/pushd/popd/rmdir complete directories only in the no-signature
  path fallback; Fig 'folders' templates narrow signature slots the
  same way. Symlinks now classify by their target.
- New tab_completion config field (default true) plus a Settings ->
  Terminal -> Keyboard toggle; when off every Tab goes to the shell.
2026-07-21 18:25:49 +08:00
l0ng-ai e3edcdb153 feat(agent): carry launch flags onto session resume commands
Resume-after-restart replayed a hardcoded per-agent command
(claude --resume <id>), dropping whatever flags the agent was
originally launched with (--dangerously-skip-permissions, --model).

The daemon's foreground poll already reads the agent's argv for
detection; keep it, stream it to the client inside AgentSessionState
(serde-default, wire-compatible both ways), persist it in the session
Leaf, and splice a conservatively-gated flag tail into the resume
command. The gate refuses anything that is not a plain flag-shaped
token sequence and falls back to the bare table command.

The Windows 133;C typed-command capture is forgeable by terminal
output, so it contributes identity only, never flags. Copilot gains a
resume entry (copilot --resume <id>, hooks already report its session
id) and Amp's threads continue verified to accept global flags.
2026-07-21 12:16:11 +08: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 df36292db1 test(config): round-trip follow-system fields; docs + comment fixes from review 2026-07-17 15:11:11 +08:00
l0ng-ai 98b4282a39 Merge pull request #110 from l0ng-ai/feat/sidebar-repo-grouping
feat(sidebar): group vertical tabs by git repository
2026-07-16 18:26:19 +08:00
l0ng-ai e5ede1beb7 fix(sidebar): key repo group on first pane; document grouping
The group key now reads the first pane's cwd instead of the focused
pane's, so switching focus between splits in different repos never
relocates the row (the branch line still follows focus). Also documents
the repo-grouped sidebar and the sidebar_grouping setting in
features.md / features.zh-CN.md.
2026-07-16 18:03:31 +08:00
l0ng-ai 4aeed6fa46 Merge pull request #106 from l0ng-ai/feat/theme-background-visuals
feat(theme): render gradient and image backgrounds, global window opacity/blur
2026-07-16 16:44:01 +08:00
l0ng-ai 2dd817d8cd fix(theme): review follow-ups — diff overlay background, hot-reload window effects, docs
- Diff overlay paints the gradient/opacity-aware window background instead
  of the stale representative solid.
- Config hot-reload now re-runs apply_theme with the window (blur flip,
  traffic-light re-pin) and re-syncs the Appearance opacity slider, so
  hand-edits to config.json / theme files take effect fully.
- Document the theme background/window settings in features docs (EN + zh-CN).
2026-07-16 16:30:59 +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 501fd4a7de docs(readme): rewrite in minimal style, reposition as terminal workbench
Slim the READMEs to an index (why / install / what's inside / benchmarks);
move feature details, keybindings, and performance notes to docs/features.md
(en + zh-CN). New tagline: a terminal workbench — shells, sessions, SSH,
coding agents. Sync the Cargo.toml description.
2026-07-16 11:45:33 +08:00
l0ng-ai b33fcc7035 chore(docs): drop the docs directory and stale plan files 2026-07-15 21:21:48 +08:00
l0ng-aiandl0ng-ai 168abe4cde refactor(palette): saved profiles are the single SSH source (#77)
The command palette listed SSH hosts from two parallel sources: saved
profiles and a live scan of ~/.ssh/config Host aliases. The same host could
appear twice with different behaviors (frecency, edit affordance, credential
handling), and config hosts surfaced even when Settings showed no profiles.

Make saved profiles the palette's only SSH listing:

- drop the live-alias rows and the OpenSshProfile command; ~/.ssh/config
  hosts appear after Settings -> SSH -> 'Import from ~/.ssh/config'
- keep 'ssh <alias>' semantics for *typed* targets: QuickConnect and
  'SSH: Add Connection...' now resolve a target naming a config alias on the
  spot (HostName/User/Port/IdentityFile/ProxyJump), with typed user@/:port/
  flags overriding the config's values -- previously only the ProxyJump chain
  resolved and a typed alias was treated as a literal hostname
- remove the now-dead discovery walker (discover_profiles + struct); its
  alias-filtering and Include-following tests move to import_profiles_from,
  which exercises the shared parse_config_blocks path
- update PRD (FR-P3, section 3.3) and both READMEs to the new model

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-14 14:28:04 +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
l0ng-ai 22e1ab1694 tty7: a GPU-rendered, daemon-backed terminal in pure Rust
tty7 is split into two Rust processes: a persistent daemon that owns the
shells and a GPU-rendered client that talks to it over a local socket.
Because the shells live in the daemon, quitting and reopening the app
leaves the session intact — detach and reattach, no tmux required.

- Persistent sessions — the daemon holds the PTYs and child processes, so
  closing a window or swapping in a new build never takes a shell down.
- Performance — an 11 MB `cat` completes in 95 ms and DOOM-fire renders at
  888 fps; the daemon drains the PTY at device speed off the render path.
- Shell-aware — new tabs and splits open in the current working directory;
  zsh, bash, fish, and PowerShell are set up automatically.
- Enhanced prompt — inline completion, syntax highlighting, history, and
  in-terminal search, with rich flag/subcommand signatures for common tools.
- Tabs, resizable splits, a command palette, click-to-open links, desktop
  notifications, eight themes, and CJK/IME input.

Native builds for macOS, Windows, and Linux.
Built on Zed's gpui and Alacritty's VT core.
2026-07-06 21:54:27 +08:00