* 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.
Saved SSH hosts and their usage counts move out of config.json into
servers.json beside it, so config.json can be synced between machines
without carrying a server list (#911). An older config.json is split on
first load: servers.json is written first (0600), then only the two keys
are removed from config.json, leaving every other key as it was. When
both files hold hosts, servers.json wins and the stale copy falls out of
config.json at its next save. A servers.json that does not parse is kept
aside and blocks saves, as config.json does; hand edits hot-reload.
Settings -> Window & Tabs -> SSH tab title (`ssh_tab_title`) pins an SSH
tab to the profile name (saved host name, ~/.ssh/config alias, or the
address typed for a quick connect) or the hostname, on the OSC title's
rung of the existing label ladder: a renamed tab still wins, OSC titles
are still tracked, local panes are untouched (#726).
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.
Add a macOS-only `font_thicken` key (default true) and a Settings row
under Appearance > Terminal text. When off, AppleFontSmoothing is pinned
to 0 in this process's NSArgumentDomain before gpui's text system first
reads it, so glyphs render at the face's own weight. The volatile domain
is in-memory only: nothing is persisted and no other app is affected.
gpui caches the preference in a OnceLock, so a change applies after a
restart; no gpui fork change is needed.
Add SelectNextTab / SelectPrevTab, which move to the neighbouring tab in
the order the strip or sidebar shows them, wrapping, with no popup.
Defaults: Cmd+Shift+] / Cmd+Shift+[ on macOS, Ctrl+PgDn / Ctrl+PgUp
elsewhere. The tmux preset's prefix n / p now bind these (tmux
next-window semantics) instead of the MRU switcher, which never
auto-committed there.
NextTab / PrevTab keep their config names and Ctrl+Tab, but are labelled
Recent Tab Switcher on the Keybindings page; the palette's Next/Previous
Tab entries now show the SelectNextTab chord they actually run.
Alt+1..9 are vim's tab keys, and tty7 takes all nine for Go to Tab.
Two things stood between the reporter and getting them back.
**Nothing in the app could leave an action unbound.** Backspace on a
Keybindings row that has recorded nothing *reset* the row — dropped the
override so the action gets its shipped chord back. On a row nobody had
overridden, which is every row the first time it is looked at, that is a
no-op: pressing it over Alt+1 left Alt+1 sitting exactly where it was,
which reads as the default restoring itself. `config.json` has spelled
"no chord" as `[]` since #868, but no gesture wrote it.
Backspace now writes that empty list. The row falls to `—` and grows the
**Reset** button every overridden row has, which is the way back to the
default. The capture hint names the key, and the docs say what it is for.
**A keybinding line serde could not read failed the whole `Config`.**
`keybindings` is a hand-edited map and was strict, so `"ActivateTab1":
null` — or a number, or an object — quarantined `config.json` and started
the app on built-in defaults. Every rebinding in the file then read as
its shipped default, and the next settings write persisted those
defaults over what the user had written. It now reads one entry at a
time, like every other hand-edited nested key here: the lines that name
a shortcut bind, a line that does not is logged and skipped.
Tests, each failing on the unfixed code:
- `ui::app::keybinding_gpui_tests::backspace_on_a_row_unbinds_the_action_rather_than_restoring_its_default`
- `core::config::tests::a_keybinding_line_that_cannot_be_read_does_not_take_the_config_with_it`
and `ui::keymap::gpui_tests::alt_digits_can_be_moved_off_the_tab_actions_for_good`
pins the merge and a save/reload round trip: a list replaces the shipped
Alt+1, `[]` leaves nothing, and neither comes back after a restart.
Fixes#901
Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
A chord added in config wins a tie only against another app-wide action.
Copy, paste, find, clear scrollback and insert newline are bound in the
Terminal context, which is deeper, so they keep their chord while a
terminal is focused. Also tells people upgrading that a shortcut recorded
in Settings up to 26.9.2 was saved as a string. It now adds instead of
replacing, and a list brings back the old meaning.
Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
effective_bindings kept one chord per action and set_binding overwrote that
slot, so "NextTab": "cmd-shift-]" silently took Ctrl+Tab away.
A string in keybindings now adds a chord beside the action's default (or
preset) chord; "" still unbinds, as configs and the docs already rely on; a
list is the exact chord set, [] unbinds. Configured chords are installed after
every shipped one, so a chord the user names wins a tie with another action's
default. The Settings page lists every chord of an action, and recording a
shortcut writes the list shape (it sets the binding) and takes only the stolen
chord from the action that had it.
Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
* feat(settings): add interface font family configuration in appearance typography
* fix(settings): let the interface font go back to the system face
Three things the new **Interface font family** row spelled once and needed
twice.
`apply_theme` only wrote `Theme.font_family` when the setting was `Some`,
and `Theme::change` never puts it back — it rewrites the field only when a
theme config names a face, and none of ours does. So picking a font worked,
and picking **Default** back saved `None`, redrew every window in the font
the user had just cleared, and only came true at the next launch: a setting
that looked like it had applied instantly and had not. The face is now
assigned in both directions, against the stock value read once before
anything overrode it.
The dropdown's first row borrowed the bold/italic label, "Default (match
primary)" — which promises the *terminal's* primary family. The interface
falls back to the system UI font instead, so the row said the chrome would
come out in Hack while the description beside it said the opposite. It gets
its own label in all three locales.
`ui_font_family` was also the one key in `config.json` that disappeared when
unset; every other optional key is written as `null`. Dropped the
`skip_serializing_if` so the file still lists it, and documented the key in
the two tables that enumerate the typography settings.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
* refactor(i18n): drop the About page shell primer and trim the long copy
The About page carried a "How shells work" section explaining that shells
live in a background server. Nothing linked to it and the Updates and
Server sections below already say what happens to those shells, so it was
a paragraph of prose the page did not need. Remove it, its search index
entry, and its three L10nKeys.
Then cut the padding out of 48 strings across settings rows, dialogs and
notices. Two patterns accounted for most of it: the restart-server
dialogs stated "your shells keep running" up to four times each in
different words, and the config.json failure notices packed three
subordinate clauses into every sentence.
Nothing is dropped but repetition and clauses the reader can infer —
every consequence a dialog asks the user to weigh is still spelled out.
en, zh and ja stay in sync.
* feat(themes): add Catppuccin Mocha, Gruvbox Dark, Nord and Tokyo Night
Four more dark built-ins, taking the set from nine to thirteen. The docs
table and description are updated to match.
* fix(themes): give Catppuccin Mocha its rosewater caret, refresh a stale builtin count
Found by driving a dev instance and measuring what an idle window costs.
Two of them were frame loops that never stopped. A `Head` diff overlay
calls itself stale when the cached git status disagrees with the snapshot
on screen, and every landed probe wakes that check by touching the cache
— but the read published its counts and left the branch behind, so a
branch switched outside tty7 made the disagreement permanent: two `git`
processes a lap, forever, with `refreshing…` pinned to the header and an
idle window at 7% of a core. And the home page asked for a frame sixty
times a second to change one glyph's opacity twice, which made a window
with nothing open in it eight times more expensive than one running a
shell. Both now settle: the diff read publishes the branch it found, and
the home cursor flips a bool on a timer the way the terminal's own does.
The rest:
- The tab sidebar and the right panel each capped themselves at half the
window and knew nothing about the other, so together they could take
all of it — 260 points of terminal on a 720-point window. Both now cap
at whichever binds harder: half the window, or what is left after the
terminal's floor and the other panel's floor. The same cap bounds the
drag, so a panel dragged to its limit stays where it was dropped.
- Only a HEAD diff may correct the sidebar's counts. Those numbers mean
`git diff --numstat HEAD`; an unstaged or staged patch answers a
smaller question, so opening an untracked file from the Source Control
panel took the staged lines off the total on the click.
- An untracked row in the diff overlay had no click target, and once
focused could not be left — the breadcrumb looks the path up in
`files`, where an untracked file has no entry. Both ends fixed.
- A new pane keeps the name its directory was reached by. `cwd()` alone
loses it: the shell falls back to `getcwd()`, so `/tmp/x` became
`/private/tmp/x` in every tab opened from the first. `PWD` carries it,
and POSIX has the shell discard a `PWD` that names the wrong
directory, so this can correct the name and cannot invent one.
- The settings search now sees into the Keybindings page, which is
generated from the binding table rather than the static index — so
searching for a feature finds its shortcut, and the page filters to
the matches. Closes#444.
- The settings reading column is centred rather than pinned to the nav:
on a window as wide as the display it was made for, 640 points of
settings sat beside 1600 points of nothing.
- `New Workspace…` takes the ellipsis its three sibling actions already
carry — it opens a form asking for a name and a host.
Every fix has a test. The re-probe loop is pinned end-to-end with
`render_probe::draws() == 0` against a real repository, confirmed to
fail on the old behaviour before it was kept.
Audited every page under docs/ against the source. Fixes for what the
code actually does:
- agents: the status vocabulary is idle/working/waiting/done, not
running/waiting/idle; hook rows grow a separate Uninstall button; the
Settings table labels read "Copilot CLI" and "Grok Build"; Copy Session
ID lives in the tab's context menu, not the pane's
- cli: `pane ls --all` reports the owning workspace id, not "tty7-cli";
document bare `tty7 [PATH]` as the GUI launcher it is instead of listing
it as unimplemented; note `active_tab` and the `diagnostics` array; wait
also defaults to $TTY7_PANE
- git: the branch dropdown is a plain list with no search box and no
stash-and-switch, and checkout is not a palette command; quote the diff
overlay's own overflow notice rather than the sidebar's
- window: the unread marker tracks a finished agent turn, not any output;
rows cannot be dragged across groups; the sidebar and `tty7 tab ls`
resolve labels differently; drop Toggle Commit History and Checkout to
from the palette's Git group; ~/.ssh/config aliases are not palette
entries
- terminal: Ctrl+R dedups by command text and shows no directory; Esc does
not dismiss a ghost suggestion; document Cmd+Enter
- remote: GSSAPI is an ordinary Auth choice, not a managed-connection-only
mechanism
- fonts: Maple Mono NF CN leads the chain on Windows and Linux only; list
the real per-platform defaults
- settings paths: the three Links settings and per-pane history were filed
under the wrong sections
The in-app switch that wrote `~/.claude/skills/tty7-orchestration` was
removed in da6df70, before any release carried it — `git grep` finds it in
no tag. The changelog entry recording it landed the same day, written from
the branch's state rather than the tree's, and the documentation site (#478)
was then written from the changelog, so the feature reappeared on three
pages describing something the app has never shipped.
The orchestration page now says what is true: nothing is installed for you,
the agent-facing contract is `skills/tty7` and you add it yourself with
`npx skills add l0ng-ai/tty7`. That skill covers driving panes; `wait` is
documented on the orchestration page itself, so neither page promises what
the other holds.
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>