Commit Graph
169 Commits
Author SHA1 Message Date
l0ng-ai 241b91d70b chore(release): v26.9.4 2026-09-29 12:58:21 +08:00
LiuSirandstevelliu e06ba47017 feat(tabs): name the agents you run in the New Tab menu (#994)
The + menu lists up to three agents that have actually been run, beside
Local and SSH. Other Agents… opens Search Everywhere already filtered to
agent. New Agent Tab still starts the one used last.

Refs #955

Co-authored-by: stevelliu <stevelliu@tencent.com>
2026-09-28 16:31:39 +08:00
e94c0d39cb fix(path): read the interactive shell's PATH, not just the login shell's (#989)
* fix(path): read the interactive shell's PATH, not just the login shell's

The GUI starts with launchd's bare PATH and refills it by running the login
shell. A login shell reads .zprofile/.bash_profile and never .zshrc/.bashrc,
so every directory exported there was invisible to the app: agent quick
launch found only what /opt/homebrew/bin and /usr/local/bin happened to hold,
and an agent_launch override could not bring a missing program back, since
the override's program is looked up on the same PATH.

Probe with -i as well (fish unchanged: it reads its config in every mode) and
take the last non-empty line, so an rc that prints a greeting above the
echo $PATH cannot be mistaken for the value.

* fix(path): bound the interactive PATH probe and bracket its output

Running .zshrc/.bashrc on the startup path needs guards the login-only
probe could do without:

- Read the PATH between markers instead of the last line, so an exit
  hook that prints after it cannot be taken for the PATH.
- Return as soon as the closing marker arrives rather than at EOF: an rc
  that backgrounds a daemon hands it the pipe, which then never closes.
- Give up after 5s and kill the shell, so a slow or stuck rc delays
  startup instead of preventing it.
- Probe after forwarding an open path to a running window, which has no
  use for the PATH.

* docs(changelog): describe the bounded PATH probe

---------

Co-authored-by: stevelliu <stevelliu@tencent.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-09-28 12:12:55 +08:00
l0ng-ai b063ba97a0 refactor(settings): fold Window & Tabs into General, drop two settings (#982)
The Window & Tabs page is gone. Its three remaining tab settings (new tab
position, tab bar position, auto grouping) are a Tabs group on General,
below Startup & restore, so the nav has seven sections.

Removed outright:
- SSH tab title (`ssh_tab_title`, #726, unreleased). The sidebar already
  groups SSH tabs under their host, and renaming a tab pins its name.
- Open diff preview from sidebar counts (`sidebar_diff_preview`, #247).
  The sidebar counts and the Info panel's changes row always open the
  diff overlay; the large-tree stall it worked around is bounded. An old
  config.json that still carries either key loads as before.

The now-unused window settings icon goes with the page.
2026-09-27 19:11:45 +08:00
l0ng-ai fd2c4f7d4e feat(panel): Search and GitHub tabs in the right panel (#978)
* feat(panel): add Search and GitHub tabs to the right panel

The right panel grows from three tabs to five. Five word labels do not
fit the panel's 280px resting width, so the tab row now draws a glyph
per tab and names it in a tooltip.

Both new panes are placeholders here; the content search and the
GitHub issues/PR browser land on top of this.

* feat(panel): find in files in the right panel's Search tab

The Search tab replaces its placeholder with a content search over the
active tab's project -- the same roots the Files tab shows -- on the host
that project lives on. Hits arrive as you type (debounced, with a
generation counter so a stale answer never lands), grouped by file with a
count, each line excerpted with its matches highlighted. Clicking a hit
opens the built-in editor at that line and column; Enter searches again.
Match-case, whole-word and regex toggles sit at the end of the field, and
the tab focuses its field whenever it is brought forward.

Host::search_content is new on the Host trait, implemented once in
host::content_search (ignore walk + regex) and run by LocalHost directly
and by tty7-server over a new SearchContent control request. The walk
honours .gitignore with or without a repository, skips dot-entries, binary
files and files over 1 MB, and reports a capped search as truncated. The
request is gated on a new `content-search` hello feature, so a server that
predates it is never sent it; the panel says the server needs updating
instead of showing no results. Conformance cases cover local and the
stdio server alike.

* feat(panel): browse GitHub issues and pull requests in the right panel

The GitHub tab follows the focused pane's repository: its root is resolved
the way the Source Control tab does, its remotes are read through the Host
(the tree may be on another machine), and the github.com remote is bound,
upstream over origin in a fork, with a menu to pick another.

The list switches between issues and pull requests, open and closed, 50 rows
a page with Load more; rows carry a state glyph distinct by shape, labels
(click one to filter by it) and relative times. A row opens the detail in
place: title, state, author, labels, description and comments as Markdown,
and for a pull request its branches, size and changed files. A file opens in
the diff overlay through a new supplied-patch DiffSource, so GitHub's patch
renders exactly like a local one without a git probe.

Read-only, and sign-in reuses the GitHub CLI: GH_TOKEN, GITHUB_TOKEN, then
`gh auth token`, found on PATH or at the Homebrew locations a Finder launch
cannot see. Signed out, public repositories still work; 401, 403, 404 and
rate limits are told apart and explained. Requests go out from this machine
over the installer's ureq stack and proxy settings, on threads of their own,
cached per repository with background revalidation. Remote images in issue
text become links instead of loading, and non-web link targets are disarmed.

The Info tab gains a GitHub row that opens the branch on the remote it
tracks, or the repository for a branch never pushed.

* docs: list ShowRightPanelGitHub with the other panel actions

* feat(panel): one-line GitHub rows, a pill for the current tab

- GitHub list rows are one line: state glyph, #number, title. Labels and
  the age of the last update appear on hover, from state rather than a
  group_hover display switch, which gpui cannot paint.
- The current right panel tab sits on the sidebar's selected fill; ink
  alone could not tell five same-weight glyphs apart.
- The GitHub glyph is a 1.8px outline like the other tab icons, not the
  filled mark.
- The detail byline names both times (opened / updated) so it no longer
  reads as disagreeing with the list's update age.

* feat(github): show screenshots pasted into issues

Images GitHub hosts itself (github.com/user-attachments, a repo's
/assets, *.githubusercontent.com) now render in issue and PR text, each
in a paragraph of its own so the text view draws it at its size rather
than at line height. Images from any other host stay links, so opening
an issue still tells no third party that you read it.

gpui held a null HTTP client, so no remote image could load; the app now
installs the update check's reqwest client (same user agent and proxy)
at launch.

* fix(github): load private-repo screenshots, give inline code a neutral fill

- Pasted attachments (github.com/user-attachments/assets/<uuid>) want a
  browser session on a private repository, which an API token is not.
  The detail and comment requests now ask for the full media type, and
  each attachment is swapped for the signed private-user-images URL the
  rendered body_html carries for the same uuid.
- Inline code in rendered Markdown (the GitHub tab and the editor's
  preview) sits on a faint neutral fill instead of the theme accent,
  which is also the selection colour. Needs gpui-component 6af19d91 for
  TextViewStyle::inline_code_background.

* style(panel): tidy the GitHub and Search tabs' top rows

- GitHub drops its heading row on macOS. It existed only to hold the
  refresh tile, and no other tab has one; refresh now sits with the
  repository's other actions, in the repo row and a detail's header.
- Search's Aa / ab / .* toggles are muted while off instead of body ink.
- Search's idle note puts the folder on its own line, spelled ~/…, so
  the narrow column no longer breaks the path at a slash.

* feat(panel): order the right panel's tabs Info, Files, Search, Changes, GitHub

Info stays first as the default and the pane's overview; after it come
two pairs, the project's files (Files, Search) and its version control
from local to remote (Changes, GitHub), where Changes and GitHub were
split by the file tabs before. The palette, the Keybindings list and the
docs follow the same order.

* style(panel): drop the change count from the Changes tab

Beside one glyph of five, the number read as a badge on that tab alone,
and the Changes tab already leads with the same count under its own
heading. right_panel_tabs no longer needs the row's width, which it only
measured to decide whether the count fit.

* style(icons): fit the GitHub glyph to the other tab icons' size

The Lucide mark filled its whole 24px box, edge to edge, where tty7's
own icons keep about 3.5px clear, so at 15px it drew a size larger than
the four tabs beside it. Scale it to 0.9 about the centre, and raise the
stroke to 2.0 so it still renders at the others' 1.8.

* style(icons): a simpler GitHub glyph

Drop the Lucide mark's tail and redraw the head and legs on tty7's own
grid: the same ~15px live area and 1.8 stroke as the other tab icons, no
scale transform. The legs keep it reading as the Octocat; a head alone
read as any cat.

* test(github): find gh on PATH in the blank-variable token test

The test placed gh only at /opt/homebrew/bin/gh, which gh_candidates never
offers on Windows, so the Windows CI job panicked at unwrap. Put gh on a PATH
directory spelled with the platform's exe name instead.

* fix(github): close image and link bypasses in the issue Markdown sanitiser

Checked against markdown-rs (the parser TextView uses), several inputs got
past the line-based rewrite:

- is_github_hosted cut the host only at `/`, so
  `https://evil.io?.githubusercontent.com/x.png` (and `#`, `\`, `&#47;`)
  counted as GitHub-hosted and was fetched from evil.io. The host now ends
  at the first of `/?#\` and may hold only DNS characters.
- `<img src>` values were written into `![..](..)` unescaped, so a `)` in
  the value closed the image and opened a second one from any host. Written
  destinations are now percent-encoded.
- `<image>` (which the HTML parser reads as `<img>`) passed as an ordinary
  tag and loaded its src.
- A kept link target was copied without scanning; when the parser ended
  the link elsewhere (open title, unbalanced paren) a `![..](..)` inside it
  came alive. Markup characters in it are now encoded.
- `file&#58;///...` and similar character references passed is_safe_target
  and decoded to a `file:` link. References are decoded before judging.

The rewrite still cannot see every construct the way the parser does
(code spans inside tag attributes, fences the parser rejects, multi-line
link definitions), so the detail view now also checks the parsed tree: a
block containing a non-GitHub image, an unsafe link or definition, or raw
`<img>` is drawn as its plain source instead.

* fix(github): hide gh's console, bound Retry-After, and reject URL authorities with ?#\

- run gh through proc::output_within with hide_console, so a Windows GUI
  launch does not flash a console window and stdout is drained while gh runs.
- saturating_add a hostile Retry-After instead of overflowing i64.
- parse_github_url no longer accepts `https://evil.io#@github.com/o/r`.

* fix(search): no panic on an unbounded time budget, and read files through the size cap

ContentLimits arrive off the wire on a server; Instant + u64::MAX ms
panicked. A file that grew between the size check and the read was read
whole; it is now read through a take() at the cap.

* fix(panel): keep Load more on an empty filtered page, and drop another host's hits

- /issues pages filtered to one kind can come back empty while later pages
  hold matches; the GitHub list said "No issues" and hid Load more. It now
  reads on through up to five such pages and keeps Load more offered.
- While a new search runs, the previous hits stay on screen; if they came
  from another host, a click opened their path on the active host. They are
  now kept only when the host is the same.
2026-09-27 19:04:42 +08:00
l0ng-ai d6c223751d fix(i18n): proofread the Chinese UI copy (#980) (#981)
Fix half-width punctuation, unify terminology (passphrase, Finder,
server), quotes and dash styles, and rewrite the most literal
translations. Point every language and the CLI at Settings →
Integrations, the page's actual name, instead of Settings → Agents.
2026-09-27 18:32:56 +08:00
l0ng-ai ed939bbc26 feat(search): browse every agent's past sessions, locally and on remote workspaces (#977)
* feat(search): list more agents' past sessions, and fork, copy or hide one

The Sessions tab listed Claude Code and Codex only, and a row could only
be resumed.

- Past sessions of Gemini CLI, Qwen Code, Pi, Oh My Pi, Kimi Code,
  Copilot CLI, Droid, Qoder CLI and CodeBuddy are read from where each
  keeps them (honouring QWEN_HOME, COPILOT_HOME, KIMI_CODE_HOME, …), with
  the name the agent or user gave the session, else the first prompt.
  Context blocks agents prepend (<system-reminder> and kin) no longer
  hide a prompt.
- Cmd/Ctrl-E on a session row opens its actions: Resume, Fork Session
  (agents that can fork, with the configured launch flags), Copy Session
  ID, and Remove from List. Removing keeps the search open, drops the row
  at once and remembers it in `hidden_agent_sessions`; the agent's own
  history is not touched.

OpenCode and Cursor keep sessions in SQLite and are not read yet.

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

* feat(search): list remote workspaces' sessions, and OpenCode and Cursor

The Sessions tab only ever read this computer, and left out the two
agents that keep their history in SQLite.

- agent_history moves into tty7-core, and the scan goes through the
  Host: a local workspace reads this machine in-process, a remote one
  asks its server (new ControlRequest::AgentSessions; CONTROL_VERSION
  11 -> 12, so each remote host takes one Update Server). Resumed or
  forked sessions open on that machine, in the directory they ran in.
  The last answer is kept per host so the tab does not open empty.
- OpenCode: top-level, unarchived sessions from opencode*.db (or
  $OPENCODE_DB); a placeholder title gives way to the first prompt.
- Cursor CLI: chats under ~/.cursor/chats (or $CURSOR_CONFIG_DIR), named
  from meta.json or store.db. Cursor files a chat only under the md5 of
  its directory, so it is placed by matching open tabs' and other
  sessions' directories; chats nothing matches are left out, since they
  could not be resumed anywhere.
- SQLite is bundled (rusqlite), opened read-only, falling back to an
  immutable read when the writer's WAL cannot be shared.

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

* test(search): wait for the real scan before seeding sessions

The search's own scan runs on a real thread. On CI it landed after the
test seeded its rows and replaced them, so the edit gesture found no
row. The test now waits for the scan first. Assertion messages no
longer print session ids (CodeQL rust/cleartext-logging).

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

* fix(search): harden the past-session scan and note it in the changelog

- Honour CLAUDE_CONFIG_DIR for Claude Code's projects, as the hooks
  installer already does.
- Read a Codex rollout's head as bytes: the 2 MiB cap can split a
  multi-byte character, and read_line then dropped the whole session.
- Escape `%` and `#` (not only `?`) in the immutable SQLite URI fallback,
  so a database under such a directory still opens.
- Refuse a session id starting with `-`: ids now come from file and
  directory names on disk, and one would be read as a flag by the
  resume or fork command.
- Update the unreleased Sessions changelog entry for the new agents,
  remote workspaces, the Cmd-E actions and the v12 dialect bump.

* fix(search): spell the immutable SQLite fallback as file:///C:/ on Windows

SQLite reads file:C:/x as a relative path, so the fallback open (and its
test) failed on Windows. An empty authority and a leading slash name the
file on every platform.

* test(search): keep ? out of the fixture directory name on Windows

Windows file names cannot hold a '?', so the fixture's create_dir_all
failed there before the fallback was ever opened.
2026-09-27 18:18:05 +08:00
l0ng-ai c103a57c01 feat(settings): every pick-one setting is a dropdown (#979)
Single choices were drawn two ways — segmented buttons for most, a
dropdown for a few — and the segmented rows ran to their labels' width,
so the right-hand column never lined up. settings_choice now renders a
dropdown; an off-preset value shows as a checked "Custom (N)" row.
The SSH strip's forward form keeps the segmented control: it lives
outside the settings window and has no popover state to use.
2026-09-27 17:12:03 +08:00
l0ng-ai d0e42fa49b feat(tabs): drop the New Tab menu's Launch Agent row
Agents stay one search away: type agent in Search Everywhere's Terminals
tab, or use New Agent Tab.
2026-09-27 10:48:09 +08:00
l0ng-ai 84935813d5 feat(terminal): the mouse wheel no longer zooms the font by default
mouse_zoom_modifier now defaults to none. The platform modifier is cmd on
macOS, held for so much else that the font jumped size mid-scroll (#668).
Picking a modifier in Settings brings the wheel zoom back; cmd+/cmd- are
unchanged.
2026-09-27 10:42:02 +08:00
l0ng-ai 6fcf659e04 feat(search): tabbed Search Everywhere with a Sessions tab to resume agent sessions (#969)
* 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.
2026-09-27 09:38:31 +08:00
l0ng-ai 39ceb35fdd feat(cursor): give the shell prompt its own cursor shape (#963)
A new prompt_cursor_style setting (follow, block, bar, underline) shapes
the caret while the shell waits at a prompt, whether tty7's inline editor
or the shell's own line editor draws it. `follow`, the default, keeps
cursor_style everywhere, so nothing changes until it is set. Any other
value leaves cursor_style to the programs the shell runs: bar here with
cursor_style block gives kitty and ghostty's bar at the prompt and a block
inside a TUI that never sets a shape, such as Claude Code. A vi-mode
prompt keeps the shell's own insert/normal shapes.

Settings shows both cursor shapes as dropdowns, kept in step with resets,
language switches and config edits made outside the window.

Closes #958
2026-09-27 09:35:18 +08:00
l0ng-ai 334734b0b6 feat(links): make Windows file paths clickable (#971)
* feat(links): make Windows file paths clickable

Drive-letter paths with either separator and UNC shares resolve as file
links, including when CJK prose or a Markdown link is glued onto them.
Windows paths are spelled with backslashes and an upper-case drive so
Explorer can open and reveal them, the Windows join is textual so a Mac
asking a Windows host sends one spelling, and a POSIX pane looks a drive
path up under /mnt/<drive> for WSL. Drive-relative tokens (a:b, C:,
C:notes.txt) are never probed.

Closes #965

* feat(links): quoted paths with spaces and Open with Default App

A path enclosed in matching double quotes, single quotes or backticks is
read as one candidate even when it contains spaces, with a line:column
location inside the quotes or right after the closing one. The quoted
span must look like a path (a separator, no space at either end, at most
260 chars) and yields a single reading, so quoted prose costs one lookup.
Unquoted spaces still end a path.

The file-link context menu gains Open with Default App, which always
uses the OS association. It is shown only for local files and hidden when
link_file_open is already system. The OS opener and Reveal now spell
Windows paths with backslashes before handing them to Explorer.
2026-09-27 09:31:58 +08:00
l0ng-ai b5cb6efe7a style(sidebar): mark pinned groups with ◆ instead of a pin icon, drop the divider (#972)
The pin icon read as a smudge at header size. Kept groups now carry a small
◆ beside their name — every kept group, label groups too, since with the
divider gone it is the one thing that tells kept from derived. On a folder
group the mark is still the click that unpins it, and the hover button that
pins an auto group shows the same character.

The divider is no longer drawn at rest. It keeps its (smaller) place in the
layout and its recorded bounds, so pinning a header by dragging it up and
handing a tab back by dropping it below behave exactly as before, and it
still shows as a line while it is the drop target.
2026-09-27 09:31:49 +08:00
l0ng-ai 03c24514b2 fix(editor): ghost suggests the newest match, not the most frecent (#968)
The inline suggestion now takes the newest history entry that extends
the line, preferring one run in the current directory and falling back
to the newest anywhere, and skips commands whose last run failed. It
used to be the top frecency match, where run count and the directory
bonus outweighed recency. Ctrl+R keeps ranking by frecency.

Re-submitting a command now drops its older copy, matching the global
dedup applied when history loads, so up-arrow steps onto each command
once.
2026-09-27 09:31:43 +08:00
l0ng-ai a6e4235d27 fix(render): stop a blank IME preedit hiding the cell under the cursor (#970)
paint_marked blanks the cursor's cell to the theme background before laying
an IME composition down. A composition with nothing visible in it (Windows
IMEs can leave one behind) therefore painted a background-coloured hole with
an underline over the character and the block cursor on every cell the
cursor visited. Skip painting a preedit that has no ink.

Fixes #966
2026-09-27 09:31:35 +08:00
migegeandl0ng-ai e6009636b5 feat(tabs): reorder the active tab from the keyboard (MoveTabLeft / MoveTabRight) (#974)
* feat(tabs): MoveTabLeft / MoveTabRight — keyboard tab reordering

The drag that reorders a tab now has a keyboard form: the active tab trades
places with its neighbour in visual order, wrapping past either end. Ships
unbound like the pane-swap pair; bindable from config.json and the Keybindings
page, and listed in the palette and the docs.

* fix(tabs): keep a keyboard tab move inside its sidebar group

On a left tab bar the move stepped along the flat visual order, so a
step out of a group reordered self.tabs without moving anything on
screen (still saved, still synced as TabMove, and visible later on a
top bar), and a wrap through another group landed the tab at its own
group's far end by accident. The move now reassigns only the slots of
the tab's own sidebar section, and wraps at that section's ends.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-09-27 09:29:30 +08:00
l0ng-ai cf0e8f02e6 feat(sidebar): pinned groups above auto groups (#962)
* 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.
2026-09-25 16:53:26 +08:00
l0ng-ai afcb8aa2dd feat(agents): quick launch for detected CLI agents (#961)
* 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.
2026-09-25 16:47:59 +08:00
l0ng-ai 27483e893d feat(ui): collapse the New Tab menu's shell list to three by frecency (#960)
The + menu listed every shell the machine reports (nine on a stock macOS box)
above the SSH hosts. The Local section now names the default shell, always
first, then only shells that have actually been opened, by frecency, three rows
at most, the same way the SSH section caps its hosts.

Shell usage is recorded in a new `shell_frecency` config map, keyed by the
inventory label, bumped from both the menu row and the palette. Every shell is
now a palette command titled "Shell: {label}" (same word in every locale), and
an "Other Shells…" row opens the palette pre-filtered to them. That row is
hidden when the menu already names the whole inventory. Running a shell command
from the palette respects the ⌥/Alt split modifier like the menu row.
2026-09-25 16:44:32 +08:00
l0ng-ai bd0dd22bfa feat(tabs): hibernate a tab to free its memory and wake it later (#954)
A tab can be put to sleep from its context menu or the command palette:
its panes are stopped (screens kept on disk), the tab keeps its place in
the sidebar, and selecting it wakes it through the same restore a reboot
runs, resuming a supported agent's session. The sleep mark lives on the
machine tree, so it survives app and daemon restarts.

Closes #762
2026-09-25 16:41:12 +08:00
l0ng-ai 9f034558c0 feat(ssh): switch a port forward off without losing its rule (#953)
A forward could only be removed, so pointing one local port at another
remote target meant deleting the rule and retyping the other one (#439).

Forward rules gain an `enabled` flag (serde default on, so saved profiles
and older peers keep every rule live) and the daemon a SetForwardEnabled
op for panes and workspaces. Off releases the listener and keeps the
entry; on rebinds it from the rule it was made from, and is refused by
name when another switched-on forward of the same owner holds the port.
The Ports panel and the Settings rules editor each get a switch; a
switch flipped in the panel on a rule from a saved host is written back
to that host.

Closes #439
2026-09-25 16:38:00 +08:00
l0ng-ai de15f9b0ab feat(ssh): keep saved hosts in servers.json and add an SSH tab title setting (#952)
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).
2026-09-25 16:34:38 +08:00
l0ng-ai 02d8611e33 feat(cli): add exec, and send --stdin/--from-file/--paste (#951)
`tty7 exec %N -- CMD` types a line at an existing pane's shell prompt,
follows the shell integration's OSC 133 marks to the command's end, prints
what it printed (rendered to text by default, `--raw` for the bytes) and
exits with its exit code. `--timeout` exits 124 and leaves the command
running. A pane with no prompt marks, or one not at a prompt, is refused
before anything is typed.

`tty7 send` can take its text from `--stdin` or `--from-file`, sent byte
for byte and never echoed in --json, so a secret stays out of `ps` and
shell history. `--paste` frames the text the way the GUI's paste does:
bracketed when the pane has mode 2004 on, unframed otherwise, reported in
--json. The framing moves into tty7-core (`core::paste`) so the GUI's
clipboard paste, the agent prompt and the CLI share one construction, and
the daemon now reports each pane's bracketed-paste mode in PaneContext.

Closes #839
Closes #838
2026-09-25 16:32:19 +08:00
l0ng-ai 23948bcdda fix(render): draw fallback Nerd Font icons at the text's size (#949)
* fix(render): scale fallback-font icons up to fill their cell

A lone Private Use Area glyph supplied by a fallback face is drawn at the
primary's font size on the fallback's own metrics, so Nerd Font icons came
out about two thirds of the cell. Grow such a glyph, aspect ratio kept,
until it fills the width of its cells or the height of the row (at most
2x), and centre it there. Overflow is still shrunk as before, text from a
fallback face keeps its metrics, and the primary's own icons are untouched.

Closes #866

* fix(render): fit fallback icons to their cells, borrowing a same-colour blank

Checked in the running app with Symbols Nerd Font Mono behind Menlo: its
icons ink a full em (1.6 cells), so growth alone never fired. What made
them small was the one-cell budget an icon gets when the space after it
paints a background, which is every icon in a coloured prompt segment.

Fit a fallback icon to its cells and one em of height instead, letting it
take the blank after it when that blank paints no background or the
icon's own, and only when that makes it bigger. Leave the Powerline
separators to the existing rule.

* docs(render): name icon_fit in the fit_scale test's comment
2026-09-25 16:28:49 +08:00
l0ng-ai 455e74dc2c feat(scm): filter changed files and show them as a tree (#950)
* feat(scm): filter changed files and show them as a tree

* test(scm): key the tree test's settle on the panel's own root
2026-09-25 13:53:44 +08:00
l0ng-ai 1215eba16d fix(ui): drop the trailing tiles while a docked document holds the right edge
On macOS the panel toggle and app menu tiles sat at the end of the terminal
column's strip whenever the detail panel was closed, including when a docked
diff or file column stood to its right, leaving them in the middle of the
window beside the document's header. Hide them while a document is docked and
stop reserving their width for the tab chips.
2026-09-23 19:28:39 +08:00
l0ng-ai d843b82286 chore(release): v26.9.3 2026-09-23 19:21:12 +08:00
l0ng-ai 6aa83b4bd9 chore(release): v26.9.2 2026-09-10 11:50:47 +08:00
l0ng-ai 139ce81bf2 refactor(panel): drop the agent conversation outline
The Info panel's CONVERSATION section, and the jump back into the
scrollback behind it, are gone: the row list, the anchors the client
kept for it, and the scanner that cut a batch of pty output at every
agent event.

What is left is what the outline rode on rather than owned. The hooks
still send their OSC 777, the daemon still reads it for the tab's status
dot, and `AgentEvent::prompt` still parses — nothing else read the rows.

Output batches now split for one reason (the parked-cursor repair), so
the two-scanner merge and its sort go with the section, and a replayed
snapshot parses in a single pass again.

Claude-Session: https://claude.ai/code/session_01E4EPKzHg1fm9HMmHkUYpER
2026-09-09 18:13:47 +08:00
l0ng-ai 20b73adb2a chore(release): v26.9.1 2026-09-07 21:00:55 +08:00
l0ng-ai 37be703d5b chore(release): v26.9.0 2026-09-04 17:42:08 +08:00
ayamirandl0ng-ai e231b16fb3 feat(ssh): allow remote image clipboard writes (#766)
* feat(ssh): allow remote image clipboard writes

* fix(ssh): keep a profile's clipboard grant across a re-attach

A native ssh pane's OSC 5522 permission is decided by the spec that
dialled the host, and the daemon is the only side that holds it. A window
reopening onto a pane that outlived it attaches by pane id, has no spec
to read, and sends `allow_remote_clipboard_write: false` — which the
daemon took as the new answer and the pane's own view took as a refusal.
Both sides then said no, so the first restart after switching the
permission on turned every copy into an `EPERM` with the switch still
reading "on".

Pin the spec's answer in the pane and route both attach and detach
through one decision point, so a pane that carries a spec keeps that
spec's answer whatever an attaching client claims, and a pane without one
— everything on a remote `tty7-server` — is exactly as permitted as its
controller says. On the client side, refuse only what the pane can see is
forbidden and leave the verdict to the daemon otherwise.

Also: release a failed transfer's buffered bytes instead of parking up to
`MAX_CLIPBOARD_BYTES` per pane until the next request, and answer the
capability probe with the permission actually in force rather than a
constant that always reads as "off".

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-09-02 14:12:28 +08:00
l0ng-ai a2b5ae56d9 fix(panel): stop a conversation row offering a jump the pane cannot make (#759)
A turn's row is a link back into the scrollback, and `scroll_to_agent_turn`
refuses two cases: a turn with no anchor, and a pane sitting on the alternate
screen with no scrollback behind it. The panel only checked the first. So a
conversation recorded under the classic renderer kept its anchors, the user
switched the agent into a full-screen renderer — Claude Code's `/tui
fullscreen` — and every row went on drawing a pointer and a hover fill while
swallowing the click in silence.

Both conditions now live in one predicate the panel and the view agree on, and
a row that goes nowhere says why on hover: grey text reads as "less important"
long before it reads as "not a link".

Claude-Session: https://claude.ai/code/session_01A8Hiu4o14SkF5bpoPiV7Ko
2026-08-28 19:56:50 +08:00
l0ng-ai b4e7bf2e95 fix(updater): read the designated requirement off the stream it is on (#708) (#717)
`codesign -d -r-` writes the requirement to stdout and puts only the `-d`
display header (`Executable=…`) on stderr. `signing_requirement` searched
stderr, so the `designated => ` prefix could never match and every in-app
update on macOS ended at "codesign did not report a designated
requirement" — every build, every channel, with nothing a user could do
but download the app again by hand.

Verified against codesign rather than reasoned about:

    $ codesign -d -r- /bin/ls
    stdout: designated => identifier "com.apple.ls" and anchor apple
    stderr: Executable=/bin/ls

Both streams are read now, stdout first. Which half goes where is
codesign's own business and has moved before; a requirement printed
anywhere in the output is the requirement, and the updater has no reason
to be the stricter party about where it appeared.

The parse is split out of the process call, which is the part that
matters for it staying fixed. Fused to `Command::output`, it could only
run against a real signed bundle, so nothing in a test suite ever
executed it — that is why a total failure of the macOS update path
shipped and stayed. `/bin/ls` is the bundle it was missing: Apple-signed,
on every macOS, and it answers `-d -r-` with a requirement of its own, so
the stream split is now asserted against the tool instead of against our
belief about it.

Both tests were run against the old stderr-only parse; both fail there.
2026-08-25 16:16:08 +08:00
l0ng-ai 07e3b26434 feat(agent): outline a coding agent's conversation, and jump back to a turn (#703)
* feat(agent): outline a coding agent's conversation, and jump back to a turn

The hooks tty7 installs into Claude Code already announce every turn over the
pty as an OSC 777, and the daemon reads those for the pane's status dot. The
same bytes reach the client, where they are worth something else: the byte
offset a `prompt-submit` lands on is a *position in the stream*, so advancing
the emulator to exactly there and reading the cursor gives the scrollback row
that turn began on. That is an outline of the conversation, and a way back into
it — which is the one thing a long agent session in a terminal has never had.

The Info panel grows a CONVERSATION section: one row per turn, the prompt's
first line as its label, a dot that says whether the turn is still running.
Clicking a row scrolls the pane so that turn's prompt is the top line.

Not a fourth right-panel tab. `RightPanelTab` says out loud why there is no
room for one at 260px, and a fourth variant would drop anyone who rolled back
to an older build onto Info. This is a fact about the pane, like its shell and
its cwd, so it sits with them.

The hook is a subprocess writing to the controlling tty while the agent's own
renderer writes to it too. Claude Code repaints in place with ink, so the cursor
when the hook's bytes land is wherever the last repaint left it — inside the
live region, a few rows from where the prompt's echo comes to rest. And once
the scrollback limit starts discarding lines, every anchor slides by the discard
count at once.

So the anchor is a hint, and the prompt's own text is the correction: at click
time (by which point it has long been drawn) the row is looked for around the
anchor, exact match first — the row that *is* `> hi`, marker stripped — and only
then by containment, which keeps its length floor because `hi` appears inside
half the rows of any answer. The row that is found is written back, so a second
click does not search again and cannot land somewhere else.

Claude Code keeps a JSONL transcript, and reading it would give the assistant's
side too. It would also only work for Claude, only when the agent runs on this
machine, and only for a path this process may read. An OSC comes back through
the pty from wherever the agent actually runs — over ssh, in a container, in a
remote workspace — with no file access and no per-agent format. What is lost is
the assistant's text; what is kept is every host tty7 supports.

- `OscTokenizer::feed_at` reports each payload's end offset. The client already
  tokenized OSC 777 on every batch to keep agent events out of desktop
  notifications, so the scan is free; only a real event now costs a cut, which
  is what #404 was right to object to about the old per-command mark scanner.
- `Cut` is a two-variant enum again (cursor repair, agent turn). Two ascending
  runs concatenated are not one, so a batch carrying both kinds is sorted —
  and only such a batch pays for it.
- A replayed ring is cut the same way, so reattaching to a pane rebuilds the
  outline from its own history rather than losing it with the old client.
- Turn anchors are dropped where image placements are: `clear_scrollback`, and
  the grid reset in `adopt_relink`.
- A turn that began on the alt screen is listed but not clickable — there is no
  scrollback behind it to return to.
- A turn announced twice is one turn. Hooks are not guaranteed to fire once,
  and what makes it the same turn is that the one before it never ended: a real
  repeat can only come after an answer, and an answer brings a `stop`.
- The hook forwards the prompt's first line, clamped to 200 characters. The
  tokenizer *abandons* a payload past 8 KiB rather than truncating it, so a
  pasted file would otherwise cost the whole event; and a needle spanning a line
  break matches no single row.

No protocol change: the prompt rides in the OSC the hook already sent, and an
older client ignores the field.

* refactor(panel): drop the Info panel's agent row

It said `Claude Code · working` behind a status dot — the same name and the
same dot the tab chip and its sidebar row were already wearing, restated two
panels away from either of them. The CONVERSATION section that now sits under
it says what the agent is doing in a form the row never could: which turns
there were, which one is still running, and a way back to each.

`InfoValue::Agent` and `status_pip` went with it — the dot was the row's only
caller — and `PanelAgent` / `PanelAgentIdle` with those. The remaining three
status labels stay: the tray menu still names them.

`Tab::agent_row` stays too. `agent_status` is that pair's status and the tab
strip's badge reads it, which is the one-leaf rule #543 put there.
2026-08-20 21:56:34 +08:00
l0ng-ai 958d8b7442 feat(window): dock the code panel and the diff overlay beside the terminal (#625) (#685)
* feat(window): dock the code panel and the diff overlay beside the terminal (#625)

Opening a file covered the workspace. The terminal underneath kept
running and was neither visible nor typeable, so reading a file while an
agent talked was a toggle loop: open it, close it to read the reply, open
it again. The Files tree already docks; the two surfaces you go to *from*
it did not.

They dock now, as a flex sibling of the terminal column rather than a
narrower overlay — that distinction is the feature. `set_grid_size` is
driven by the terminal element's laid-out bounds, so a column takes width
away from the grid and the PTY reflows into what is left; a card painted
over half the workspace would have left the grid full width with half of
it hidden.

`overlay_top` stops ordering a pair and starts choosing between them: a
column has one child, and two `flex_1` siblings would split it and fight.
Fill mode keeps the old vector, the old opaque paint and the old platform
hoist untouched, so nothing about today's overlay changes for anyone who
picks it.

- Half the terminal column by default; drag the divider, double-click it
  to cycle a third / half / two thirds, or use the palette commands. Two
  thirds deliberately runs past the half-window cap the side panels obey
  — only the terminal's floor binds it.
- `DOCUMENT_MIN_W` joins the width budget: both side panels reserve it
  the way they already reserve each other, and the column is derived from
  the *live* sidebar and panel widths rather than their floors, so a
  panel someone dragged wider is width the terminal keeps.
- A window too narrow to seat both fills for that frame. The fallback is
  derived at render time and never stored, so widening re-docks on the
  next frame with nothing to undo.
- Fill or dock is per tab, on the header's context menu. Reading a long
  file over the whole window in one tab while an agent keeps half of
  another is the normal case, and one global switch made each of those
  flip the other. A tab that has not been told reads `document_layout`
  from the config, which is what a fresh tab starts as — and which the
  menu therefore does not write, since every untold tab is reading it.
- Everywhere but macOS the title bar spans the workspace, which left a
  bar's height of nothing above the column. The header is drawn into it,
  and behaves like the title bar it now sits in. With the detail panel
  closed the column reaches the window's right edge, so the header stops
  short of the trailing chrome through a width the tab strip's own
  reservation shares.
- The docked headers drop the traffic-light inset they never had to
  clear, and the diff header's branch name becomes the thing that yields
  so the view toggle and the close tile survive a column's width.

New in `config.json`: `document_ratio`, and `document_layout` for what a
fresh tab starts as. Four new actions, bindable and unbound by default.

* fix(window): hold the docked column to widths the strip and the file agree on

Three defects in the document column, each with a guard test that fails
without its fix.

The tab strip did not know a column had taken width off it. On macOS the
strip lives inside the terminal column and sizes itself to the window less
the detail panel, so a docked document left it 340 points wider than the
column it sits in and the chips ran on under the column — the same overrun
the panel's own reservation was added for. Everywhere else the strip spans
the workspace and the column's hoisted header is drawn over its trailing
end with no fill of its own, so a chip left under it showed through the
file name and stayed clickable through it. The column's width now comes off
`strip_w` on macOS and off `corner_w` elsewhere, which is where the panel's
already goes.

The divider wrote widths the file would not keep. `Config::sanitize` holds
`document_ratio` to 0.2..=0.8; the drag clamped in pixels only, so a column
pushed against either edge of a wide window was saved outside that band and
reopened somewhere else — on a 2560-point body, 232 points from where it
was dropped. The band is a pair of shared constants now and the drag clamps
to it, the way the font size and its stepper were made to agree in #550.

The palette named the config's layout rather than the tab's. Fill is per
tab, so a tab told to fill was still offered "Document: Fill Window" — a
row that named the state it was already in and did the opposite. It reads
the active tab through `ChromeState` now.

Also: `document_layout`'s doc comment still described the global switch an
earlier draft had, three lines after the field became a per-tab default.
2026-08-19 18:03:51 +08:00
l0ng-aiandl0ng-ai 9fc0f331e8 feat(tabs): drag a tab in as a pane, and a pane out as a tab (#651)
* feat(tabs): drag a tab in as a pane, and a pane out as a tab

A tab dragged by its chip or its sidebar row can be dropped over the
panes to become one of them, and a pane dragged by its grip can be
dropped on the strip or the sidebar to become a tab of its own. Both
carry the panes across as they are: nothing is spawned and nothing is
killed, so a shell mid-command, an SSH session or an agent mid-turn
keeps running.

The landing is read the way a pane drag's already is, minus the middle:
an arriving tab has nothing here to trade places with, so a pane's core
means "split it the way it is longest". A tab that was itself split
arrives with its own shape intact and takes one share of the row or
column it joined. A pane on its way out is offered a caret between two
tabs, and the last pane in a tab is offered nothing, being a tab of its
own already.

Picking a tab up no longer switches to it: the strip and the sidebar
now activate on the click rather than on the press. Without that the
merge cannot be expressed at all — pressing the tab to drag it would
put it on screen, leaving no other tab to drop it into.

Two things in the machine tree had to follow:

* Panes that change tabs are told as PaneMove, one at a time, rather
  than as a tab closing and another being rebuilt around them.
* The tabs the machine already has are reconciled before new ones are
  created, so a pane leaving for a tab of its own is given up by the
  old tab before the new one asks to register it. The machine refuses a
  pane that is in two tabs at once, and the refusal desynced the window.

Closes #621

* test(tree-sync): a tab grafted above a whole layout still converges

* fix(tabs): keep a click on the close button from switching tabs

Switching on the release rather than the press means every click inside
a chip or a sidebar row now reaches the row itself, and gpui-component's
`Button` does not stop propagation on a click it handled. So one click on
a tab's close button ran `close_tab(i)` and then `activate(i)` — with `i`
by then naming whichever tab had slid into that slot, which moved the
active tab somewhere nobody asked for. A click into the rename field did
the same: it switched away from the tab whose name was being typed, and
took the focus out of the field with it.

Both now hold the click where they handled it, the way they already held
the press.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-15 17:05:34 +08:00
l0ng-aiandl0ng-ai 34957f8659 docs(shell): name custom arguments as a reason integration never engaged (#634)
A pane that never armed shell integration blamed a PTY wrapper or an unsupported shell setup. Since #629 a zsh or fish the user gave arguments to is deliberately left alone, so the notice now names that first — it is the one cause the user can undo. All three locales.

The release notes gained the matching entry: the change turns integration off for an existing config that sets `shell` or a `custom_shells` entry with `args`, which is worth stating outright.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-14 18:57:24 +08:00
l0ng-aiandl0ng-ai 72db26d15a feat(prompt): let the shell's own line editor own the prompt (#633)
Closes #624

tty7's inline editor takes the prompt the moment OSC 133 reports one, and
until now the only way to keep it off was to hide the shell's own name
from tty7 so integration never armed — which costs the prompt boundaries,
cwd and exit codes as well. Someone who binds `history-beginning-search-
backward-end` to Up in their zshrc had no way to reach it, and the local
history the editor walks instead is per-view: a command run in one pane is
not in another's list, so the shell's shared history looked broken too.

The new `prompt_editor` switch (Settings -> Input -> Prompt, on by
default) hands the line back. Off, every key at the prompt goes to the
PTY, so ZLE / readline / fish do the editing and what the user bound
behaves as written. Shell integration is untouched by it.

The gate is one line in `input_inactive_reason`, which every path that
could take the prompt from the shell already asks: keys, IME commits,
paste, Tab, the completion and reverse-search menus, the input bar. That
is what makes this a mode rather than a special case per key.

`shell_owns_prompt` learns the flag too, and that half matters more than
it looks: the gap hold and the typeahead record both exist to feed the
local editor, and `flush_typeahead` sends ^U to erase the line before
moving it there — on a line only ZLE is editing, that erases the user's
work. Ctrl-R landing on the PTY also stops raising the missing-integration
notice: the shell owning it is what was asked for.

Turning it off mid-line hands what is typed to the shell the way an
unknown chord does, so the text is still on the prompt to finish. Live
panes follow the switch, including a hand edit of config.json in another
window.

Tab completion and history search are menus tty7 opens inside that editor,
so the page greys them out and says why while it is off. Only their text
dims — a switch already draws its thumb at 35% when disabled, and dimming
the row on top of that leaves a pill with nothing visible in it. Their
stored values are left alone and come back with the editor.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-14 14:46:22 +08:00
b2d73ec68b feat(update): update an all-users Windows install through one UAC prompt (#562)
* fix(update): surface a failed install instead of silently re-prompting (#540)

The GUI quits as soon as tty7-updater is spawned, so an install that
failed inside the helper left a trace only in update.log — and because
launching the helper had already cleared the prompt state, the next
check offered the same version again, and again. The failure mode the
user saw was an app that nagged about an update it could not install.

The helper now writes update-outcome.json beside update.json on every
terminal path it can still reach, and the next GUI launch folds it into
the update state: a failure shows in Settings with the installer's own
reason until dismissed and stops the version from re-prompting on its
own; a success at the running version retires a failure an earlier
attempt recorded. A leftover result that exists but cannot be parsed is
reported rather than dropped — something ran, and "unreadable" is a
result too.

The same change moves the config directory off the environment and onto
the command line (--config-dir). An elevated child process does not
inherit the spawner's environment, so TTY7_CONFIG_DIR would have fallen
back to the administrator's config directory exactly in the
over-the-shoulder case — the groundwork this lays for #504. The updater
re-exports the variable for the helper children it spawns itself, so
the relaunched app keeps answering for the same config directory.

* feat(update): update an all-users Windows install through one UAC prompt (#504)

An Inno install under C:\Program Files could not be replaced in place:
the updater ran the release Setup as the signed-in user, which either
installed a second, per-user copy beside the real one or let Inno
re-launch itself elevated — a bare UAC prompt for an unsigned
executable in %TEMP%, seconds after the GUI had vanished. So the
layout was refused outright and told to download by hand.

It now updates itself, with the split the design in #504 settled on:
one UAC prompt covering two privileged stages, and one watcher that
is never elevated at all.

- The GUI probes the *installed* updater for the new verbs by running
  it ("capabilities"), so a side-loaded or downgraded binary answers
  for itself instead of being trusted by version number. An updater
  that predates the verbs exits with a usage error, and the install
  falls back to pointing at the release page exactly as before — the
  first release carrying this still updates the old way, and the one
  after it updates itself.
- The prompt dialog says the UAC prompt is coming before the app
  quits, and stops offering "Install on Next Launch": nobody is there
  to answer a prompt before the first window exists. The same guard
  keeps a staged plan from being armed for the next launch, and
  apply_pending_at_launch leaves an elevation-needing plan staged
  rather than raising a windowless prompt at boot.
- "Install now" spawns the watcher first (medium integrity, the
  signed-in user's token, so the relaunched app is never elevated),
  then ShellExecuteEx "runas" on the installed updater — the trust
  root a medium-integrity process cannot rewrite. Everything the
  elevated half needs crosses as command-line arguments, because an
  over-the-shoulder child inherits neither the environment nor the
  user's profile. The package's expected SHA-256 crosses the same
  way, from the checksums the GUI already holds in memory, so a
  payload and its checksums file cannot be rewritten together behind
  the IL boundary.
- The privileged first stage re-verifies the payload against that
  digest, pins its helper byte-for-byte to the installed updater,
  stages both in a fresh administrator-only %ProgramData% directory
  (an explicit SDDL DACL, swept of stale directories first), and only
  then runs the install stage — which runs Setup silently, writes the
  outcome file, and never touches the app binary itself. The watcher
  follows the chain through the status file and pid liveness
  (ERROR_ACCESS_DENIED from OpenProcess still means "alive" across
  accounts), then relaunches the app de-elevated and probes that it
  actually came up.
- Declining the UAC prompt is not an error: the watcher is reaped,
  nothing ran elevated, and the staged package simply waits in
  Settings.

Persisted plans from before this protocol serde-default a plan
version that is_usable rejects, so a stale plan is discarded instead
of failing against a helper that would not understand its arguments.
The installer script's explorer-menu registration gains skipifsilent:
a silent run *is* this update path, and launching the app there would
write the menu into the administrator's hive under over-the-shoulder
elevation.

One note on the test suite: ui::remote_connect's
a_routed_auth_prompt_carries_the_machine_that_raised_it fails under
parallel test execution on this machine both with and without this
change — a pre-existing flake, unrelated.

* fix(update): run the UAC request off the UI thread

Real-machine verification of the elevated chain caught this on the
first click: ShellExecuteExW pumps the calling thread's message loop
while the shell raises the consent prompt (its change notifications
re-enter the window), and from the UI thread that re-enters gpui with
its App already borrowed — the process aborts on a RefCell
double-borrow before anything ever elevates. The launch — watcher
spawn included, so the pairing stays atomic — now runs on the
background executor, and only the bookkeeping (quit / decline /
failure) comes back to the UI thread.

* fix(update): throttle a failed version instead of retiring it (#540)

Per the review on #540: a failed install must not keep the version
retired via last_prompted — record last_prompted plus a fresh
remind_after deadline (the same three days "Later" uses), so the
version asks again once the reminder expires. should_prompt already
treats "last_prompted matches, reminder expired" as prompt-again, so
no logic change is needed there, and the pinned
a_failure_lets_the_version_prompt_again test still holds.

Also write update-outcome.json *before* relaunching the previous app
on the macOS/Windows/portable non-elevated paths: the GUI that comes
up next is exactly the process that absorbs the outcome, and it used
to be relaunched before the failure existed on disk. The elevated
chain is unchanged — its watcher already waited for the file.

* fix(update): let only the elevated updater's own image name the trust root

Three holes on the privileged side of the #504 chain, all of the same
shape: a value that decides what runs elevated was taken from the
medium-integrity caller.

- `elevated-stage` pinned the staged helper against
  `<install-dir>\tty7-updater.exe`, where `<install-dir>` is a
  command-line argument. Both halves of that comparison were the
  caller's to choose: name a directory holding two copies of any
  binary and the pin passes, then stage 2 runs it elevated. The stage
  now derives the installation from its own image — UAC pointed the
  prompt at `{app}\tty7-updater.exe`, so `current_exe` is the one path
  nothing below the boundary could have written — and passes that on
  to stage 2. A caller that named a different directory only gets a
  line in the log.

- The staging directory's DACL let no standard user in, but its parent
  did: `%ProgramData%` grants Users the right to create directories,
  and the creator owns what it creates. A pre-created
  `%ProgramData%\tty7` gave its owner delete-child over the
  administrator-only staging inside it — enough to rename the verified
  staging aside and drop an identical name of their own into the gap
  between the digest check and the execute. The root is now created
  with the same protected descriptor, taking down whatever holds the
  name first; `CreateDirectoryW` applies a descriptor only when it is
  the one creating the directory, so succeeding is the proof. The
  per-run sweep goes with it — the root's removal takes the leftovers.

- The GUI aimed the prompt at the updater the *plan* named, and
  `update.json` sits in the user's config directory. It now aims at
  the installation this process runs from, so the binary the prompt
  names is the binary that starts.

Also quote the elevated command line the way `CommandLineToArgvW`
reads it back: a backslash escapes only in front of a quote, so a
config directory ending in one used to escape its own closing quote
and swallow every argument after it, `--result-file` — the file the
watcher waits on — included.

* test(update): pin the elevated stage's trust root to its own image

A regression test for the shape of the hole rather than the hole: if
`installed_root` ever goes back to reading an argument, the pin the
elevated stage runs before executing the staged helper stops meaning
anything, and nothing else in the suite would notice.

* fix(update): bring tty7 back when the elevated chain never reports

The watcher's two timeouts returned without relaunching. Every other
way out of the chain ends with the app back on screen, but a stage 1
that died before writing its status or its outcome — killed, crashed,
an AppInfo service that never delivered it — left the user with the
GUI already quit, nothing to replace it, and nothing said. Same for an
install still running an hour later.

Both paths now end the way the others do: an outcome the watcher wrote
itself, then the relaunch. The synthesized outcome is written whether
or not the relaunch succeeds, which also closes the same gap on the
pre-existing "the elevated updater exited without recording a result"
path — the next launch can name what happened instead of silently
offering the version again.

What kept those paths from relaunching was the risk of a second window
beside a GUI that is still up: a declined prompt leaves this process
running, and the kill that reaps its watcher can lose. The watcher now
takes the GUI's pid and opens a handle to it at startup — while the
GUI is provably alive, since it is sitting in ShellExecuteExW waiting
on the prompt — so the number cannot be recycled out from under it.
Before relaunching, a GUI that is still alive is waited out for 30
seconds: one that is quitting (a chain that failed fast can beat it
out the door) is gone well inside that and gets its relaunch, one that
is staying is recognized as staying and gets neither a relaunch nor a
failure record it did not earn. A live process always answers to its
own pid, so the check cannot be wrong in the direction that
double-launches.

Also give the Japanese elevation notice its closing 。

* fix(update): poll the parent out across the elevation account boundary

Under an over-the-shoulder elevation the install stage runs as the
administrator, and OpenProcess on the signed-in user's GUI answers
ERROR_ACCESS_DENIED - the same boundary pid_alive already documents from
the watcher's side. wait_for_exit treated that as a fatal error, so the
chain recovered and reported a failure before Setup ever ran. The wait
now degrades to polling the pid until it stops answering, bounded so a
recycled pid cannot hold the install hostage forever.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: l0ng-ai <l0ng-ai@users.noreply.github.com>
2026-08-13 18:13:20 +08:00
Hongwei Qinandl0ng-ai a2d53a9597 fix: 19 项低危 UX 问题(#584–#602) (#615)
* fix(scm): say what "discard all" actually discards (#594)

The group-level Discard prompt asked to "discard every change in this
repository", but discard_all_ops has only ever swept unstaged edits and
untracked files — staged changes survive, as the function's own comment
notes. Users confirmed under one belief and the code kept another.

Narrow the prompt to the operation's real footprint, in all three
languages.

* fix(scm): keep the amend toggle when its confirmation is cancelled (#595)

scm_commit cleared scm.amend when Commit was pressed, before the
"rewrite the last commit?" prompt. Answering Cancel returned to a panel
whose amend mode had silently been dropped, so the next Commit created a
brand-new commit — exactly what the user had just declined to risk.

The toggle now clears where scm.committing arms, at dispatch in
run_git_op, extending the rule the armed flag already followed: a
cancelled confirmation leaves nothing behind.

* fix(cli): answer a wait timeout in the success path's JSON shape (#589)

The 124 branch returned {pane,status,timed_out} while a finished wait
returns {pane,status,matched,stale,activity,message,session_id} — so the
one branch a consumer writes error handling for was the one missing its
fields. The timeout now carries the full shape plus timed_out, and
reference.mdx documents the schema and the flag.

* fix(cli): report a failed wait on stderr, even under -q (#590)

wait's failures are structured exits (124, or 1 when the pane died
first), so they never passed through the anyhow path whose eprintln is
the only thing quiet mode cannot silence — contradicting the documented
"errors still go to stderr". Both exits now print their headline to
stderr, the discipline pane close already established.

* docs(cli): describe owner as the workspace that may attach (#591)

commands.md still claimed the CLI stamps a literal "tty7-cli" owner on
the panes it spawns — the behaviour the orphan-workspaces work removed,
because an owner names the workspace allowed to attach and a stranger's
stamp got the panes respawned. Every spawn path now writes the workspace
id, or nothing while the pane is still unfiled. Bring commands.md in
line with reference.mdx, and note the absent case in both.

* docs(cli): close five contract drifts between the tables and the code (#592)

- The key tables listed pgup/pgdn as aliases but not pgdown, which the
  parser has always taken; both references name it now.
- "Case-insensitive" was flat wrong for Alt: M-x keeps its case because
  Alt is a prefixed ESC, unlike Ctrl. Both references note the exception.
- procs' ports JSON has carried addr since the field exists; both schemas
  show it.
- TTY7_WS is tab ls's default too; both environment tables say so.
- split --ratio's clamp to [0.05, 0.95] was discoverable only in code;
  both split sections document it.

* fix(cli): doctor exits 1 when the server is unreachable (#592)

doctor is the verb people run when something is not working, so an
unreachable server is *the* finding — not a row to exit 0 over while
`tty7 doctor || alert` never fires. The table and JSON still go out
(the context rows are the other half of what doctor is for), and stderr
carries the headline under -q. MockBackend grows an `unreachable` flag
so the branch is testable; no Status/Routes round-trips happen once
hello has failed.

* fix(settings): refuse a Start-in path that names no directory (#601)

The custom path was stored unchecked, and the daemon's picker then
skipped it — not a directory — so every new pane silently started in
the fallback directory and the typo read as a tty7 bug. Settings now
marks the field red and refuses to save, the proxy row's pattern
(#551), with the red line and the commit gated on one shared predicate
so they can never disagree; a hand-edited config.json holding such a
path gets a log::warn! naming it at the moment the fallback engages.

* fix(terminal): rescan search highlights when the pane's width changes (#586)

A match point is an absolute (line, column) against the width it was
scanned at, so a column change reflows the text out from under every
highlight. Output rescans them (Wakeup → refresh), but a quiet local
pane has no output coming and the drift outlasted the resize
indefinitely. set_grid_size now rescans on a column change with the
output path's discipline — selection and scroll untouched — and takes
the Context it needs to do so; a rows-only change reflows nothing and
stays cheap.

* fix(terminal): keep the grid selection when the search bar opens and closes (#584)

The selection that seeds the query is the thing being searched for, yet
opening the bar ran recompute_matches' unconditional clear — right for
its other callers, where the user *changed* the query and the old
selection names nothing — and closing cleared it again, so select →
Ctrl+F → Esc lost the selection every time. The seeded selection is now
restored after the opening scan, and close_search no longer clears; a
query the user actually changed still retires the stale selection, the
discipline refresh_matches_after_output already stated.

* fix(tabs): a zoomed pane stays zoomed across a tab switch (#599)

Zoom was a window-level value that activate() cleared unconditionally,
so looking at another tab and coming back restored the split layout —
while a zoom is a tab's temporary view state, like its focused pane.
It now rides with the Tab: activate stashes the outgoing tab's zoom and
brings the incoming tab's back. The clears that genuinely reshape the
layout (drag, split, close) still stand, and a stashed zoom whose pane
exited while the tab was away is validated away rather than restored.

* fix(tabs): track an open rename box by tree id, not index (#598)

The rename box held only an index, which drifts the moment any other
tab closes or the strip reorders — so close_tab_inner and
apply_tab_order threw the half-typed name away on any unrelated tab
event, and a reorder mid-rename still left a window where the commit
landed on whichever tab had taken the index over. The box now names its
tab by tree id end to end (start, render match, commit): only closing
the renaming tab itself ends the rename, and the name lands on the tab
the box was opened on wherever it has since moved.

* fix(i18n): move seven hard-coded user-facing strings into the language tables (#602)

Seven spots rendered English no matter which UI language was set: the
shell-integration notice that explains why a wrapper was blocked or never
engaged, the titles a pane wears once its process exits or the server
loses it, the loopback forward's failure line, the tray tooltip that
lists running agents (whose separator also wanted a CJK enumeration
comma), the cursor-shape choices in settings, the command palette's
empty-result hint, and the updater's install hint. Each is a L10nKey now
with en/zh/ja entries, so the parity guard keeps them translated from
here on.

The palette's empty state was also wrong in content, not just language:
every menu suggested connecting over SSH when nothing matched, including
menus that have no hosts in them. The hint now only appears in the
quick-connect menu; everywhere else the palette suggests a different
search instead.

Verified on Linux: the title/palette/tray suites (48 tests) and the i18n
parity guard all pass.

* fix(terminal): show remote path completion is listing, and say when it fails (#585)

Tab-completing a path on a remote workspace had two silences. The whole
network round-trip painted nothing, so a slow link read as a broken Tab
key; and a listing that failed was unwrapped into an empty candidate
list, so "the directory is empty" and "the listing never happened" ended
in the same nothing.

A pill over the pane's bottom-right corner — the style the integration
notice already uses, factored out — now says the listing is running from
the moment it starts, and a failed listing sets a notice with its error
instead of the empty vector. The failure pill stays until the next
keystroke dismisses it, and the trailing notify after an empty listing
closes the menu brings the "listing…" pill down with it.

Verified on Linux: the new gpui test covers the idle/listing/failed
states, and the neighbouring completion tests still pass.

* fix(files): quote cd Here / Insert Path for the shell the pane runs (#593)

Both file-tree actions wrapped a path with spaces in POSIX single quotes
whatever the focused pane's shell was. In cmd.exe a single quote is an
ordinary character, so `cd 'C:\Users\me\My Documents'` split at the
first space and cmd complained about 'C:\Users\me\My' — while the same
action was fine in PowerShell and bash, which is why only cmd users ever
saw it.

shell_quote_for takes the pane's shell program (the pane already knows
it — the settings page lists it) and picks double quotes for cmd.exe,
single quotes for everything else; an unknown shell keeps the POSIX
form, and a path that needs no quoting stays bare either way. Windows
paths cannot contain a double quote, so the cmd form has nothing to
escape.

* fix(cli): pane close fails for a pane the registry does not hold (#588)

`tty7 pane close %99` printed {"closed":[99]} and exited 0 for a pane
that never existed. The workspace path cannot drift this way — PaneClose
answers — but an orphan has no workspace to route through, so close
hangs it up directly, and that kill is fire-and-forget: the daemon never
says whether it knew the pane, so Ok(()) only ever meant the bytes
reached the socket. A reaper script chasing the orphans `pane ls --all`
points at would read the ghost success as cleanup done.

The direct path now reads the running-pane registry once per batch and
refuses ids it does not hold: the miss lands in `failed` with exit 1,
next to the failures kill itself can report. A pane that exits between
the listing and the kill is gone either way, which is what closing it
wanted, so that race still reports closed.

* fix(session): a launch that leaves workspaces running says so (#597)

Quitting with several windows open and starting again restored only the
most recent one; every other open window was marked detached — panes
alive, nothing on screen, the only trace a "left N detached" log line.
The workspaces were reachable from the sidebar, but nothing said they
existed, so they were easy to forget entirely.

restore_one now returns how many windows it detached, and both launch
paths (normal startup and the CLI-driven open) push an in-app
notification into the restored window naming the count and where to
reopen them. The count rides the return value rather than firing the
notification inside the store, because the store has no window to notify
in — and a launch that detaches nothing, like the reattach-the-last-
closed case, stays silent.

* fix(switcher): list the local machine's orphan panes, with a way to close them (#596)

A pane whose workspace went away — an interrupted `tty7 run`, a forgotten
workspace that kept its shells — was invisible everywhere in the GUI: not
in the sidebar, not in the switcher, not in the tray. It kept its process
and its memory, and the only way to even learn it existed was the CLI's
`tty7 pane ls --all`, which a GUI-only user never runs.

The switcher's local machine group now carries a "Background panes" block
under its workspace rows: one line per live pane the daemon's registry
holds and no workspace does — id, owner, cwd — each with a Close button.
The listing is the same PaneClient::list the CLI's reaper reads, fetched
off the UI thread when the panel opens; closing kills and then re-lists,
so a pane that survived simply stays on the list instead of pretending
to be gone. The block steps out of the way while the search field holds
a query, which narrows the panel to workspaces.

Local on purpose: a remote machine's orphans belong to its own daemon,
and routing a listing per host is what the CLI reaper is already for.
The block joins no keyboard navigation — the panes are not workspaces
and the arrows have no business landing on them.

* fix(updater): keep Inno's progress window on screen during the install (#600)

The Windows installer ran /VERYSILENT, so from the app quitting for the
update to the watcher bringing the new build up — tens of seconds, longer
under an antivirus scan — the screen held nothing at all: no window, no
progress, no tray note. "Clicked update, the app vanished" reads as a
crash, and double-clicking the icon does nothing while the files are
being replaced.

The installer now runs /SILENT instead. Nothing about the flow becomes
interactive — /SP-, /SUPPRESSMSGBOXES, /NORESTART and /CLOSEAPPLICATIONS
are untouched — but Inno's own progress window stays on screen for the
gap, which is exactly the span the user had no word about.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 18:11:17 +08:00
Hongwei Qinandl0ng-ai 8b42905fca fix(terminal): stop menu click-through and surface failed file opens (#541, #542) (#575)
* fix(terminal): stop terminal pop-up menus leaking clicks into the grid (#541)

The completion menu and the reverse-search menu (the floating panel and
the input-bar row alike) carry no click handlers of their own, and in
gpui an element without handlers, cursor or occlude inserts no hitbox —
the same rule the app pins with a test pair in app.rs. A press that
missed every row therefore fell straight through to the live grid: it
moved the cursor and cleared or started a selection there, and a
modified click even opened whatever link happened to sit under the
menu, so the menu read as broken while the damage landed elsewhere.

All three menu roots now occlude, the same remedy the terminal search
bar already uses, so a click on menu background is swallowed where it
lands. Making the individual candidates clickable instead is a separate
feature, not part of this fix.

* fix(terminal): toast a file link that fails to open instead of dying silently (#542)

The external half of open_file_link has no failure channel: a misspelled
link_file_command or a missing xdg-open only produces a log::warn, and
the click reads as a dead link — while the path was only ever underlined
because the pane's own host verified it exists, so "nothing happens" is
the worst possible answer. The URL half at least toasts a failed
loopback forward; the built-in editor arm reports downstream of
OpenFileRequested; the two spawn arms had nothing.

open_file_path and run_file_command now return io::Result, and
open_file_link turns an Err into the same kind of notification a failed
image paste raises, naming the path and the error. The file tree's
directory fallback — the one other caller, handing a path to the OS
association — gets the same toast instead of silence. A template whose
tokens all expand to nothing (a lone {line} on a link with no line
number — a blank template never gets this far, sanitize maps it to
None) reports as an InvalidInput config error rather than a silent
no-op. Spawn is still all that is reported: an opener that spawns fine
and then exits non-zero is nobody's to see, and a test pins both error
paths.

* test(terminal): pin the press a pop-up menu has to swallow (#541)

The menus occlude now, but nothing held them to it: a bare div over the
grid renders the same and only the mouse can tell the difference. This
presses on a history row and asks the grid whether it started selecting,
then takes the menu away and presses again — the second half is what
keeps the first from passing on a pane the mouse never reached.

* docs(changelog): say what a leaked press actually did (#541)

A press on a menu never moved the terminal cursor and the menus have no
buttons to miss; what it did was clear the selection, drag out a new one,
underline the text under the row on hover, and open the link beneath it
on Ctrl+click.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 15:54:28 +08:00
l0ng-aiandl0ng-ai 3cd90c6ca6 feat(ssh): name panes after their host, reach the host form from where you are, and test a connection (#566)
* feat(ssh): name panes after their host and reach the host form from where you are

Five gaps in the SSH flow reported in #438, and the two silent no-ops
around them.

Panes now carry a display name: a saved host's own name, or its address
when nobody named it — every host imported from ~/.ssh/config arrives
nameless, and each one opened a tab reading "tty7". The name survives a
title reset and a dropped link, and `strip_host_prefix` no longer cuts
`deploy@10.0.0.5:2222` down to "2222" on its way to the tab strip.

The host form is now reachable from the machine in front of you: the
switcher's machine menu edits the host it is showing, or offers to save
one for a machine reached by address or by ~/.ssh/config alias. A live
connection dialled by hand can be kept as a host from the palette, with
everything it was dialled with carried over — the one thing that cannot
come along is an ad-hoc jump hop, and that is said out loud rather than
saved broken.

The New Tab menu lists the saved hosts, most-used first, and the typed
address route that already understood -p, -J and config aliases. Both
were behind the workspace switcher, its host dialog and the settings
list.

Finally, the three proxy fields are exclusive — map_proxy picks the
first one filled and ignores the rest — so the ones that lose now say
which field won instead of leaving it to be discovered by connecting.

* feat(ssh): test a connection from the host form, and pick a host by name

Three things the host form and the New Tab menu were missing.

The form gets a Test button. It hands the spec to the daemon, which
dials it the way Connect would — proxy, jump host, host key, auth — and
drops the connection again, reporting how long it took. A test never
rides an existing connection: one would answer for the credentials that
connection was made with, so a password typed wrong would come back
green. Anything the handshake stops to ask a person is declined on the
spot and reported as what it asked for, since a form is nowhere to
answer a password prompt and waiting out the two-minute prompt timeout
would be a worse answer than "it got that far and wants your password".
The result clears the moment a field changes: vouching for a host that
was edited since is worse than saying nothing.

The Auth row becomes a dropdown. Six methods is more than a segmented
control can label without squeezing, and it is the row that stacks
first on a narrow page.

The New Tab menu scrolls — PopupMenu only does that past 20 items, and
every shell on the machine plus a handful of hosts already runs off a
short window — and past the five hosts it lists, Find a Host opens a
filter box over all of them.

* feat(new-tab): put a search box on the New Tab menu

The menu is as long as the machine is — nine shells here, and a
~/.ssh/config with two dozen hosts in it is ordinary — so it needed
filtering, not a scrollbar and a row leading somewhere else.

A PopupMenu cannot hold a text field: it claims the keyboard for its own
navigation, and there is no search input anywhere in it. So the New Tab
button now opens a popover holding the same searchable list the command
palette is built from, with the shells and the hosts under their own
headings and one box over both. Typing filters across both groups;
typing an address offers to connect to it, the way the palette does.

The standalone host picker this replaces is gone with it, and the row
that led there — one search reachable from the button beats two behind
a menu.

* revert(new-tab): put the New Tab menu back to shells only

The searchable popover was the wrong shape for a button in the chrome:
too big and too heavy next to the tab strip it hangs off. The menu is
the plain shell list it was before this branch — byte for byte, so
nothing about it needs re-reviewing — and the hosts, the search box and
the row leading to a host picker are gone with it.

Everything that came along to serve it goes too: the positional
NewTabWithShell command, the picker's palette delegate and its compact
row metrics, the standalone host palette, and the four strings they
needed. Connecting to a saved host is the palette's job again, which is
where it was and where it works.

* fix(switcher): size and weight the machine glyphs like the icons beside them

The two machine icons are drawn by hand; every other glyph in that
gutter comes from lucide. Ours were built to a tighter box — ink 19.3 ×
17.3 of a 24 grid against lucide's 22 × 20 — so at the same nominal
16pt the local machine rendered 11.5pt of ink beside a 14.7pt globe and
read as a size smaller. Both are redrawn to lucide's extents, which
also makes them agree with each other.

The local machine's glyph was muted while every remote one was full
strength, and while its own name was full strength either way. Beside
the machine under it that read as a disabled row rather than as the
computer you are sitting at. One weight for all of them now; the
"Other Machines" globe keeps its dimmer register, which belongs to the
muted section label it sits next to.

* fix(tabs): only a port stops the host head being cut off a title

Teaching `strip_host_prefix` that `deploy@10.0.0.5:2222` is an address and
not a titled directory was done by requiring the tail to start with `/` or
`~`. Two very common titles do neither.

Debian's stock bash title is `\u@\h: \w` — a space after the colon — so
`user@host: ~/work` would have stopped being cut at all, and every one of
those tabs would have gone from reading `~/work` to `user@host: ~/wo…`. And
tty7's own PowerShell integration writes `ann@BOX:C:/src` whenever the cwd is
off the home drive, which would have read `ann@BOX:C:/src` rather than
`C:/src`.

Key on the port instead, which is the thing that actually makes the string an
address: a tail of nothing but digits is kept whole, and everything else is
the path it always was. The space belongs to the head, so the tail is trimmed
on the way out.

* fix(ssh): keep a connection test off the cache, off a stale form, and clear about a changed host key

Three ways the new Test could answer for something other than the host in
front of it.

It dialled with `reuse: false` but still took the connection cache's slot
lock, which is held for the whole handshake. So a test stalled every Connect
to the same host behind a connection it was never going to leave them — and,
queued behind a session already dialling, spent its own budget waiting and
came back "connection timed out" about a host that answers fine. A test that
is not going to touch the cache has no business locking it: it now skips the
slot entirely, and only a reusing dial takes the guard it later fills in.

The form dropped a test result whenever a typed field changed, on the
grounds that the answer was about the host as it was a moment ago — but the
authentication method is a dropdown, and changing it left the green line
standing under a handshake the form would no longer make.

And a host key that has *changed* was reported with the same words as one
nobody has accepted yet. Those are not the same news: the first is a new
host, the second is the server presenting a different key than the one on
file. `SshTestNeed` now tells them apart and each gets its own line, in all
three locales.

Verified against a live sshd on localhost: refused port and unresolvable
name come back in milliseconds with the connect path's own message, a
password host comes back `NeedsInput { Password }` in 55 ms rather than
waiting out the two-minute prompt timeout, and two tests of the same host
back to back no longer serialize.

* chore(palette): drop the root flag the New Tab revert left behind

`grouped_root` was split out of `quick_connect_root` for the searchable host
picker on the New Tab menu, which was taken back out again. Every
constructor now sets the two to the same value, so the second one is a field
and a doc comment describing a list that does not exist.

* docs(changelog): record the SSH host form, pane names and connection test

Every user-facing change in this branch: panes named after their host, the
host form reached from the switcher and the palette, and Test.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 15:02:58 +08:00
Hongwei Qinandl0ng-ai f237cf5c48 fix(scm): answer a branchless push and name why a commit is refused (#545, #546) (#576)
* fix(scm): answer a push that has no branch to move (#545)

A click on Push at a detached HEAD died in scm_push's let-else without a
word, and it was not the only path that did: the key binding, the
palette entry and the follow-up half of "Commit and Push" — which lands
in scm_push after the commit has already succeeded — all share that
guard, so a compound verb read as pushed when only the commit happened.
git_data.rs says out loud why that cannot stand: a swallowed click on
Push looks exactly like a push that finished instantly, which is why the
busy slot toasts ScmNetworkBusy instead of dropping the click.

The sync tile made it worse by promising "Publish Branch" — upstream is
None at a detached HEAD by definition, and publishing is the one thing
it cannot do. The tile and the branch menu's Push item now disable
themselves with a tooltip that says why (Fetch works from any HEAD and
Pull already fails loud out of git's own error, so they stay), and the
guard itself toasts the reason for every path that can still reach it.

The guard also swallowed HeadState::Unborn — a branch with a name but
no commits yet — which now gets its own answer rather than the detached
one. Both dead ends are decided in one pure helper, pushable_branch, so
the tile, the menu and the toast cannot drift apart, with a test pinning
what each head state says.

* fix(scm): name the real reason a commit is refused (#546)

Committing with staged work but a blank message was answered with
"Nothing to commit" whatever the actual blocker, because scm_commit
hard-coded that one key for every disabled plan. The panel's own button
gets away with a shared tooltip because it is disabled and the reason
shows on hover; the palette entry and the key binding have nothing to
hover, so their toast was the whole feedback — and it pointed at the
index when the message box was the problem, sending the user staging
files they already staged.

The toast now carries the commit plan's own reason, the same key the
button's tooltip uses, and the commit_plan test pins the split:
whitespace-only message on staged work is ScmCommitNeedsMessage, a
clean tree is ScmNothingToCommit.

* fix(scm): ask pushable_branch for the tile and the menu too

The helper was introduced so the tile, the branch menu and the toast
could not answer differently about the same HEAD, but the first two were
still deciding off their own `detached` check — so an unborn branch,
which the helper already answers with "no commits to push yet", kept a
live sync tile promising "Publish Branch" and a live Push item, and
learned the truth only from the toast after the click. Both now ask the
same helper, and the tooltip carries whichever reason it gives back.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 15:01:03 +08:00
l0ng-aiandl0ng-ai 70ac7f201f fix(tree-sync): tell a window the name the machine gave the workspace it made (#604) (#613)
A workspace a window creates is created with a generated name, and that
name is what `tty7 ws ls` prints and what `tty7 ws rename` addresses. The
window itself was never told it. A client is left out of the deltas its own
ops raise, so the WorkspaceCreated delta carrying the name never comes back,
and both create paths threw away the copy the reply carried: `pull_or_create`
reduced the answered workspace to its tabs, and `pull_workspace` returned the
tree it had read before the create, which does not hold the workspace at all.

The mirror therefore held the workspace unnamed, the chip fell back to the
directory its shells started in, and the GUI and the CLI gave two different
answers to what the workspace was called — a user could read `verify-main`
on screen and not address it by that name. The first pull of the whole tree,
which a daemon restart, a rebuild and a plain relaunch all do, then produced
the name it had had all along and looked like a rename that stuck.

Both paths now carry the name they were answered with: the prime hands it to
the mirror, and the hydrating create puts the workspace it made into the tree
about to be installed. A workspace the machine really has no name for still
reads the directory it is working in, and a chosen name still wins.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 12:55:18 +08:00
l0ng-aiandl0ng-ai c159a86039 fix(ui): read a path's ~ off the machine the path is on (#580) (#607)
`abbreviate_home` measured every path against this process's own `$HOME`,
whoever the path belonged to. A remote pane sitting in `/home/deploy/app`
therefore read as `~/app` on a laptop that happens to log in as `deploy`,
and stayed spelled out on one that does not — the `~` naming the wrong
machine either way. #568 took the same borrow out of the file-link
resolver; this is the display half of it.

The home is now the caller's to name, because only the caller knows which
machine the path is on, and nothing here has to be asked for: a host
reports its home in the control handshake (`ControlHelloOk::home`) and
`HostLinks` already keeps it per host, so `path_display::home_for_host`
is a map lookup and never a round trip. `TerminalView::display_home` puts
a pane's own answer behind one call, and `Tab::leaf_title_and_home` reads
a title and its home off one leaf so the two cannot disagree.

Everything that draws a shortened path is on it: the Info panel's cwd, the
tab strip's label and tooltip, the sidebar's title and cwd lines, and the
switcher's workspace and tab rows. A path on a machine with no link — or
one a pane's shell has ssh'd away to, which no host here can answer for —
is shown in full rather than measured against a home that is not its own,
the same answer #568 settled on. A WSL pane gets its distro's home instead
of `C:\Users\…` for free, since it is a host like any other.

Tests: the borrow itself (a path with no home is left alone, and this
machine's home is not offered as a stand-in) at all three seams —
`abbreviate_home`, `short_title`, `display_path`. `ui::home`'s test no
longer has to set `HOME` on a process everything else is reading.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 12:09:06 +08:00
l0ng-aiandl0ng-ai 84d6fc223a fix(tree-sync): stop an abandoned Replace debt deleting the tabs it was pulling (#579) (#608)
`take_rehydrate` dropped an owed `Adopt::Replace` whenever the window had
any tabs, on the reading that the user had filled it in themselves. That
reading only ever fitted the one caller it was written for: Restart
Server, which empties the window before it resyncs. Every other
`Replace` — a daemon back as a new process, a restart handoff that was
refused, a remote server restarted, a layout delta that would not apply —
is ordered over a window that still holds its tabs, because those stale
tabs are the whole reason it was asked. The retry was abandoned on its
first attempt, every time, and the resync silently did nothing.

Abandoning it also left the window `informed`: still licensed to prune a
workspace whose layout it had never read. `start_prime` then refilled the
mirror from the machine, and the next `SyncScope::Full` diffed whatever
the window happened to hold against it — one tab opened over an emptied
window became `TabClose` for every tab on the machine, deleting those
panes' records while their shells kept running, with nothing tree-driven
left that could reach them. The damage #554 describes, through a door
#554 did not close, and the same on a remote window.

So the debt is now scoped to the layout it was owed over: `hydrate`
records the tabs on screen as it orders a pull, and only a tab that debt
never saw counts as the user moving on. And a `Replace` takes back the
`informed` licence up front, the way `on_preempted` does, so a window
waiting on a rebuild adds to its machine without pruning it until the
pull lands — however the debt ends.

Pre-existing since #472 (af3928d, e4bd49c); both PR #564 and #569 saw it
and left it alone because the rule is shared with the remote resyncs.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 12:02:20 +08:00
l0ng-aiandl0ng-ai 49901d7f8a fix(cli): let --enter press the key it is shorthand for (#581) (#606)
`--enter` is documented as sugar for `--key enter`, but the send dispatch
counted only `args.keys`, so `tty7 send %42 --enter` answered "needs TEXT
... or a --key to press" and pressed nothing. The key list is now built
before the dispatch and the dispatch counts it, so a marked address with
`--enter` and nothing else runs what the pane already has typed, and a
bare `send --enter` presses Enter where the caller sits.

An unmarked id is deliberately left out of that promotion. #567 made the
address slot take bare ids, and `send 83 --key C-c` addressing pane 83 is
fine because `--key` says "press this" and nothing else. `--enter` does
not: `send 2 --enter` reads at least as much like typing 2 into your own
pane and running it, and turning it into a keystroke at pane 2 would be
the silent retarget #567 spent its diff closing. It stays a loud error,
now naming both spellings (`send %83 --enter`, `send %PANE 83 --enter`)
rather than only the typing one.

The reference, the bundled skill reference, `send --help` and the
`--enter` help all said the old thing in slightly different words; they
now say the same thing as each other and as the code.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 11:43:17 +08:00
Hongwei Qinandl0ng-ai 664b766698 fix(settings): split Shell Arguments like a shell, quote them on the way back (#551) (#573)
* fix(settings): split Shell Arguments like a shell, quote them on the way back (#551)

The field split on raw whitespace, so `-c "echo hi"` became four argv
fragments with the quotes still attached, and it silently rewrote config
too: `build_shell_inputs` refilled the field with `args.join(" ")`, which
cannot spell an argument containing a space, so a legal
`"args": ["-c", "echo hi"]` in config.json re-committed as three argv on
the next blur without the user typing anything.

Parse with shell-words rules instead and quote each argument on the
refill, so field text and the argv array round-trip losslessly. An
unbalanced quote cannot become argv at all, so commit refuses it and the
row explains why under the input — the proxy field's pattern. The field
description in en/zh/ja now says quoting works.

Program gets the milder half of the same treatment: a bare command that
detection (a PATH probe) never saw is almost always a typo like `pwsh7`
that today only surfaces when a pane fails to open, so the row warns
under the field. It never refuses — the field stays free-text so a shell
detection missed remains reachable — and anything spelled as a path is
taken at its word. The comparison reuses core's `same_shell_program`, so
"known" here means exactly what the new-tab menu dedup means.

shell-words was already in the tree via portable-pty, so the direct pin
adds no new code.

* fix(settings): split Shell Arguments as argv, not as POSIX source

Review pass over the #551 fix. Splitting with `shell-words` bought the
quoting contract at the price of two silent rewrites of its own, both the
same shape as the bug being fixed: a backslash outside quotes is a POSIX
escape, so `--dir C:\Users\me` committed as `C:Usersme` on the platform
where that is how a path is spelled, and `#` opens a comment, so
`--tag #1 --verbose` committed as one argument. The refill was noisier
than claimed too — `shell_words::join` quotes on `=`, `~`, `*`, `?` and
`[`, so an existing `--color=auto` came back as `'--color=auto'`.

Nothing here is a shell: the field is a text spelling of an argv array
that goes to `CommandBuilder` directly. So split and join are now a local
pair sized to exactly that job — quotes group, `\"` and `\\` inside double
quotes escape, everything else is a character — and the direct
`shell-words` pin goes away again. They are exact inverses, which is what
`config.json` needs, and the test walks the round trip over the cases a
space-join cannot spell plus the two above.

Drops the Program nudge. `shells::inventory()` inserts the *configured*
shell into the inventory it returns, so `pwsh7` is in `self.shells` from
the next refresh onward: the warning could only ever flash between the
commit and the refresh landing, and never appeared at all on a later visit
to Settings. Its test passed because it hand-built an inventory that
version of the value could not be in. Making it true needs core to say
which rows were detected rather than configured, and that is a serialized
protocol struct — too much for a nudge the issue itself called the milder
half.

Refusing the arguments no longer drops the Program typed or picked beside
them: the stored argv stays as it was, which is what "this value was not
saved" already told the user, and the shell picker works again while the
arguments field is mid-edit.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 10:32:18 +08:00