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.
The overlay was a single app-global slot rendered over whatever tab was
active: switching tabs left a stale diff occluding the new tab's body,
with focus on the terminal underneath so Esc couldn't even close it.
Move the overlay state onto Tab so it follows product intuition: switching
away hides it, switching back restores it (re-probing if the status cache
disagrees with the shown snapshot), and closing the tab drops it.
focus_active now hands focus to the returning tab's overlay so Esc keeps
working; probe landings update every tab showing the same cwd.
Also replace the unified diff body with a GitHub-PR-style side-by-side
view: removed lines fill the left column, added lines the right, aligned
positionally within each change block; context lines land on both sides
and leftovers pair with a muted empty cell.
Claude Code has no 'permission replied' hook, so after the user approved
a mid-turn permission prompt the dot stayed amber for the rest of the
turn. Install a PostToolUse hook mapped to a new tool-complete sentinel
event: the first tool that finishes after approval flips the state back
to Working (guarded on Waiting, so completions during normal work are a
no-op and a straggler can't overwrite Done). Same shape as Warp's
ToolComplete transition.
Old daemons drop the unknown event; existing installs surface as
Outdated in Settings -> Agents with an Update button, so the stale
'points at another tty7' copy is reworded.
A split tab can finish several agent turns while you're away, but the
unread marker was a tab-level bool: three unread results looked exactly
like one. Replace the same-hue target ring with a count badge — when a
tab has unread finished turns, the green Done dot swells into a disc
with the number of unread panes, and shrinks back to a plain dot once
every pane has been seen.
- Tab::agent_result_unread (bool) becomes Tab::agent_unread_count
(usize), counting Done-and-unread panes; still zero while a busier
pane (working/waiting) owns the corner.
- status_dot takes the count and renders the numbered badge, centred on
the read dot's corner point so the swell reads as the dot growing in
place; count clamped to 9 so an absurd split can't overflow the disc.
- Sidebar rows reuse the same avatar, so both layouts pick it up.
Windows' std::fs::canonicalize returns \\?\-prefixed verbatim paths while
git reports plain absolute paths, so occupied()'s starts_with never matched
(silently disabling the shared-checkout guard) and two path-equality test
asserts failed. Canonicalize both sides in occupied(); strip the verbatim
prefix in the tests.
- Bump gpui-component fork (562341a, ff8af95): menus get a 6px rounded pill
highlight inset by 5px panel padding, inset hairline separators, a 10px
panel radius with a floatier shadow; searchable lists get a taller
Spotlight-style search row with 15px query text.
- palette: card matches the menu panel (10px radius, shadow_xl); rows are
fixed-height inset pills; the list viewport holds a whole number of rows
so the card's bottom edge never cuts the last visible row mid-height.
New tabs inherit the current cwd, so two tabs sharing one managed worktree
is common; removing the checkout on the first close would yank the
directory out from under the surviving shell. Before offering, scan every
leaf of every surviving tab (splits included) and stay quiet if any cwd
still sits inside the worktree.
Right-clicking a tab chip (strip) or row (sidebar) opens a context menu:
rename, New Worktree Tab, splits, copy working directory, and a close
group (tab / others / to the right or below).
New Worktree Tab opens a sheet pre-filled with a generated two-word
name (used for both the directory and the new branch) and the current
branch as the start point — all three editable, with a live path
preview. The checkout lands in the repository's own
.tty7/worktrees/<name>, kept out of git status by an auto-written
self-ignoring .tty7/.gitignore. The entry only appears when the tab's
cwd is inside a git repository.
Closing a tab that sat in a managed worktree offers to remove the
checkout (and its branch when fully merged); dirty checkouts default
to keeping and make the discard explicit.
Clicking anywhere on a sidebar row's git line (branch icon, branch name,
counts) toggled the diff overlay, so ordinary clicks on the lower half of
the row popped the overlay instead of activating the tab. Only the +N/-N
readout is the click target now; the icon and branch name fall through to
the row's normal activate handler.
The sidebar row's third line (branch + counts) is now a click target: it
opens a read-only, GitHub-style unified diff of that pane's repo as an
overlay covering the terminal body. The sidebar stays visible, so other
tabs' git lines remain one click away — peek at another session's
changes without switching tabs.
- terminal/git_diff: parse `git diff HEAD` into files/hunks/lines
(renames, binary, quoted paths, per-file line cap), plus the
untracked list `diff` can't see; probed off-thread, read-only via
the shared GIT_OPTIONAL_LOCKS=0 helper.
- ui/diff_overlay: per-file cards with collapsible hunk bodies (big
files collapse by default), line-number gutters, green/red tints,
Esc / close / re-click toggles; auto-refreshes off the existing
GitStatusCache triggers when shown counts go stale.
Persist the window's final geometry to window.json on quit (written by
the app-quit hook, tracked live by a bounds observer that records the
restore bounds while fullscreen). On launch, a normal startup window
reopens at the remembered size and position instead of the hardcoded
centered 1440x900; a remembered window that no longer touches any
display keeps its size but re-centers, and degenerate or malformed
state falls back to the default.
Gated by a new remember_window_size config (default on) with a switch
in Settings -> Window & Tabs and a search entry. The geometry is
written unconditionally so toggling the setting back on restores the
most recent quit rather than stale pre-toggle state.
Closes#89
Drag-resizing a pane whose TUI redraws on every SIGWINCH cuts a tiny
segment per column change; such segments never fill RING_CAP, so over a
long-lived pane's life they accumulated without bound and attach replay
(one full client-side reflow per Size) degraded linearly. Past
MAX_RING_SEGMENTS (64) the two oldest segments now merge, replaying the
older bytes at the newer geometry — like the byte cap, precision
degrades from the oldest scrollback first.
Also correct the client reader's pending_size comment: the one-lock
guarantee is per Size/Snapshot pair, not across the whole replay.
The agent-hook emitter reads its JSON payload from stdin until EOF,
which assumes the spawner pipes a payload and closes the stream (as
Claude Code's hooks do). OpenCode's plugin runner spawns the emitter
with stdin inherited from the pane's terminal instead: a tty never
reaches EOF, so the read blocked forever, hanging the plugin's async
init and leaving OpenCode's TUI blank on startup — while also
swallowing the user's keystrokes.
Skip the stdin read when stdin is a terminal; a bare event without
session_id/message is already a supported payload.
Fixes#88
The replay ring stored raw PTY bytes with no geometry history and attach
replayed all of it at the final recorded size. Any resize during a session
(pane split, window drag) meant older bytes re-wrapped at the wrong width
on replay, so a TUI's cursor-up redraws (Claude Code's inline renderer is
the canonical case) landed mid-frame and every redraw leaked stale rows
into the reattached pane's scrollback -- duplication that never existed
live (10 markers live vs 45 replayed in the regression scenario).
The ring is now a sequence of geometry-tagged segments: resize seals the
current segment (retagging an empty tail in place), cap eviction drops
emptied segments, and attach replays a Size -> Snapshot pair per segment.
The client reader already applies each Size to its grid right before the
paired Snapshot advances (pending_size), so it reflows between segments
exactly where the live client did -- no client changes needed.
Settings > Agents now owns hook installs with per-agent state (#87);
the palette entry covered only Claude and duplicated that path, so
remove it rather than grow one palette command per agent. Also drop a
leftover unused HookAgent import in the settings render.
The active tab row/chip used to keep its × always visible while the
others faded in on hover. Treat every tab the same — opacity 0 until
hover, space still reserved — so the sidebar and strip read clean.
Each pane used to compute and hold its own git branch/diff snapshot,
refreshed only by its own events (cwd change, command end, agent turn
end). Tabs sitting idle in the same repo kept whatever they last saw, so
rows for one directory showed different +/− counts — or none at all when
a tab's last probe landed on a clean tree.
Snapshots now live in a process-wide GitStatusCache keyed by work-tree
root: every pane whose cwd resolves into the same repo reads the same
entry, refreshed by whichever pane probed last, and the sidebar observes
the cache so all rows repaint together. In-flight probes are deduped per
cwd (concurrent triggers fold into one git shell-out, with a rerun if
re-triggered mid-flight), and a failed git diff keeps the previous
counts instead of rendering the tree as suddenly clean.
Startup used to silently stop a daemon speaking a different protocol,
killing every persisted session without warning. The old daemon is still
serving its panes fine — the mismatch may be benign for the messages
actually exercised — so the call is now the user's: keep it and reuse
the sessions, record the mismatch, and have the first window raise a
Keep Sessions / Restart Daemon prompt (restart reuses the confirmed
half of the existing Restart Daemon flow). Only a daemon that cannot
answer the handshake at all (wedged, timeout) is still replaced
outright, since it cannot serve its sessions either way.
The SSH avatar drew a 2px border ring in theme tokens, which read as a
second avatar shape next to the flat shell badge — and "connected" used
theme.accent, the list-selection grey in this app, so the ring showed no
state at all. Reuse the agent status_dot on the badge corner instead,
colored from the same hardcoded palette as agent dots (amber connecting,
green connected, red failed/disconnected, neutral for a foreground ssh);
the tab strip's inline 6px dot picks up the same RGB values.
A bare word like "java" parses as a valid hostname, so every command
search got Connect/Save rows pinned above the real matches. Require the
query to look like a connect target (contain '@', ':' or '.') before
injecting QuickConnect rows.
The daemon outlives the GUI binary, so after an app upgrade the running
daemon can speak an older wire dialect. ensure_running now asks a live
daemon for its protocol version (new Version request/reply, kind 40)
before reusing it and restarts it on a mismatch; a pre-versioning daemon
drops the unknown kind, which reads as "replace it" too.
* feat(agents): hook integrations for Codex, Copilot, OpenCode, and Pi with install state in Settings
Generalize the Claude Code hook install into a per-agent integration
layer, all feeding the same `tty7 agent-hook <agent> <event>` emitter
(still gated on the TTY7 env var):
- Claude Code / Codex: tty7-marked entries merged into their hooks map
(settings.json / hooks.json); user hooks and settings are never
touched. Codex install also runs `codex features enable hooks`,
best-effort with manual-run advice when the CLI is missing.
- Copilot CLI / OpenCode / Pi: a tty7-owned hook file / JS plugin / TS
extension, byte-compared for drift detection and ownership-guarded on
install and uninstall.
- Copilot's catch-all notification hook is filtered in the emitter:
permission/elicitation prompts escalate to permission-request,
everything else stays silent.
- New Settings -> Agents section: one row per agent with install state
(not installed / installed / outdated) and Install / Reinstall /
Update / Uninstall actions.
- On launch (release builds only), integrations pointing at a moved or
stale tty7 binary are rewritten in place.
* fix(agents): rustfmt + make the owned-file exe assertion Windows-safe
The generated integrations embed the exe path inside JSON/JS string
literals, so the test must look for its string-escaped form — on Windows
the raw path's backslashes appear as \\ in the content and the raw
substring never matches.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
* fix(terminal): shift the grid up when wrapped input overflows the bottom
While the local command editor is active, typed input never reaches the
grid, so the emulator's scroll-on-wrap never fires: a multi-line command
at a bottom-of-screen prompt grew past the window edge and its tail (and
caret) were clipped by the surface's overflow_hidden.
Emulate the scroll an echoing shell would perform: compute how many rows
the wrapped overlay spills past the last grid row (input_scroll_rows) and
raise the whole paint origin by that many lines — the top rows clip, the
overlay re-anchors to the shifted prompt row, and the wrapped tail lands
in the vacated strip. The shift is capped so the caret row never scrolls
off the top when the input is taller than the screen, and disabled while
scrolled into history or in reverse-search mode. The completion menu
follows the shifted anchor, and editor clicks/drags map through an
unclamped row so the rows past the old bottom stay clickable.
* style: rustfmt
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
* feat(agents): recognize CLI coding agents + show git branch in the sidebar
Observe (never wrap) third-party coding agents running in a pane — Claude
Code, Codex, Gemini CLI, Aider, Amp, OpenCode and ~10 more — and enrich the
UI around them, plus front each sidebar row with its git branch and diff.
Detection & identity
- Command-based detection over the foreground argv (launcher basename, and
interpreter-wrapped `node …/cli.js` / `npx …` forms), with user rules via
`agent_commands` in config. Brand avatars on the tab chip and sidebar row.
Rich status channel
- A per-pane state machine (idle / working / waiting-for-you / done) driven
by agent-reported events over an OSC 777 sentinel channel
(`tty7://cli-agent`, versioned JSON), sniffed daemon-side and streamed to
the client (DaemonMsg::AgentStatus).
- `tty7 agent-hook claude <event>` + a palette installer wire Claude Code's
lifecycle hooks up; the hook writes the sentinel to the controlling tty
(with an ancestor-tty fallback for detached hook processes).
- Avatar status dot: working (blue) / waiting (amber) / done (green); an
unread finished turn gets a crisp outer ring that clears on focus.
Notifications, resume, context feed
- "Needs your permission…" the moment an agent blocks; "finished after Ns"
per turn, honoring the notify policy (rich turns suppress the coarse exit).
- Session resume: restored panes re-launch their conversation
(`claude --resume …`), gated by `restore_agent_sessions` (default on).
- Palette commands send the current selection or the repo `git diff` to the
running agent as a ready-made prompt.
Sidebar git line
- New `terminal::git_status`: off-thread `git` probe (branch, or short sha
when detached; `git diff --numstat HEAD` line counts) with
GIT_OPTIONAL_LOCKS=0, refreshed on cwd change or command finish, dropped
on a stale cwd via a generation tag.
- Each row is avatar + title + `⎇ branch +N −M` (green/red), sized to
content; the redundant cwd/"Working…" lines and the aggregate rollup are
gone — the status dot and branch line carry it.
672 tests pass.
* fix(agents): repair CI and refresh the git line when an agent turn ends
- The live PTY detection test used `sh -c 'exec -a codex cat'`, but
`exec -a` is a bashism dash (Ubuntu's /bin/sh) rejects — spawn bash.
- cargo fmt over cli_agent.rs / view.rs / app.rs.
- An agent session is one long foreground command, so the back-to-prompt
edge never refreshed the sidebar's branch/diff line while the agent
worked — exactly when the working tree changes. poll_agent_status now
reports a turn ending (transition into Done) and the poll reprobes git
on that edge too.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
* feat(ssh): support gssapi auth
* fix(ssh): pin the russh patch to an exact rev + fail on a stalled gssapi context
- [patch.crates-io] now pins rev 0d1d073 instead of tracking the fork's
branch: russh is the credential-handling SSH protocol layer, and a
moving branch would let `cargo update` silently pull unreviewed code.
Documented the removal condition (upstream russh PR #737 releasing).
- gssapi_step: an incomplete context with no output token used to claim
GssapiStep::Complete without a MIC, which servers reject with an opaque
failure; return an error naming the stall instead.
- auth.rs module doc: include gssapi-with-mic in the Auto ordering.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
* fix(terminal): keep each tab's active pane across tab switches
Switching tabs reset the target tab to its first pane: focus_active
always focused first_leaf, and a Tab stored no notion of which pane was
active. Tab labels had the same root cause — an inactive tab holds no
window focus, so leaf_title always fell back to the first pane.
Record the focused pane per tab (Tab::last_focused) when leaving it or
opening a focus-stealing overlay (Settings), and restore it on return
via Tab::focus_target, degrading to first_leaf when the remembered pane
has closed. leaf_title now uses the live focus for the active tab and
the remembered pane for inactive tabs, so background labels track the
pane each tab is working in.
Add Pane::leaf_matching_or_first as the pure selection rule, unit-tested
against the u32 leaf model.
* fix(terminal): remember the active pane on every tab-leaving path
Cmd+T (new_tab_with_shell), native-SSH new tab (open_native_ssh_tab), and
reopen-closed-tab (Cmd+Shift+T) all move the active tab away without going
through activate(), so the departed tab never recorded its focused pane and
switching back jumped to the first leaf — the exact bug this branch fixes,
reachable from its most common entry point. Snapshot via remember_active_pane
before the switch in all three.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Splitting a native-SSH pane dropped back to a local shell because split()
only inherited shell_spec (which is None for SSH panes) and never looked at
ssh_spec. Detect an SSH pane in split(), re-resolve its persisted secret-free
spec from the saved profile (re-applying keychain secrets, mirroring the
reconnect path), and spawn the new leaf via new_terminal_native on the same
connection. Local panes are unchanged.
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>