* feat(agents): add Qoder CN CLI integration
* fix(agents): spell Qoder CN's config override QODERCN_CONFIG_DIR
The China build of Qoder resolves its configuration through QODERCN_CONFIG_DIR.
The first pass invented QODER_CN_CONFIG_DIR instead, so an install that set the
real one was treated as unrelocated: hooks went to ~/.qoder-cn and history was
scanned from a directory the CLI never writes to. The test that should have
caught it set the same invented name, so it only proved the name agreed with
itself.
Picks up the rest of the review as well — detect the `qoder-cn` dispatcher
beside the other two binaries, call it the mainland-China build rather than a
different vendor, and drop the QODER_CN_HOOK_EVENTS alias for the table it only
pointed at. The serialized enums keep their variants appended; only the
independent ALL arrays moved QoderCLICn next to QoderCLI.
* feat(search): replace the command palette with tabbed Search Everywhere
The palette was one flat list that every new kind of row had to be squeezed
into: tabs and SSH hosts rode along as "Switch to Tab: …" and "SSH: …"
commands, and the only way to narrow to one kind was a magic seed word.
Search Everywhere splits it into sources behind one trait — All, Actions,
Terminals, Hosts — each with its own empty-query layout and ranking. The All
tab shows each source's top rows, ordered by best match, with a row that
opens the full tab. Tab / Shift-Tab walk the tabs and keep the query.
- Terminals lists every open tab of every workspace (reusing the switcher's
tab rows) and jumps to it wherever it lives, plus the shells and agents.
- Hosts replaces the separate "Add Connection" input: a typed address or
full `ssh …` line offers to connect.
- Fixes Return doing nothing after a search that found nothing, or when the
search opens pre-filtered: gpui-component re-picks the row from a stale
frame; the delegate now re-arms the first row.
- Fixes `ssh -p 2222 me@box` being offered as a quick-connect address with
user `ssh -p 2222 me`.
The keymap action stays `TogglePalette` so custom bindings keep working.
* feat(search): a Sessions tab to resume past agent sessions
Search Everywhere gains a Sessions tab listing the Claude Code and Codex
sessions on this computer, read from ~/.claude/projects and
~/.codex/sessions ($CODEX_HOME). Sessions that ran in the focused tab's
directory lead; the All tab offers the last three of them before anything
is typed. Return opens a new tab in the session's directory and runs the
agent's resume command with its configured launch flags.
- Only each transcript's head and tail are read, off the window thread,
and cached by path, size and mtime: ~170ms cold for 50 sessions,
under 1ms warm. The search opens on the cached list and fills in.
- Titles: /rename name, then the agent's own title (ai-title, Codex's
session_index.jsonl), then the first thing typed, skipping harness
injections. Codex rollouts it ran for itself (subagent/internal) and
sessions never asked anything are left out.
- A session whose directory is gone is refused with a notice rather than
resumed where the agent cannot find it.
* feat(agents): recognise Empryo and Prime Agent
Prime Agent (PrimeIntellect-ai/prime-agent) is a Pi fork: detected as
`prime-agent`, resumes with `--resume <id>`, forks with `--fork <id>`,
`--no-session` opts out, and reports status through the shared Pi
extension bridge at ~/.prime/agent/extensions/tty7/index.ts.
Empryo is detected as `empryo` and resumes with `empryo --session <id>`.
No hook installer yet: Empryo filters TTY7* variables out of hook
processes, so `tty7-app agent-hook` would stop at the missing marker.
* feat(agents): Antigravity status hooks
Antigravity CLI (`agy`) now installs a `tty7` hook set in
~/.gemini/config/hooks.json, next to the user's own sets:
PreInvocation -> prompt-submit, PostToolUse -> tool-complete,
Stop -> stop. Only the first PreInvocation of a turn (invocationNum 0)
counts as a new prompt, later ones keep the turn working; a Stop with
fullyIdle false is ignored. conversationId and workspacePaths feed the
session id and cwd. PreToolUse is left alone because it must answer
with a decision.
* fix(agents): index Prime Agent and Antigravity in settings, scope conversationId alias
- Add settings titles, search keywords (en/ja/zh) and Agents index entries
for the two new HookAgents; agent_rows_are_in_the_search_index requires
one per HookAgent::ALL.
- Read Antigravity's conversationId as the session id only for antigravity:
the alias table is last-write-wins, so a global alias could replace
another agent's session id.
- Drop the stray .empryo/ job records.
---------
Co-authored-by: kalpak <you@example.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
* feat(sidebar): keep pinned groups on the workspace and derive the rest
Replace the sidebar's hand-made groups with the model from #955: the sidebar
groups tabs by repo automatically, and you pin what you want to keep.
- Pinned groups (`PinnedGroup`: id, optional name, optional folder, fold) are
stored on the workspace in the machine tree, in display order, and a tab
points at one by `GroupId`. Everything else is an auto group worked out
every frame and never stored: by repo home, or by `user@host` for an SSH
pane — native or a shell that ssh'd onward — so `/home/ubuntu` on two
machines no longer lands under one header.
- A tab whose cwd *enters* a pinned folder joins it (deepest folder wins; a
repo home equal to the folder counts, which keeps worktrees with their
repo). It is edge-triggered through `EntryWatch`, so a tab dragged out
while still inside the folder stays out until it leaves and comes back, and
a tab restored at launch is not pulled in by where it already sits.
- Groups sync as one `WorkspaceSetGroups` / `GroupsChanged`, pushed only from
an edit and adopted from every pull, so a fresh window can never push an
empty set over the workspace's. The machine hands tabs naming a dropped
group back to auto grouping in the same mutation. Control dialect → v11.
- Config: `sidebar_grouping` (three modes) and `sidebar_collapsed_groups` give
way to one `sidebar_auto_grouping` toggle; folds live with the workspace.
- `tty7 tab ls` reports the pinned group a tab is in (name or folder leaf;
JSON carries id, name and folder).
* feat(sidebar): draw pinned groups above a divider, with their own gestures
The sidebar now reads as two halves: the groups you keep, in the order you
put them, then a divider, then the groups it works out (Arc-style).
- Pinned headers drag-reorder among themselves (their own reorder surface, so
a pinned header cannot be dropped among the derived ones); the order lands
on the workspace's group list, not on the tabs.
- An auto header carried above the divider is pinned when let go. With
nothing pinned yet the divider appears during that drag as a "Drop here to
pin" zone, since a hairline at the top of the list is nothing to aim at.
- A tab kept in a pinned group and dropped anywhere below the divider goes
back to auto grouping; the divider lights to say so.
- An empty pinned group stays, with a "+ New Tab" row that opens a tab in its
folder (or where ⌘T would, for a label group) and files it there.
- Folder groups carry a pin mark that unpins on click and a tooltip with the
folder; auto headers show pin and "+" on hover.
- Header menus: pinned — Rename, Set Folder… (local workspaces), Use Current
Tab's Folder, Clear Folder, New Tab, Unpin (folder groups), Delete. Auto —
Pin Group, New Tab. Nothing renames an auto group; nothing pins implicitly.
* feat(sidebar): open folders as pinned groups from Finder, the file tree and the palette
Every way into a pinned group the design calls for:
- Drop a folder from Finder or Explorer onto the sidebar to pin it (a local
workspace only — a dropped path is this machine's, and a folder group keeps
a directory on the workspace's host). Files are let fall.
- "Pin as Group" on a folder in the file tree, on local and remote workspaces
alike, since the tree and the group are both on the workspace's host.
- Palette "New Group" makes an empty label group and opens its name for
typing; "Open Folder as Group…" picks a folder with the system picker, pins
it and opens a tab in it. The picker browses this computer, so that one is
not offered on a remote workspace.
- Tab right-click "Move to Group" lists the pinned groups plus "New Group…",
which files the tab in a fresh label group with its name open for typing.
Pinning a folder already pinned hands back the group that keeps it rather
than making a second one to split its tabs with.
* fix(sidebar): let groups that arrive from elsewhere pull no tab into a folder
A window draws its first frames before its copy of the workspace's groups
lands, so every tab's entry watch recorded "in no folder" — and the groups
landing then read as each tab walking into its folder. A restored tab, or one
dragged out of its folder group, was pulled back in on every launch.
Groups adopted from a pull or from another window's `GroupsChanged` now start
every tab's watch over from where it is; only this window's own pin gathers
the tabs inside the folder, and says so tab by tab. A tab also goes up with
the group it names even when the window does not know that group yet, so a
sync in that same gap cannot send every kept tab back to auto grouping.
* docs(sidebar): describe pinned and auto groups, and log the change
Rewrite the sidebar page's grouping section around "grouped by repo
automatically; pin what you want to keep": the divider, folder and label
groups, the edge-triggered join, every way to pin, and the header menus. The
configuration reference swaps `sidebar_grouping` for `sidebar_auto_grouping`,
the CLI reference describes the GROUP column as the pinned group, and the
changelog gains an Unreleased entry (#955).
* fix(sidebar): file a tab opened by the CLI in a pinned folder into it
A tab that reaches a window as TabCreated — from `tty7 tab new` or another
window — started its entry watch as a restored tab, so opening one inside a
pinned folder left it in the auto group below. It is as new as a tab opened
here, and now joins the folder like one; every window that hears of it
reaches the same answer.
* test(machine): build the group sets in their initializers
Clippy's field_reassign_with_default on the two WorkspaceGroups the
set-groups test assembles.
* fix(sidebar): draw restored tabs in their auto group, and title by repo again
Auto groups are not stored, so after a restart every tab sat in Ungrouped
until its own repo probe came back, then jumped; before pinned groups the
stored repo key put it in place on the first frame. Each tab now carries
`last_auto`, the auto group it last resolved to, as a hint: stored with the
tab, sent up alongside its group in `TabSetGroup` whenever the live answer
moves, and used to draw the tab until the probe answers. The probe always wins
and rewrites the hint, and the hint never outranks a pinned group or the
folder-entry rule. Another window's hint only fills a gap, so two windows can
never bounce a disagreement between them.
The workspace's fallback title regained the repo majority it lost: the most
common pinned folder first, then the repo most unpinned tabs were last filed
under (a worktree counting toward its repo home), then a pane's cwd.
* refactor: drop what the new sidebar left unused, and two clippy findings
- `TerminalView::native_ssh_cwd` and its helper existed for the sidebar's old
folder grouping of native SSH panes; an SSH tab now groups by host, and
nothing else read it.
- The file tree's context menu takes `cx` instead of `danger` and the new
groups flag, back to the argument count it had on main.
- A title test builds its workspace in the initializer.
* feat(agents): quick launch for detected CLI agents (#955)
Every agent whose launch program is on PATH becomes a palette command,
"Agent: <name>", ordered by frecency and bindable as LaunchAgent:<slug>.
"New Agent Tab" (Cmd+Shift+A on macOS; unbound elsewhere, where
Ctrl+Shift+A is select-all) launches the most recently used one, and the
New Tab menu gains a single "Launch Agent..." row that opens the palette
pre-filtered to them.
A launch always opens a new pane (a tab in the active tab's cwd, or a
split when picked from the palette with Alt held) and types the command
into that pane's shell once it exists - immediately for a local pane, on
landing for a remote one via PendingSpawn::run_on_land - never into a
pane that was already there. Detection, status and resume then work as
for a hand-typed agent.
The command is the agent's bare binary unless the new `agent_launch`
config map overrides it. A wrapper named there is detected as its agent
without an `agent_commands` entry: the daemon folds the programs
`agent_launch` runs into its alias map (interpreters, shells and real
agent names excepted), reloaded when config.json changes instead of
once per process, and a mapped script run under its interpreter
(`bash ~/bin/cc`, `node cc.js`) is now recognised too.
A running agent's pane menu gets "Set Current Launch Args as Default",
which writes its launch argv - minus session flags and positional
prompts, joined with the settings' quoting - into `agent_launch`.
Remote workspaces cannot be asked for their PATH through the Host
trait, so they offer the agents previously seen running in that
workspace (WindowView::seen_agents).
* fix(agents): count only agents that start under a view, not ones reattached to
A view rebuilt over a running pane - every agent tab after an app
restart, or a workspace switched back to - saw its agent appear from
nothing and reported it as detected, bumping that agent's frecency once
per launch of the app. The terminal now remembers whether its link was
an attach, and such a view only reports agents once its shell has been
seen back at the prompt with no agent in front.
Also pins down that the agents seen in a remote workspace, its quick
launch list, persist in views.json with the rest of the workspace.
* fix(daemon): probe the foreground as soon as a new program takes it
The foreground probes ran on output only, at most once per 500ms
interval. A quick launch types the agent's command the moment the shell
is up, so the agent drew its whole first screen inside the interval of
the prompt it was typed at and then waited for a key: the pane never
learned it was running an agent until something else printed. Seen in
a dev instance, where a launched Claude Code stayed undetected at its
trust prompt.
The reader now asks the pty for its foreground process group on every
read (one ioctl) and probes immediately when it changes.
The README now opens on an animated WebP of the one-minute tour (GitHub
won't play an mp4 inline), linking the full-quality mp4. The docs home
page autoplays the mp4 in the hero slot. The old hero screenshot and the
separate tour poster block are gone.
Every placeholder frame in docs/ now shows a real capture of the current
build: 20 screenshots plus two short looping clips (prompt editor, pane
drag). The README and the docs home page link a one-minute tour covering
agent status across repos, one agent driving another through the CLI,
the prompt editor, diffs, pane dragging, and sessions surviving a quit.
Cursor's ~/.cursor/hooks.json is a flat hook map — `command` directly on
each entry under `hooks.<event>` — so it reuses the flat writer Crush
introduced. Two things are Cursor-specific:
- The file needs `"version": 1` at its root or Cursor ignores it. tty7
now writes it when it creates the file or finds it missing, never
overwrites a version the user set, and reports a versionless file that
holds our hooks as Outdated so refresh repairs it.
- Payloads name the session `conversation_id` (only sessionStart repeats
it as `session_id`), and most events carry `workspace_roots` instead of
`cwd`. Both are now read as aliases, which is what Copy Session ID and
resume were missing.
Events: sessionStart, beforeSubmitPrompt, postToolUse, stop, sessionEnd.
cursor-agent does not fire beforeSubmitPrompt today (a known gap on
Cursor's side), so postToolUse also reports prompt-submit: the first tool
call opens the turn and Cursor's stop, which the CLI does fire, closes it.
A turn with no tool calls never shows as working. None of Cursor's
permission hooks are installed, since those would block on the empty
stdout tty7's hook prints.
CodeBuddy Code takes Claude Code's hooks as-is: the same nested
`hooks.<Event>[].hooks[{type, command}]` shape in ~/.codebuddy/settings.json
(or $CODEBUDDY_CONFIG_DIR/settings.json), the same event names, and the same
session_id/cwd payload fields. So it rides the shared hook-map installer and
needs no payload aliases.
The event table follows Qoder rather than Claude: CodeBuddy has a
first-class PermissionRequest and Elicitation, so it gets no Notification
hook, and StopFailure ends a turn like Stop. CodeBuddy also emits
SessionStart with source "compact" mid-turn, which is filtered out the same
way Qoder's is so the pane does not drop back to idle.
Detection covers all three npm bins (codebuddy, codebuddy-code, cbc).
Resume is `codebuddy --resume <id>` and fork appends --fork-session; stale
session and worktree flags are dropped from the replayed launch argv, and
--no-session-persistence disables both commands.
Icon, en/ja/zh names and search keywords, and docs are updated.
The support matrix left Fork blank for Droid, Qwen, and Goose, but all
three have a fork command in CLIAgent::fork_label and hooks in
HookAgent::ALL, so the menu entry is reachable. Amp stays blank: it has a
fork command but no hooks, so no session id ever arrives and can_fork
never turns true — which the intro paragraph now states.
Also restores "click places the caret" and IME to the input and window
rows, folds the prose that had crept into the Agent-aware, CLI, and Git
cells back into scannable fragments, and drops the Why lede's repetition
of the three bullets directly beneath it.
* feat(agents): wire hooks, resume and detection for Kimi Code
Kimi Code CLI takes its hooks as [[hooks]] entries in the same
config.toml that holds the user's providers and models, so this adds a
third install strategy — a format-preserving TOML merge on toml_edit —
beside the JSON map merge and the owned files. Like Qwen it reports
permission requests first-class, so it gets no Notification hook.
Resume rides `kimi --session <id>`; fork stays unwired, Kimi
documents none.
Closes#693
Signed-off-by: Austin Spraggins <spragginsdesigns@gmail.com>
* fix(agents): harden the Kimi Code TOML hook merge and its resume flags
The TOML merge strategy the Kimi wiring introduces round-trips a shared
config.toml cleanly, but three gaps sat behind it.
`hooks_state` counted only the marked entries that still named an event,
so a hand-edit that dropped the key off one of nine entries left the
remaining eight matching the roster exactly and the file reported
Installed with a broken entry in it. Every marked entry now counts,
which is what the JSON merge already did and what `refresh_hooks` needs
to see.
A `hooks = []` spelled as an empty inline array made install fail
outright -- toml_edit keeps an empty array and an array of tables apart,
but the two say the same thing and neither carries any configuration. It
is now promoted rather than refused. Every other wrong-shaped `hooks`
key -- a string, a table, a non-empty inline array -- still refuses with
the file left byte-for-byte alone.
`Stop` is not the only way a Kimi turn ends: its own event reference says
`Stop` does not fire on interrupts and `Interrupt` fires instead, and a
turn that dies on an error reports `StopFailure`. Without those two an
Esc or a failed turn left the pane on "working" for good and `tty7 wait`
could only ever time out. Both are observation-only events and report
the same end of turn `Stop` does.
On resume, `--agent` and `--agent-file` join the stale flags: Kimi
rejects either next to `--session` at startup, and resuming rebinds the
session agent by itself, so replaying them turned a working resume into
a launch error.
Tests cover the wrong-shaped `hooks` keys, a config.toml that does not
parse on both install and uninstall, a file that does not exist yet, a
second install being byte-for-byte the first, mangled and surplus marked
entries, an uninstall threading between the user's own entries and the
tables after them, and the `--session=<id>`, bare `--session`,
`--continue` and `--agent` spellings on the resume path.
---------
Signed-off-by: Austin Spraggins <spragginsdesigns@gmail.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
`skills add` does not refresh a skill that is already installed, so the
one install line left existing users with no documented way forward.
Add the `skills update tty7` counterpart to both READMEs and give the
agent-skill page a short Updating section covering update and remove.
38 pages under docs/, written against the source rather than the README:
config keys and their clamps from core::config, default keybindings from
ui::keymap, every CLI verb and flag from tty7-cli, agent aliases and
hook/fork/resume support from core::cli_agent, and Settings paths taken
from the actual en-US strings.
docs/features.md and its zh-CN translation are retired — everything in
them now lives in a page of its own, plus the two things they carried
that nothing else did (IME input, the performance notes). README and
README.zh-CN point at docs/ instead.
Screenshots and videos are placeholders for now: docs/images/placeholder.svg
with a caption naming what each shot should be.
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
main shipped v26.8.2 and 15 fixes while this branch was open. Resolved:
- zh: main's #417 decided the background process is called "server" in
Chinese, and that decision is newer than this branch's "服务器" — took
it, kept this branch's typographic quotes around {machine}, and
whitelisted SettingsServer in the new every-key-is-translated test,
since the zh heading is now that English word on its own.
- presets.rs: this branch factored main's inline `clear` closure into
Theme::clear_ink / ansi_seed; same arithmetic, so kept the methods.
#400's border and caret floors and #413's legible-palette flag both
survive untouched.
- app.rs: took main's Option-typed `alive` argument, kept this branch's
note on why a dropped tab is worth a sentence.
- README / docs: agent count is now exactly 18 with Oh My Pi, so the
precise number replaces both "17" and "~18"; the zh feature doc keeps
its translated menu names and gains Oh My Pi in the fork list.
Six keys main added are gone because the surfaces that used them were
rewritten here: the home screen's relative time now runs to years, hook
failures name install vs remove, Full Screen left the View menu on
purpose (AppKit adds its own), the SFTP filter says "search files", and
the settings index titles its CLI row by its own label.
CLIAgent::ALL is [CLIAgent; 17], so "~17 CLIs" and "and ~10 more" were
hedging about a constant. Six agents are named in the features list, so
the remainder is exactly 11.
Issue #376 asked for `omp`. Oh My Pi is a fork of Pi (can1357/oh-my-pi,
descended from badlogic/pi-mono), but the fork is where the similarity
stops for our purposes: it ships one binary of its own — `omp`, the only
`bin` in `@oh-my-pi/pi-coding-agent`, and it never installs a `pi` — and
it keeps its config under `~/.omp`. A pane running it was therefore not
detected at all, and aliasing `omp` onto `CLIAgent::Pi` would have been
worse than nothing: the status bridge would land in `~/.pi`, and Resume
Session would offer `pi --session <id>` to a binary that spells that
flag `--resume`.
So it gets its own variant, wired the whole way through:
| | |
|---|---|
| Detection | argv stem `omp`, distinct from `pi` in both directions |
| Avatar | its own mark, normalized from the project's `assets/icon.svg` |
| Resume | `omp --resume <id>`, opting out on `--no-session` |
| Fork | `omp --fork <id>` — a verified fork command, so the menu item appears |
| Hooks | Settings → Agents, at `~/.omp/agent/extensions/tty7/index.ts` |
The status bridge is the one piece the fork did not change. Oh My Pi
inherited Pi's extension contract intact — same default-exported factory,
same `session_start` / `agent_start` / `agent_end` / `session_shutdown`,
same `ctx.sessionManager.getSessionId()` — so `pi_extension_ts` now takes
the agent and substitutes two things, the package it imports the type
from and the slug it calls the emitter with. Pi's generated file is
byte-identical to before, so no installed bridge goes stale.
`--resume`, `-r` and `--session` are three spellings of one flag in Oh My
Pi; all three shed when a session command is rebuilt, while `--session-dir`
is a different flag and rides along. `fork_command` now honors the same
`--no-session` opt-out `resume_command` already did — Oh My Pi rejects
`--fork` outright under it, and no existing agent declares an opt-out.
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
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).
Replace the orange window-and-cursor identity with the "Duo" mark: two
offset session panes (mint #3FDD8C behind, ink #17171A in front) with a
prompt chevron, on a cream tile following the Big Sur icon grid.
- app-icon.svg is the master; app-icon.png, tty7.icns and favicon.ico
are re-rendered from it (rsvg-convert + iconutil + PIL)
- logo.svg/logo.png/logo@256.png carry the tile-less transparent mark
- tray.svg redrawn as the same mark in template form: solid front pane
with the chevron masked out, back pane at partial alpha
- bare (non-bundled) macOS binaries now set the Dock icon at runtime
via NSApplication.setApplicationIconImage, so cargo dev/run shows the
logo instead of the generic executable icon
- README version badge and the social preview switch to the new brand
green; social preview copy re-aligned with the current README slogan
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.
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.
* 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>
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>
* 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>
Implements issue #61's tmux-like input model in three layers — editable shortcuts (Settings → Keybindings), directional pane focus/resize/swap + relative tab nav + Activate Tab 1-9, and a tmux prefix preset — without parsing ~/.tmux.conf and without changing zero-config defaults. Closes#61.
* feat(release): ship a Linux AppImage alongside the tarball
The Linux release was a bare, dynamically-linked binary built on
ubuntu-latest, so it only reliably ran on Ubuntu — Fedora/Arch users hit
missing/mismatched runtime libs. Add an AppImage that bundles the
x11/wayland/xkb/fontconfig/freetype libs so it launches across distros.
- bundle-appimage.sh: linuxdeploy populates an AppDir + deps, completions
go beside the binary (usr/bin/completions, matching signature.rs's
current_exe lookup), appimagetool packs it. Runs FUSE-less on CI.
- release.yml: new "Package Linux AppImage" step after the tarball,
libfuse2/file added to the Linux deps, *.AppImage added to the upload
list. The AppImage step avoids `rm -rf dist` so the tarball survives.
- README (en + zh): recommend the AppImage, keep the tarball as the bare
fallback.
Note: glibc is not bundled, so ubuntu-latest still sets the glibc floor.
* fix(release): downscale AppImage icon to a resolution linuxdeploy accepts
linuxdeploy rejected the 1024x1024 app-icon.png (its valid list tops out at
512). Resize to 256x256 with ImageMagick's convert (added to the Linux apt
deps) before handing the icon to linuxdeploy.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>