Commit Graph
95 Commits
Author SHA1 Message Date
l0ng-ai bf5149bea0 fix(editor): stop losing edits, share buffers, add file strip, quick open and go to line (#984)
* fix(host): save local files atomically via a temp file and rename

LocalHost::write_file truncated the target in place, so a crash, a full
disk or a killed process mid-save destroyed the user's file. It now
writes a hidden sibling temp file, syncs it, keeps the old file's mode
and renames it over the target, removing the temp file on any error.

It still writes in place where a rename would change something visible:
a non-regular target (symlink, directory, FIFO), a read-only file, and on
Unix a hard-linked file or one owned by another user, or when the temp
file cannot be created (e.g. a read-only directory).

* feat(editor): add editor_text for encodings, line endings, indentation and EditorConfig

A pure module the code editor will use when loading and saving files:
decode detects BOMs, binary files, UTF-8, GB18030 and a lossless
Windows-1252 fallback and normalises CRLF; encode restores the exact
bytes and names the first unrepresentable character; detect_indent
infers tabs or a 2/4/8 space width with language defaults; and
editorconfig_for resolves .editorconfig sections with save-time rules.

* feat(editor): share buffers across tabs, guard unsaved work, add a file strip

- One buffer per file per window; tabs list which buffers they show. The
  same file open in two tabs is no longer two diverging copies.
- Closing a tab, its last pane, the window, or quitting asks about unsaved
  files (Save / Cancel / Discard) instead of dropping them. Bulk closes skip
  tabs with unsaved files; a tab that vanishes any other way hands its
  unsaved buffers to the tab in front.
- File tree rename/delete now retarget or flag the open buffer, so a save
  no longer recreates the old path.
- Saves check the file's mtime first and ask before overwriting a change
  made elsewhere; this is the only detection SFTP buffers get.
- Dirty is a comparison with the saved text, so undoing back clears it.
- Reloads replace only the changed span as an ordinary edit, keeping undo.
- Load/save go through editor_text: encoding, BOM and CRLF round-trip,
  indentation is detected, .editorconfig is honoured.
- Header shows a strip of open files; New File, Save As (native panel
  locally, a path bar remotely), Go to Line (Ctrl+G), and the status bar
  shows indentation, encoding and a clickable line ending.
- Open files are remembered per tab across restarts.

* feat(search): quick open a file by name from a Files tab

Search Everywhere gains a Files tab that finds any file in the active
tab's project by fuzzy name and opens it in the built-in editor, with
`name:line[:col]` jumping to that spot. The list comes from one walk of
the project through the host (Host::search with an empty query), so it
works the same on local, SSH and WSL workspaces and skips what the tree
hides: dotfiles, .git and gitignored paths. The walk is capped at 50k
entries / 20k directories, kept between openings and revalidated in the
background each time the search opens.

Files join the All tab once a query finds them. Go to File... is bound to
Cmd+O on macOS (Cmd+P is already Search Everywhere) and ships unbound
elsewhere, where every obvious chord is taken or owed to the shell.

* chore(editor): allowlist the editor session file, tidy lints

* fix(editor): keep restored file order, drop stale close waits, carry files through tab merges

- Background arrivals (restore, merge, rescue) append to the strip in order
  instead of inserting beside the active file, which reversed them.
- A cancelled Save As, a dismissed path bar, or a dropped buffer cancels any
  close that was waiting on that save.
- Merging a tab into another carries its open files along.
- A shell exiting closes its tab without a prompt it could not honour;
  unsaved buffers move to the tab in front.
- Tabs rebuilt under the same id (server restart) restore their files.

* fix(host): only fall back to an in-place write when the rename is refused

On Windows every failure of the atomic save fell back to fs::write,
including a failure while staging the temp file. A full disk would then
truncate the original in place, the very loss the temp file prevents.
Staging errors now return as-is; only a refused rename (a file held open
elsewhere) takes the in-place path.
2026-09-28 00:32:18 +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 572bfc014b feat(ui): v4 redesign, including a rebuilt settings window (#973)
* feat(ui): restyle the right panel after the v4 design

- Tab row: 12.5/16rem word tabs 22px in and 18px apart, the current one in
  body ink at medium weight; no hover pill, no underline bar, no hairline
  under the row.
- Info: Session, Processes and Ports are spaced 16px apart with no rules;
  28px medium muted headings, 28px Session rows on a 76px label floor with
  values in body ink, 26px process rows with a tree elbow for children, and
  an explicit empty line for Ports.
- Files: the search sits in a 28px filled well; tree rows are 26px with a
  disclosure chevron column, ignored entries dim their icon instead of
  going italic, and a folder's change dot is 5px.
- docs/design-system.md updated to match.

* feat(switcher): restyle the workspace switcher after the v4 design

- Card: 112px from the top, 12px corners, 48px search row with an esc
  keycap, 420px body split 340px / preview, 40px footer.
- Workspace rows are 52px: a 26px initial disc carrying the link state as
  a ringed dot (live green, faint when offline, amber while connecting,
  red on failure), a medium name with its stable number, a machine ·
  path · time line, and the tab count over the state word.
- Preview rows are 44px with the sidebar's 18px brand disc, an all-muted
  branch · diff line, a Current label and a 5px dot that blinks with the
  sidebar while an agent is working.
- Footer: ghost New workspace button and keycap hints for navigate, open
  and new window; the unused click-for-new-window string is dropped.

* feat(scm): restyle the Changes tab after the v4 design

- Pinned block keeps 8/10/14 rhythm; branch name medium, 26px sync tile.
- Commit message box rests at 56px with a 7px radius.
- Split commit control: inverted neutral fill when committable, faint
  fill otherwise; 6px radius and an inset 0.5px seam.
- Change groups sit 16px apart under 22px sentence-case medium headers;
  file names take width first and directories right-align, eliding
  from the start.
- History: 32px header, 26px rows inset with rounded hover, 1px lines
  and 7px beads (HEAD filled, others hollow), neutral inks on a
  single-lane page, age column always shown, faint HEAD pill.

* feat(ui): restyle the rail and palette after the v4 design

- Default Light/Dark take warm neutrals (#fcfcfb/#1c1c1e, #18181a/#ececed);
  Git added/modified seeds follow v4 green and amber.
- The left rail gets its own tinted fill again (Neutrals.rail, 3% toward
  the ink) with its own surface ladder; the right panel keeps the content
  fill. Captions and hairlines are floored on the rail too.
- Title bar is 48px; the bar over the terminal centres the active tab's
  title in caption ink when tabs live in the rail.
- Rail header: 26px new-tab and collapse tiles, then the workspace chip
  and search field (28px, 7px radius).
- Groups sit 16px apart under a 22px caption heading with
  'branch · +a −d' in tabular numerals.
- Rows are 30px (42px with a branch line), 16px avatars, medium weight
  when current, branch cut from the front, and a trailing 5px status dot
  (blinks while working, hollow while waiting, unread count as a pill).
- docs/design-system.md updated.

* docs(design-system): note the commit button's inverted neutral fill

* fix(panel): align the right panel's insets with the v4 design

- Rows pad 8px inside lists inset 12px, so text sits on a 20px column in
  every tab and hover fills start 12px in with a 6px radius. Headings,
  empty states and the Ports line move to the same column.
- Tab labels 18px apart; the panel row's chrome tiles are 26px, 4px
  apart, 12px from the edge. Default panel width 280.
- Info: label column floor keeps values at x=88; Ports add tile 22px.
- Changes: 8px top gap on macOS, pinned block on 14px edges with the
  branch at 22, 12px sync glyph, 8px group chevron, 10px status cell,
  1px between rows.
- History: compact gutter for single-lane pages, filtered rows on the
  text column, 10px row gap, 24px age floor, header on 20px insets,
  4/12 padding when expanded (heights re-counted in commits).
- Files: search well at 12px with an 11px glyph, 10px before the tree,
  16px indent step, 16px bottom padding.

* feat(diff): restyle the diff overlay and commit detail after the v4 design

Carry the v4 language into the diff overlay and the commit detail view:
0.5px hairlines at 8% ink, 26px row pills with a 6px radius, the rem type
ladder from right_panel.rs, neutral chips instead of accent washes, the
shared git_badge for status letters, and tabular figures on counts.
Layout, spacing, type and colour only; no behaviour or i18n changes.

* feat(ui): restyle the dialogs, notices and home page after the v4 design

- New ui::dialog module holds the shared modal chrome, taken from the
  workspace switcher: a 12px card, a 48px title row with an esc keycap,
  18px insets, a 40px hairline footer, 28px borderless field wells on the
  faint fill, 11.5px medium muted labels, and 18px keycaps.
- Buttons: the primary is the inverted neutral fill, the Commit button's
  paint, instead of the accent. Secondary buttons are transparent with the
  surface's hover rung. Override on a changed host key stays the one red
  button. A disabled button sinks to the faint fill and drops its click
  handler.
- SSH sheet: host and fingerprint lines sit in a mono detail well, and
  keyboard-interactive prompts become field labels. Banners match the
  sheet's width and card shape.
- Worktree prompt: moves to the same card, with the path preview hung off
  the Name field.
- Notice pill: severity moves from a tinted edge to a 6px leading dot.
- Home: shortcut rows are 28px with a hover fill and keycap chords, and the
  remote strip's action uses the secondary button.

* fix(panel): start Info and Changes flush under the tab row

Their first line is text centred in a 28px row, so the extra 8px step put
it visibly lower than the Files tab's search well. Only Files keeps it.

* fix(scm): put the commit detail on the right panel's 20px text column

* feat(palette): restyle the command palette after the v4 design

- Card: the switcher's 12px corner, 112px drop from the top (shorter
  windows still scale it up), 600px max width.
- Search row keeps the list's own field; an esc keycap sits in its
  trailing corner while the field is empty.
- Rows are 32px with an 8px corner, 8px list inset and 10px padding. The
  keyboard row takes the popover's neutral selected step and a medium
  title instead of the accent wash, via a palette row element in place
  of ListItem.
- Section headings: 28px, 11.5/16rem medium caption ink, on the rows'
  text column. Shortcuts are per-key 18px faint keycaps from ui::dialog.
- New 40px footer with the switcher's keycap hints (navigate, open).
- Empty state: headline in body ink, hint in caption ink.

* feat(ui): carry the v4 chrome into panes, the file viewer and SFTP

- theme: additive helpers for a device-pixel hairline, tabular figures
  and an inverted neutral button variant.
- Pane splits rest as a device-pixel hairline in the divider tone; hover
  and drag keep the accent at 1px like the other resize edges.
- File viewer header: medium file name, 5px unsaved dot, 26px/6px close
  tile with its glyph on the content inset, divider hairline under it.
  Status bar: divider hairline, caption size, tabular line/column.
- SFTP browser: file-tree rows (26px, 6px corner, 16px caption glyphs),
  breadcrumb and notes on the 20px text column, a borderless edit well,
  inverted OK button, and ink-on-track transfer progress.
- Forward rows line up with the process and port rows (text at 20px,
  6px corner); Add and Reconnect use the inverted neutral fill.

* docs(design-system): note the v4 palette, pane, viewer and SFTP chrome

* feat(settings): restyle the settings page after the v4 design

- Nav: the rail's tinted fill and surface ladder behind a divider hairline;
  a 28px filled search well; 28px rows in 7px pills, the current one on the
  selected rung at medium weight instead of the accent; match counts in
  muted ink; the modified-only filter toggles like a nav row.
- Pages: the title sits in the 48px title-bar band at 16/16rem; group
  headings are 11.5/16rem medium muted on a 28px line; sections are split
  by a 0.5px divider with 16px either side.
- Rows: labels in body ink at regular weight, descriptions at 12/16rem
  muted, 28px floor with 8px padding; a search hit wears the faint neutral
  fill rather than the accent tint.
- Controls: text fields and dropdowns are 28px filled pills with no
  outline; buttons, segmented tracks and steppers are 26px with a 6px
  radius on the same fill. The one primary action per view (save theme
  draft, connect, install update) is the inverted neutral fill of the
  commit button. Switches and sliders keep the accent.
- SSH: host list header with 26px tiles and a filled search, 22px group
  headings, 42px two-line host rows; the form's labels are a muted,
  right-aligned column level with 28px fields; disclosure headers use a
  chevron on a 28px band.
- Theme cards are filled and unoutlined, taking the selected rung while
  open; the theme panel keeps the content fill with a divider edge and its
  title in the title-bar band. Keycaps are filled with no outline and
  shortcut rows are divided by 0.5px hairlines.
- right_panel::SECTION_GAP is now shared; docs/design-system.md updated.

* feat(ui): spell tab titles out in full in the rail and title bar

The rail's rows and the centred title have room to spare, so they take
the whole label from a new full_tab_label rather than tab_label's
three-segment cut; only the width they have decides what gets elided.

* fix(settings): even out the page rhythm and line up the columns

- Nav header: drop the min_h(ROW_H)/min_h(0) pair on the heading, which
  measured ~46pt taller than it painted and opened a hole under the search.
- Page titles sit under the title-bar band, level with the nav heading,
  instead of jammed against the window's top edge.
- Headings get a 22pt group-header row and hug their rows; rules keep more
  air, so a heading reads as its rows' rather than floating between.
- SSH: the host list gives width before the nav, so the nav no longer
  narrows on that page; its header, search well and detail title run level
  with the nav's; the empty note starts on the host-title column.
- Window & Tabs no longer opens on a stray rule.
- Integrations: status leads the buttons on one line, in the meta ink.
- Terminal: the shell footnote stays close to its rows.

* fix(ui): stop eliding branches that fit, and seat the SCM branch on the text column

- elide_tail_clusters returned "…" plus the whole string when nothing
  needed cutting, so the rail's group header printed …feat/v4-redesign
  with room to spare. Return the text as-is when it fits.
- The group header only reserves the chevron's width when it draws one.
- The Changes tab's branch name no longer stacks a small button's padding
  on the row gap; it starts on the file names' column.

* fix(ui): keep a tab's name in place when an inline rename starts

gpui-component's Input keeps 12px of inner padding even with
appearance(false), so the name jumped sideways as the rail row, the
group header and the top-strip chip swapped their label for the field.

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

* revert(settings): restore the page rhythm from before 08497d57

The title in the title-bar band, full-row headings, SECTION_GAP rules and
the shell footnote's spacing read better than the tightened version. The
bug fixes from that commit stay: the nav gap under the search, the stray
rule on Window & Tabs, the SSH column alignment and nav width, and the
one-line Integrations rows.

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

* revert(settings): restore the pre-v4 settings layout, on the rail's fill

The v4 restyle (541a887a) and the follow-ups crowded the page. Bring
settings.rs back to main's layout and give its sidebar the main window's
tab-rail fill, so the two sidebars read as one surface.

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

* feat(settings): rebuild the settings window after the v4 design

Rewrites the settings page to the Settings design: a sidebar with search,
per-page modified counts and a "Modified only" switch; quiet grouped rows
with an inline Reset; and the design's own controls (switch, segmented,
stepper, slider, text field, dropdown and popover menus) in a new
`settings/kit.rs`.

- Appearance: Light / Dark / System cards and a theme menu per slot with a
  live preview, search, keyboard navigation and swatches. Font menus are
  searchable and draw each family in itself.
- Keyboard shortcuts: back link, search, "Restore N changed", Default/tmux.
  A recorded chord another action already has now asks Replace / Cancel
  instead of taking it over silently.
- SSH: one column of recent hosts, "Show all" by source, search; details and
  a six-field editor open in place. Auth, jump/proxy, forwarding and
  advanced sections are no longer shown; saved values are kept.
- Integrations: machine menu, agent search, install summary, agent icons,
  and a per-row menu (Reinstall, Reveal hook file, Uninstall).
- General gains startup and restore; updates and the server move to About.
- Search results group live rows by page; a Modified view lists changes.

The page code moves out of settings.rs into src/ui/settings/.

* fix(ui): lay truncating names out at their full width

Moves the gpui fork to 5d366e6, which stops a size measured under
truncation from answering the later whole-text measure. Before it, a
truncating name beside other content in a flex_1 column read as just its
ellipsis with the whole column free. Adds a switcher test that fails
without the fork change.

* fix(scm): seat the History chevron on the change groups' column

The History header now draws its chevron in the change groups' own box
and size, so its title starts where Staged Changes and Untracked do.
Folded, the header drops to 24px with even padding instead of the
expanded section's taller band.

* chore: ignore local design mockups and Impeccable state

* fix(macos): show enter and tab shortcuts correctly in menus

Moves the gpui fork to 5c390b9, which maps enter and tab to their native
key equivalents. A menu item bound to secondary-enter, like
ToggleFullscreen, read as ⌘E.
2026-09-27 09:48:25 +08:00
l0ng-ai d843b82286 chore(release): v26.9.3 2026-09-23 19:21:12 +08:00
l0ng-ai 592058acc7 feat(fonts): make macOS stroke thickening configurable (#720)
Add a macOS-only `font_thicken` key (default true) and a Settings row
under Appearance > Terminal text. When off, AppleFontSmoothing is pinned
to 0 in this process's NSArgumentDomain before gpui's text system first
reads it, so glyphs render at the face's own weight. The volatile domain
is in-memory only: nothing is persisted and no other app is affected.
gpui caches the preference in a OnceLock, so a change applies after a
restart; no gpui fork change is needed.
2026-09-23 15:23:10 +08:00
l0ng-ai 6aa83b4bd9 chore(release): v26.9.2 2026-09-10 11:50:47 +08:00
l0ng-ai 6649cccbc7 Merge pull request #822 from l0ng-ai/fix/notification-poll
fix(notify): stop polling Notification Center from the UI thread
2026-09-09 17:35:45 +08:00
l0ng-ai 990873f762 fix(notify): scope notification identifiers to the process
Include the pid in the `tty7-pane-<pid>-<leaf>-<seq>` identifier so a
banner left over from a previous run (or a concurrent instance) is
ignored instead of revealing an unrelated pane.

Also drop the dead `com.apple.Terminal` fallback in the delegate
installer (mac-notification-sys completes its Once even on failure, so
the second `set_application` never ran), collapse the macOS cfg arms so
test builds compile the production path, and correct stale comments.

Claude-Session: https://claude.ai/code/session_01VuYUPiDEhQX6aQ4WQZbEGn
2026-09-09 17:08:09 +08:00
ayamirandl0ng-ai 59dbe83913 feat(macos): add default terminal integration (#818)
* feat(macos): add default terminal integration

* fix(macos): route external opens through the layout pull

Five holes in the LaunchServices path, all on the way from a URL to a tab.

The `ssh:` arm handed the raw URL back to `parse_quick_connect`, which
reads a bare `user@host:port` typed into Quick Connect. Everything a URL
carries past the authority landed in the wrong field: `ssh://h:2200/`
parsed its port as `2200/` and was dropped on the floor, `ssh://h/srv`
became the host `h/srv`, and the percent escapes `url` was added for were
never decoded. Read the authority off the parsed URL instead.

`x-man-page://3/printf` is Apple's sectioned form, and taking the host as
the page name ran `man 3`, which asks the user what page they wanted.
Section and page are now both carried.

A window that is pulling its layout is one `Adopt::IfEmpty` will not adopt
into, so a tab inserted while the pull is out comes back as the whole
workspace — the failure `then_open` already exists to avoid. Both the
script/man path and the SSH path inserted straight into a freshly restored
window, so `then_open` becomes a list of parked requests and carries a
command or an SSH link as well as a folder. A cold `ssh://` link also went
through `open_at` directly, claiming a fresh workspace and leaving the
restored one detached and unannounced; it takes the shared restore now.

`new_tab_running` wrote the command whether or not a tab opened, so a
failed spawn typed a script path and a newline into whatever pane was
focused before — a shell mid-line, or an agent.

Left alone deliberately: an `ssh://` link still connects without a
confirmation, which is a product call rather than a defect.

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

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-09-09 17:05:58 +08:00
l0ng-ai c9ec23d090 fix(notify): stop polling Notification Center from the UI thread
macOS notifications went through mac-notification-sys with wait_for_click so
a click could reveal the pane. That crate notices a click by parking the
sending thread and adding, per outstanding notification, a repeating 0.5 s
timer on the main run loop that calls deliveredNotifications — a synchronous
XPC round trip. A banner nobody clicks stays in Notification Center, so its
timer never goes away. Sampled with nine outstanding: a fifth of the UI
thread inside that XPC, every window juddering, one more timer per agent
turn.

Drive NSUserNotificationCenter directly with a delegate of our own: the click
arrives through didActivateNotification, the pane rides in the identifier,
and nothing runs on the main thread until the user clicks. notify-rust's
show is no longer called on macOS, since it is that crate and would replace
the delegate; only set_application stays, to name a bare binary.

Claude-Session: https://claude.ai/code/session_01VuYUPiDEhQX6aQ4WQZbEGn
2026-09-09 16:50:42 +08:00
dependabot[bot] cb710c4d79 deps: bump async_zip from 0.0.18 to 0.0.19 (#764)
Also bump the version requirement in Cargo.toml, which dependabot left at 0.0.18 and which made every --locked job fail.
2026-09-07 22:23:28 +08:00
l0ng-ai 081e191bb0 perf(diff-overlay): draw the patch as a virtualised row list (#799)
The overlay built its whole patch as a nested element tree on every frame:
a card per file, a header per hunk, six elements per line. gpui notifies the
view on each scroll wheel event, so a few hundred lines of diff rebuilt tens
of thousands of elements tens of times a second, and the window stalled.

Flatten the tree into one row per line in a new `diff_list` module and draw it
with `gpui::list`, which builds only the rows on screen. The rows are rebuilt
only when what they are built from changes, so scrolling no longer re-splits
hunks or re-clones every line, and a change to one file splices just the rows
it touched rather than resetting the list and losing the scroll position.

The key that decides a rebuild takes the snapshot each frame was asked about
even when it matched only by contents. A probe that finds nothing new still
lands a fresh `Arc` over an equal snapshot; a key left pointing at the old one
would go on walking the whole patch to prove the two equal, once per wheel
event, which is the cost the key exists to avoid.

A list counts a row it has not laid out yet as zero tall, which left the
scrollbar reading an 800-line patch as one viewport: its thumb filled the
track, and a drag from top to bottom travelled 248px and stopped. The rows
below the fold are counted at the 19px both views already give a line of a
patch, through `ListState::with_size_hint` — added to the gpui fork for this,
`Cargo.lock` following its `tty7` branch to `ece710e3`.

A card cannot survive that flattening — its rows are separate items now — so
the frame it drew is gone, and with it the grey header bars and hunk bands
that made the overlay the one view in the app still speaking gpui-component's
default container language. The rows take the source control panel's own
measurements instead: 26px, 10px inset, 5px radius, colour only under the
pointer. The title bar's view switch loses its border for the same reason.
2026-09-07 22:13:57 +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
l0ng-ai 46759b8a01 fix(input-bar): read column widths from unicode-width, not a hand-rolled table (#704)
* fix(input-bar): read column widths from unicode-width, not a hand-rolled table

The input bar scored every character against a hand-written list of code-point
ranges. Anything the list missed counted as one plain column, so `🀄`, `⌚` and
every combining mark pulled the rest of the row a column left, and clicks,
wrapping and the caret all landed off by that much (#701).

The grid gets its widths from `unicode-width` by way of `alacritty_terminal`,
so read the same table. Zero-width characters then need a cell to ride in:
group each base with the marks that follow it, so the shaper sees one run and
composes `é` instead of setting `e` and its accent side by side. An emoji
presentation sequence is re-scored as a string the way the grid re-scores it,
so `❤️` is two columns in the bar as well.

A ZWJ sequence stays two cells on purpose — that is what the grid makes of it,
and composing it here would put the bar a column off from where the text lands.

* fix(input-bar): derive click and wrap geometry from the cells the bar draws

`input_cells` re-scores an emoji presentation sequence to two columns and
hands a stranded combining mark a column of its own, but `input_char_positions`
kept walking the text character by character — so `❤️` was drawn two columns
wide and counted as one. Everything geometric read the short count: a click on
`X` in `❤️X` selected past it, wrapping broke a column early, and vertical
caret motion aimed at the wrong column.

Walk the same cells instead. Only the base of a cell carries the width, so a
click still lands on the base rather than a mark riding on it, and the riders
sit at the column the caret takes after the cell.

A cell now also tints as a unit when a selection covers any character in it —
it is one glyph, so half-highlighting it drew a mark unselected next to its
selected base.
2026-08-20 22:35:32 +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
l0ng-ai 71c6783fb4 chore(release): v26.8.3 2026-08-12 18:41:37 +08:00
l0ng-ai 44f0683d0a fix(sidebar): cut labels on grapheme clusters, not on chars (#450)
The sidebar's elision measures against real glyph widths but slices by
`char`, so it can satisfy every width check and still hand back a torn
cluster. Scanning budgets from 30px to 200px over emoji fixtures, 48
widths produced output no font can render as intended:

    "release-…\u{200d}👩\u{200d}👧"   a joiner with nothing in front of it
    "lon…\u{fe0f}"                    the variation selector lands on the ellipsis
    "abcdef…🇳ghijklmnopqr"           half a flag, which renders as a bare N

A tab title carrying an emoji is not exotic — plenty of TUIs and coding
agents put one there — and the second case is the same U+FE0F this repo
already carries an alacritty patch for.

`elide_keep_edges`, the tail-only fallback, and `short_title`'s 40-glyph
clamp now index grapheme clusters. `elide_path_keep_tail` cuts on `/`
and was already safe. Widths are unchanged: clusters are measured the
same way chars were, so every existing elision test still passes on the
same fixtures.

`unicode-segmentation` is already in the tree via gpui; pinning it here
adds one line to Cargo.lock and no new code.

Tests assert the property rather than the symptom: whatever survives on
either side of the ellipsis has to be a cluster-aligned prefix and
suffix of the input. That catches any tear, not just the three shapes
found here. Written first, confirmed failing on all three cut sites, and
green after.
2026-08-10 10:38:04 +08:00
ARNOandl0ng-ai 61efe27f2d feat(windows): add native backdrop material presets (Mica / Acrylic /… (#412)
* feat(windows): add native backdrop material presets (Mica / Acrylic / Blur)

Adds a Background material dropdown (Auto / Blur / Mica / Mica Alt /
Acrylic / Off) that maps onto the native Windows backdrop APIs already
provided by the gpui fork — Mica and Mica Alt via
DwmSetWindowAttribute(DWMWA_SYSTEMBACKDROP_TYPE), Acrylic via the new
DWMSBT_TRANSIENTWINDOW material, and Blur via the classic
ACCENT_ENABLE_ACRYLICBLURBEHIND path — with no fork changes required.
* config: introduce WindowBackdrop in tty7-core with lenient kebab-case
  deserialization, defaulting to Auto for existing configs
* theme: resolve the backdrop through a build-number fallback chain
  (Mica/Mica Alt need Windows 11 22H2, Acrylic needs 22H2 natively and
  1809 via classic acrylic, Blur needs 1809; older builds fall back to
  plain translucency) and default the background alpha to
  SYSTEM_MATERIAL_OPACITY (0.82) while a material is active
* settings: replace the blur toggle with a localized backdrop dropdown
  that only lists the presets the current Windows build actually
  supports, and keep the settings panel fully opaque so workspace
  translucency never shows through it
* theme: make the file sidebar and right detail panel follow the window
  opacity so the backdrop material shows through the whole workspace,
  keeping row-level accents opaque for readability
* i18n: add backdrop keys for en, zh-CN and ja-JP, covered by the
  translation completeness test

* feat(theme): let the sidebar and right panel follow the window opacity

* update GPUI

* fix(windows): gate the sidebar translucency to translucent windows and sync the opacity slider

fix(windows): gate the sidebar translucency compensation to active materials

* fix(windows): derive the material opacity default from the resolved appearance

* fix(theme): keep WindowBackdrop semantics consistent on non-Windows

f

* fix(theme): stop Windows-only materials from pinning the blur on other platforms

* docs(changelog): document the Windows backdrop material settings

* refactor(theme): share the default window-opacity derivation

* fix(ui): keep gradient presets behind the settings panel and scope its fallbacks

* fix(ui): keep the settings theme picker legible and the backdrop label honest

f

* fix(theme): let every backdrop variant defer to the local blur toggle on non-Windows

* fix(settings): restore the backdrop dropdown selection on locale refresh

* fix(ui): keep the opened-file editor surface opaque under window translucency

* fix(settings): rebuild backdrop options after selection

* fix(settings): ignore synced windows backdrop overrides on other platforms

* fix(settings): preserve synced windows backdrop on non-windows reset

* fix(diff): keep the full-window overlay background opaque

* fix(windows): keep Auto opaque and stop the backdrop from misreporting itself

Ten findings from a review of the backdrop-material work, all in the
Windows-only paths.

The root one: `material_active` treated `Auto` as a material whenever the
legacy blur toggle happened to be on. `Auto` is the default in every config
written before this setting existed, and plenty of them carry
`window_blur: true` from the switch that no longer renders on Windows, so an
untouched install would drop from opaque to 0.82 alpha - with its file
sidebar and right panel at 0.15 - on first launch after the update, with no
visible control to undo it. Only an explicit pick in the dropdown now buys
the translucent defaults. The switch comes back on Windows while the
backdrop is `Auto`, since that is exactly when the legacy flag still decides
something.

The rest:

- Mica and Mica Alt fell back to `Blurred` with no lower bound, asking for a
  blur that does not exist below 1809 - and build 0, which is what a failed
  `RtlGetVersion` reports. They now degrade to plain translucency like
  `Blur` and `Acrylic` already did.
- Acrylic is no longer offered below 22H2, where it resolves to the very
  same classic WCA blur as `Blur`. A test now asserts that no two offered
  presets render identically on any build.
- `reload_from_config` re-applied the theme and the opacity slider but not
  the backdrop dropdown, so an external config change switched the window's
  material while the control kept naming the old one.
- The settings, opened-file and diff overlays were made opaque so the OS
  backdrop cannot show through their text; that also hid the theme
  background image, which used to show through them. They paint their own
  copy of it now, and the fill they share moved into
  `theme::overlay_background`.
- The SFTP transfers tray painted `workspace_surface_color` inside the right
  panel, which already paints it, stacking the same translucent surface
  twice into a darker band with a hard seam.
- `apply_theme` re-issued `set_background_appearance` on every `Config`
  mutation in every window. With a DWM material that now costs a
  `SetWindowPos(SWP_FRAMECHANGED)` frame recalc, so dragging the opacity
  slider recalculated the frame once per mouse sample; it is skipped when
  the appearance is unchanged.

* fix(ui): dim the overlay background image, and stop telling Windows it is macOS

Two defects found while driving the previous commit's changes in the app.

The overlays repaint the theme background image over their own opaque fill,
so it survives them being made opaque - but nothing dimmed it. Before those
overlays were opaque the image reached the eye through their translucent
fill; painting it at full strength put the settings text straight on top of
the wallpaper and made the panel unreadable at any image opacity above about
half. They now paint the image and then the workspace's own fill over it,
which is exactly the strength the image had through these overlays before,
and which needs no new constant to say so. Shared as
`app::overlay_surface_layers`, empty when the theme has no image so a
themeless window paints no second pass of anything.

The Windows-only blur row reused `SettingsBlurDesc`, whose text ends in
"(macOS)". It gets its own key in all three locales, describing the job the
flag actually still has on Windows: feeding the `Auto` material.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-09 15:14:54 +08:00
l0ng-ai e46bcbcf56 chore(terminal): drop the client-side command-mark store (#404)
Removing the Outline panel (#374 / #375) took away the only reader of the
client-side command marks. The scanner kept running on every batch of PTY
output, and it was the one scanner that forced the batch to be split before it
reached the emulator, so it was not free.

Gone: `MarkScanner`, `Marks`, `CommandMark`, `record_mark`, `Cut::Mark` and the
tests that only covered them. With the cursor cut as the sole cut left, the
offset sort is a no-op and goes too — `ParkedCursorScanner` already reports in
ascending order — and `Cut` itself collapses into a plain `CursorCut`.

Kept: `zle_tok` and `mode_tok` read the same OSC 133 bytes and are load-bearing
for `zle_reading` / `shell_vi_mode`, including the deliberate live-vs-snapshot
split. Daemon-side OSC 133 handling is untouched.

Dropping `marks().clear()` left `clear_scrollback` with no anchored-state
invalidation at all, and it never had any for the other store that needs it:
kitty image placements are anchored to an absolute scrollback row, so purging
the history moves every anchor and the frame paints over unrelated text or
resolves past the viewport, with no redraw coming since the daemon does not
replay out-of-band image frames. Clear the image store there, as the reattach
path already does, and route the purge through
`Term::clear_screen(ClearMode::Saved)` so a selection reaching into the
discarded rows is invalidated instead of clamping onto the viewport.

Closes #378
2026-08-09 14:49:47 +08:00
l0ng-ai b4e7add65d chore(release): v26.8.2 2026-08-09 09:59:26 +08:00
l0ng-ai 51c35aac1f fix(terminal): resize ConPTY panes with conhost's semantics, in stream order (#415)
ConPTY emits no repaint after a resize; conhost silently re-anchors its
layout and keeps painting with absolute cursor addresses computed against
it. Measured live: growing the window keeps rows and cursor pinned and
opens blank rows below, and shrinking scrolls the last written row to the
new bottom. The grid resized the alacritty way instead, so after a
maximize every absolute-CUP paint landed mid-screen inside the old output.
The vendored alacritty_terminal now has a conpty_resize mode mirroring
conhost's model (fork rev 1276f12); every Windows pane opts in.

Separately, a resize during a burst of output reflowed ahead of the
backlog (up to the gate's 16 MiB of old-width bytes). The daemon now
echoes a Size frame to the controller at the exact stream position where
the PTY geometry changes, and a client that probes the new resize-echo
feature defers its reflow to that marker. Remote routes and older daemons
keep the reflow-at-request-time path.
2026-08-08 22:40:04 +08:00
Hongwei QinandHongwei Qin a7de7db2c4 feat(windows,macos): clickable toasts, richer context, and i18n (#373)
Desktop notifications now carry the pane they came from: clicking one reveals
that pane's window, tab and split. Windows shows a WinRT toast with an
`Activated` handler, macOS uses mac-notification-sys' click response, and both
route through the existing tray dispatch channel. Linux keeps the plain
notify-rust path.

Titles gained context — an agent name or the machine, then the workspace — and
bodies name the command or agent alongside the duration, all of it translated.

Notification text is sanitized on every path: it comes off the terminal, and a
stray control byte used to make the Windows toast XML fail to parse and lose the
notification outright.

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

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

The tray badge and the in-window status dots are untouched; they were
always the ones the taskbar was mirroring.
2026-08-06 21:40:25 +08:00
l0ng-aiandl0ng-ai 7c0598c7e0 fix(bell): ring the system bell on Windows (#359)
ring_system_bell() was macOS-only and returned false everywhere else, so
on Windows and Linux the Audible mode fell straight through to its visual
fallback: Visual, Audible, and the new Both were three names for one
behavior. Windows has MessageBeep, so two of those three now differ.

MB_OK plays the "Default Beep" scheme entry, which follows the user's
choice in Sound Settings rather than synthesizing a fixed tone at the
speaker the way Beep() does. The Win32 metadata files MessageBeep under
Diagnostics::Debug despite it being a user32 export, hence the extra
windows-sys feature; no new crate and no dbghelp.

Linux is left on the flash fallback on purpose: libcanberra and PipeWire
are runtime links away and XBell does nothing under Wayland.

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

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-06 11:25:12 +08:00
27bb1864df feat(windows): taskbar status overlay per window (#355)
* feat(windows): taskbar status overlay per window (#199)

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

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

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

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

Four fixes on top of the overlay:

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

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

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

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: l0ng-ai <ysdpk123@gmail.com>
2026-08-06 10:12:41 +08:00
603bca171e feat(updater): add windows updates and cross-platform nightly support (#330)
* feat(updater): add windows online updates

* feat(updater): support online updates for windows portable zip builds

f

* feat(updater): support online updates for nightly build

* fix(updater): strengthen post-download update verification

* feat(updater): support explicit stable and nightly channel switching

* fix(i18n): localize update settings ui

* fix(settings): prevent slider value labels from wrapping

* feat(updater): drop the nightly channel, refuse all-users Windows installs

Follow-up to the Windows updater work on this branch, applying maintainer
review.

Nightly is a build channel, not an update channel. The updater consults
`/releases/latest` again and nothing else, so it behaves on Windows exactly
as it already does on macOS: a Nightly build is offered the stable release
that supersedes it and graduates out of the prerelease, and no rolling
prerelease can become a source of code that gets executed on a user's
machine. Removed with it: the `UpdateChannel` enum and its version-string
inference, the `tags/nightly` query, the cross-channel version-ordering
bypass, the Settings → About channel row, the rolling-tag
`update-manifest.json` and the i18n keys that only served them.
`parse_version` and `is_update_available` are byte-identical to main again.

Nightly builds are untouched, and still carry tty7-updater plus the macOS
update archive — a Nightly user needs a working helper to reach the stable
release that replaces their build.

An all-users Windows installation is no longer updated in place. Running the
release Setup silently as the signed-in user cannot replace
`C:\Program Files\tty7`: Inno resolves `{autopf}` to `%LocalAppData%\Programs`
and installs a second copy beside the real one, or re-launches itself
elevated and puts a bare UAC prompt for an unsigned executable in `%TEMP%` in
front of a user whose GUI just vanished. tty7 declines both and points at the
release page. Detection reads Inno's own `HKLM` state for the frozen AppId and
independently probes whether the directory accepts writes, so a relocated or
pruned installation is caught too; the decision is a pure function with unit
tests, and it is re-checked before the download as well as during it.

Release and Nightly now verify the Windows packages they just built, mirroring
the macOS update-archive step: the install marker, tty7-updater.exe, the ZIP
layout the updater will accept and the PE versions it will demand. Every fact
the updater checks on the user's machine after downloading is checked here
instead, so a packaging mistake fails the build.

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

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 10:27:37 +08:00
618855cf4a fix(windows): brand toast notifications with a tty7 AUMID (#340)
* fix(windows): brand toast notifications with a tty7 AUMID (#339)

* fix(windows): only write the toast shortcut where it is ours to write

The AUMID shortcut was rewritten on every launch, which broke two cases
the review caught on a real machine.

An elevated install owns `%ProgramData%\...\tty7.lnk`, so writing a
per-user copy listed "tty7" twice in the Start Menu and left an orphan
pointing at a deleted exe once the uninstaller had removed only its own.
And `cargo run` repointed the installed shortcut at `target\debug`,
permanently, for anyone who both installs tty7 and builds it.

So decide before writing. An all-users shortcut settles the question by
itself — branded if the installer stamped our AUMID on it, otherwise we
stay on the PowerShell identity, because the alternative is littering a
Start Menu we cannot clean up. Otherwise we refresh the single per-user
`tty7.lnk` Inno's default install owns anyway, and only when it is not
already ours, and never from a cargo build directory. A dev build still
brands the process for taskbar grouping, and still gets branded toasts
when an install left a stamped shortcut behind — Windows asks that the
AUMID be registered, not that it point at the process using it.

Reading a shortcut back needs `IShellLinkW::GetPath`, hence the
`Win32_Storage_FileSystem` feature; `SLGP_RAWPATH` keeps it from chasing
a moved target over the network.

Also close the window this opened. The shell indexes a new `.lnk`
asynchronously and, for an AUMID it has not seen, `Toast::show()`
reports success and drops the toast — measured, it does not return an
error. A shortcut we wrote seconds ago is therefore not yet proof of
anything, so toasts keep the PowerShell identity for half a minute after
we write one: ugly beats invisible.

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

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 21:09:07 +08:00
b7e08c7e11 feat(windows): add optional windows explorer context menus (#310)
* add CLI support for opening directories in new tabs

f

* feat(windows): add optional windows explorer context menus

f

* fix(gui): restore missing windows and reject lossy paths

* fix(windows): harden explorer menu registration and native path handling

* fix(cli): preserve native GUI paths on Windows

---------

Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-03 15:23:59 +08:00
ayamir a6754b28bc feat(update): install verified macOS releases in app 2026-08-02 22:25:10 +08:00
l0ng-ai 6fc8bcb2cf chore(release): v26.8.1 2026-08-01 08:55:21 +08:00
l0ng-ai 74f4f1a35a chore(release): v26.8.0 2026-08-01 00:23:33 +08:00
l0ng-ai 86eba1e2c2 feat(cli): make a captured pane readable, and stop panicking on a closed pipe
The CLI's own --help calls it "built for coding agents", but `capture` handed
back the daemon's raw PTY bytes, which is the least readable thing it emits,
and every verb panicked when its reader hung up.

`capture --plain` replays those bytes through a terminal grid instead of
stripping escapes from them, using the same alacritty_terminal rev the GUI
renders panes with. The difference is not cosmetic: only the grid knows that a
break at the pane's width was a wrap rather than a newline, that a CR meant
"overwrite this line" rather than "end it", and which cell a wide char shares
with its spacer. A regex gets the easy 90% and then invents the rest — on one
real pane it turned 1193 lines into 2806.

The size each segment needs comes for free: the daemon already sends
DaemonMsg::Size right before every Snapshot, and the CLI was discarding it.
Panes here measure 249 and 86 columns, so the hardcoded 120 would have wrapped
both in the wrong places. Observing still resizes nothing.

The pipe fix is two mechanisms with one contract. On Unix SIGPIPE goes back to
its default disposition, which covers every write site at once and ends the
process the way it ends `cat` (141). Windows has no such signal, so stdio::out
recognizes the hung-up write and leaves quietly. Before this, 16 of 19 verbs
printed a panic and a backtrace note for `tty7 ls | head -1`; `run` instead
reported it as a failure with exit 1.

Also adds skills/tty7, the Claude skill for driving this CLI. It shipped with a
Python ANSI stripper, which is what prompted --plain; the script is gone.

alacritty_terminal moves to [workspace.dependencies] so the GUI and the CLI
cannot drift onto two revs of the fork.
2026-07-31 19:18:44 +08:00
l0ng-ai c275960ceb feat(cli): ship the CLI in every installer and put it on PATH at launch
The `tty7` CLI was built by every release run and thrown away: all four
bundle scripts copied only `tty7-app`, and the upload glob covers `dist/`,
which the CLI never reached. Nothing put it on PATH either, so the
agent-facing half of the product was unreachable from a shipped install.

Bundle it on all four platforms, and have the GUI link it up itself rather
than hiding the step behind a menu item most people never find.

The install has two halves. The environment half prepends the CLI's
directory to this process's PATH before the daemon is spawned, so every
pane inherits it — that alone makes `tty7` work where agents actually run,
writes nothing to disk, and behaves the same everywhere. The on-disk half
symlinks into a directory already on PATH (Unix) or appends to
HKCU\Environment (Windows), and is allowed to fail.

Candidate directories are a fixed list intersected with PATH, not the first
writable entry on it: pyenv/rbenv/asdf/mise shim directories sit at the
front of PATH on many machines and are writable, and anything dropped there
is deleted on the next rehash — silently, days later.

Debug builds get the environment half only. `target/debug` holds a `tty7`
too, so otherwise a `cargo run` would repoint the developer's real `tty7`
at a debug binary, and each isolated dev-verify instance would rewrite the
PATH of the machine it is meant to stay away from.
2026-07-31 16:49:22 +08:00
thomasandClaude Fable 5 b46183688e fix(cli): review findings — CI coverage, kept-pane filing, endpoint and lifecycle honesty
- workspace: tty7-cli joins default-members, so a bare root cargo test runs it
- run --keep files the pane into its workspace via TabCreate (and refuses to
  keep a pane no workspace would list); --ws help says what it really does
- server start|stop|restart|logs refuse -m instead of silently acting locally
- server start kills the spawned process when it never opens its endpoints
- -m over a down link is refused instead of redialing with auto auth
- capture help tells the truth: raw ANSI bytes, last ring segment by default
- a missed exit-code probe exits 1 with a stderr note, not a fabricated code
- TTY7_SOCKET is honored: control dials it, the pane endpoint is its sibling
- attach's success JSON says attached, not detached_from
- e2e daemons ride a KILL_ON_JOB_CLOSE Job Object on Windows, so a hard-killed
  harness cannot leak servers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 10:51:41 +08:00
thomasandClaude Fable 5 10b6741368 refactor: rename the GUI binary to tty7-app, freeing tty7 for the CLI
The package name and every display name ("tty7" in menus, tray, .desktop
Name, CFBundleName, installer AppName, shortcuts) stay as they were; only
the executable file is now tty7-app / tty7-app.exe, per docs/cli-design.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 09:28:00 +08:00
l0ng-aiandl0ng-ai bed22d899e Keep workspaces whole: remote reopen/restart recovery, and cross-workspace restore guards (#257)
* feat(remote): keep a remote workspace whole across reopens and restarts

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: drop the references to the deleted design documents

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

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

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

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

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-29 19:15:19 +08:00
7194236985 fix(terminal): prevent fullwidth punctuation overlap and speed OSC mark scanning (#250)
* fix(terminal): stop wide glyphs overlapping after fullwidth punctuation

gpui's apply_force_width_to_layout tells a base glyph from a zero-advance
combining mark by whether the shaped x advanced past half the forced
width, and CJK fullwidth punctuation fails that test (U+FF08 advances
~0.47 em against a 0.6 em half-slot). In a batched wide run the glyph
after such a character was classified as a mark and painted on top of
it. Shape each wide glyph on its own line instead: the first glyph of a
line is unconditionally a base, so the heuristic never misfires.

* perf(terminal): intern wide-segment strings via char_string

Each wide glyph now shapes alone, so its text is a single-char string —
reuse the char_string memo instead of allocating a fresh String per cell
per frame. The interned SharedString is also what keys gpui's line
layout cache, so a CJK-dense repaint allocates nothing.

* perf(terminal): skip MarkScanner's Text state ahead with SIMD memchr

The scanner runs over every batch the client receives, and ordinary
output — where the only byte that matters is ESC — dominates each one.
Skip to the next ESC with memchr instead of stepping per byte, exactly
as tty7-core's OscTokenizer already does: measured on an 8 MB batch of
plausible output, 1.6 GB/s became 8.3 GB/s.

Declare memchr for the root crate — it left with the OSC tokenizer's
move down to tty7-core, and this is the first use since.

* fix(terminal): advance segment_row past each wide glyph

The unbatching change dropped the `col += 2` along with the batching
loop it lived in, so the wide-glyph arm pushed its segment and looped on
the same column forever, growing `segs` until allocation failed — the
6 GiB abort on the Windows CI runner, and a machine-freezing memory
climb under a local `cargo test`.

---------

Co-authored-by: lizhi <lizhi20@xiaomi.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: l0ng-ai <ysdpk123@gmail.com>
2026-07-29 18:32:19 +08:00
l0ng-ai c469e10312 Merge remote-tracking branch 'origin/main' into feat/remote-workspace
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	src/core/config.rs
#	src/ui/pane.rs
2026-07-28 18:03:55 +08:00
l0ng-ai 1996f2ae12 chore(release): v26.7.6 2026-07-28 14:52:21 +08:00
thomasandClaude Fable 5 5cf767c9d9 chore: finish the resvg dedupe — bump gpui-component to 0.47
resvg 0.47 landed in tty7 (#227) and the gpui fork (#237), but
gpui-component still declared its own resvg = 0.45.1, keeping a
legacy resvg/usvg/tiny-skia 0.45/0.11 stack in the tree. The fork now
pins 0.47 (l0ng-ai/gpui-component@2264ff99 — no source changes needed;
its only resvg user, the Windows native-menu rasterizer, uses APIs
unchanged across the bump), so this moves the pin and drops the last
duplicate: the lockfile now carries a single resvg/usvg/tiny-skia
stack at 0.47/0.12, and `cargo tree -i resvg@0.45.1` matches nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:40:25 +08:00
thomasandClaude Fable 5 c04f0ab8f6 chore: dedupe the resvg stack with the gpui fork
PR #227 bumped tty7's direct resvg to 0.47 while the gpui fork still
pinned 0.45, so the tree compiled two resvg/usvg/tiny-skia stacks. The
fork's tty7 branch now carries resvg 0.47 (l0ng-ai/zed@3aac3ef); move
the gpui pin there so gpui's SVG renderer and tty7's tray-icon
rasterizer share one 0.47 stack again.

gpui-component still declares its own resvg 0.45.1 (semver-incompatible
with 0.47), so one legacy 0.45 stack remains until that fork catches up
- noted in the manifest comments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:18:04 +08:00
thomas 2c18d6b7f4 Merge origin/main into dependabot/cargo/sha2-0.11.0 2026-07-28 11:42:19 +08:00
thomas 2794be9ff3 Merge remote-tracking branch 'origin/main' into dependabot/cargo/resvg-0.47.0 2026-07-28 11:31:12 +08:00
l0ng-ai 208454e202 feat(remote): remote workspaces — a window that is one machine
Split the framework-free half of tty7 into `tty7-core` and add a headless
`tty7-server` built on it, so a workspace's filesystem, git and session state
can live on another machine while the GUI stays where it is.

- `crates/tty7-core`: wire protocol, session daemon, PTY, native SSH engine and
  the domain model, with no gpui dependency. Module paths are unchanged.
- `crates/tty7-server`: the same daemon with no GUI attached, linked fully
  static against musl and pushed onto the remote box. One dependency, on
  purpose — a second one the GUI also needs belongs in core.
- `Host` trait + `HostId`/`HostRegistry`: every fs/git/watch call a workspace
  makes goes through the machine it belongs to. `LocalHost` answers on this
  box, `RemoteHost` over a routed control connection.
- `ui::host_ops`: the GUI's single door to a `Host`. Host calls block, so all
  of them run on the background executor with the result landed on the UI
  thread; de-duplication, staleness and error reporting live here rather than
  at each call site. Enforced by a CI grep.
- Connect flow: home page → pick a configured SSH host → the machine's own
  workspace list → a window bound to one workspace on it. Workspace switcher
  groups by machine, this computer included.
- CI: static musl builds of `tty7-server` for x86_64/aarch64 via
  cargo-zigbuild, a host-boundary grep, and version stamping factored out of
  the nightly workflow. Both new jobs are non-required so branch protection
  does not wedge open PRs.

Design and the interface contract it was built to are in
`docs/2026-07-27-remote-workspace-{design,impl-contract}.md`.
2026-07-28 10:59:46 +08:00
thomasandClaude Fable 5 99f40122f8 deps: align resvg manifest requirement with the 0.47 lockfile bump
Dependabot only rewrote Cargo.lock, but the manifest still required
resvg 0.45, so every `cargo build --locked` CI job failed with
"cannot update the lock file". Bump the requirement to 0.47 and update
the pin-rationale comment: gpui/gpui-component still carry resvg
0.45.x, so a second resvg/usvg/tiny-skia stack now compiles until the
fork catches up (no type conflicts; only image::RgbaImage crosses the
tty7/gpui boundary).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:38:57 +08:00
thomasandClaude Fable 5 e36717a19e deps: align Cargo.toml sha2 requirement with 0.11 lockfile bump
Dependabot bumped sha2 to 0.11.0 in Cargo.lock but left the manifest
requiring 0.10, so --locked builds failed on every platform. tty7's
only sha2 usage (Sha512::digest in core::keychain) is unchanged in 0.11.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:37:32 +08:00
thomasandClaude Opus 5 0be3b67640 fix(render): stop italic CJK rendering as unrelated CJK on Windows
Every character came out as a different character, one for one, consistently
— it read as a broken locale or a mangled encoding, and it was neither.

Hack, the bundled default, has no CJK, so those cells are shaped through the
font-fallback chain. gpui's Windows backend then threw away the face
DirectWrite shaped the run with and looked a fresh one up by family, weight and
style. That round trip mapped DirectWrite's italic to oblique — the enum is
numbered OBLIQUE = 1, ITALIC = 2, and the mapping had them the other way around
— so an italic fallback face resolved to a request for an oblique one, and a
family with no oblique face (Maple Mono NF CN, first in our Windows chain) came
back as its upright face instead. The glyph indices were right; the outlines
they indexed belonged to a different face, at a fixed glyph-id skew.

Fixed upstream in our gpui fork by registering the face DirectWrite actually
chose rather than re-deriving one, which also closes a latent use-after-free in
the same cache: it keyed fonts by a raw pointer to a face nothing held a
reference to, so a released face could be aliased by any later allocation.

Bumps the fork pin; no tty7 code changes. Covered there by two tests in
`gpui_windows::direct_write` — one asserting a shaped run's glyphs round-trip
through the font id the run reports, one asserting every font-face cache key is
owned by the font it maps to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 08:42:08 +08:00
l0ng-ai 0030b98faa chore(release): v26.7.5 2026-07-27 14:00:22 +08:00
l0ng-ai f5b4a65ec4 fix(terminal): render emoji presentation sequences at their real width
An emoji written as base + U+FE0F rendered wrong twice over. Both halves
came from the variation selector arriving as a zero-width combining mark
after the column budget was already spent.

Width, in the `alacritty_terminal` pin (bumped to the fork's b79e704):
`input` reserves columns one `char` at a time, so a base whose East
Asian Width is Neutral -- U+2764 in `❤️`, U+1F5C2 in `🗂️`, U+26A0 in
`⚠️` -- kept the single column it was given, its glyph bled over the
next cell, and every column after it on the line shifted left by one.
The fork re-scores the sequence with `UnicodeWidthStr`, which is where
UTS #51's width-2 rule lives, and widens the cell to match.

Presentation, here: `snapshot_cell` copied only `cell.c` into
`RenderCell`, so `cell.zerowidth()` was dropped before the shaper ever
saw it. `❤` and `❤\u{FE0F}` reached gpui as the same string and picked
the same text-presentation face -- a black heart where every other
terminal shows a red one. `RenderCell` now carries the marks and a new
`RowSeg::Cluster` shapes them with their base. That restores every
combining mark, not just the selectors: `e` + U+0301 was being dropped
the same way.

A marked cell never joins a batched run. Marks add characters without
adding columns, which is exactly the correspondence `force_width` uses
to pin one glyph per column in a `Run` or `Wide` segment.

Fixes #203.
2026-07-27 10:35:58 +08:00