Commit Graph
501 Commits
Author SHA1 Message Date
yetone fac3e4492e fix(render): snap box-drawing strokes to the device pixel grid
Straight strokes with edges at fractional device pixels rasterize an
antialiasing ramp at each end, and two abutting 50%-coverage ramps
composite to 75% opacity — so a multi-row │ was perforated by a lighter
band at every cell boundary and read as broken next to kitty's solid
lines (kitty's cell-aligned box bitmaps never sit off-grid).

Snap every rectangle edge to whole device pixels via the window scale
factor. Edges are snapped individually (not origin + size), so the two
cells sharing a boundary snap the same coordinate to the same pixel
line: zero gap, zero overlap, whatever the window position. Arcs and
diagonals keep their antialiasing on purpose; the rounded corners'
straight stubs now reach one device pixel into the arc band to cover
the snapped-meets-unsnapped handoff.
2026-07-27 18:03:35 +08:00
yetone ee921c4879 fix(render): draw box-drawing and block characters natively
Box characters rendered as font glyphs only cover the font's own line
height, but the cell is font_size × line_height (1.4 by default) — so
every vertical run of │/╭/╰ broke into dashes with a gap at each row
boundary: a two-line prompt's corners never connected, a TUI frame was
perforated down both sides.

New `terminal::boxdraw` module draws U+2500–U+257F and U+2580–U+259F as
geometry pinned to the cell's real edges, the same special case every
terminal with a line-height setting ships (kitty, alacritty, WezTerm)
and the same approach the existing Powerline separators use:

* light/heavy lines, corners, tees, crosses: per-arm rectangles with a
  centre overshoot so any weight combination joins solid
* the double-line set: explicit per-character stroke lists, keeping the
  open junctions (╬ is four corners around a hole) intact
* rounded corners ╭╮╯╰: straight stubs plus a quarter-circle band of
  overlapping convex quads — a single band outline is concave, which
  gpui's fan fill renders as a solid blob, and butted segments seam at
  75% opacity where two antialiased edges meet
* dashed lines, diagonals ╱╲╳, block eighths/quadrants, and the ░▒▓
  shades as foreground-alpha washes

Anything outside the range still renders through the font.
2026-07-27 17:54:47 +08:00
mingrathandClaude Opus 5 a7835a0b8c fix(terminal): shape Thai SARA AM with the base it belongs to
SARA AM (ำ U+0E33) is `Lo` and width 1, so the grid gives it its own
column — but it is not atomic to the shaper. The Thai shaper decomposes
it into NIKHAHIT + SARA AA and moves the nikhahit backwards over any
above-base marks onto the base consonant. Shaped in a run of its own it
has no base to reorder onto, so `น้ำ` came out as `น้` plus a dotted
circle, losing the vowel entirely.

Absorb a following SARA AM into the preceding cell's cluster, so base,
tone mark and SARA AM reach `shape_line` in one string. Lao SARA AM
(U+0EB3) takes the same shaper path and is handled with it.

That makes `cells == 2` ambiguous, so `Cluster` now records why: a wide
base is one glyph spanning two columns and pins at `2 × cell_width`,
while an absorbed SARA AM is two base glyphs of one column each and pins
like a `Run`. `apply_force_width_to_layout` classifies glyphs by advance
rather than by count, so the marks ride their base under either pinning.

Two deliberate limits, both pinned by tests:

A SARA AM is not a base for another one. Absorbing there would pin the
second one's glyphs past the cluster's two-cell clip and swallow it, so
`ำำ` stays two `Solo`s and both remain visible. A SARA AM with nothing
before it likewise paints alone — a dotted circle is the shaper's honest
answer for an orphaned mark, and inventing a base would be worse.

An absorbed SARA AM takes its base's style rather than its own, so a
colour change mid-syllable (`grep --color` landing between a consonant
and its vowel) recolours the vowel. Unlike `Run` and `Wide`, the cluster
cannot break on a style change: split off, the vowel renders as a dotted
circle. A recoloured vowel beats a broken one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 16:50:57 +07:00
dependabot[bot] 4da5670882 deps: bump sha2 from 0.10.9 to 0.11.0
Bumps [sha2](https://github.com/RustCrypto/hashes) from 0.10.9 to 0.11.0.
- [Commits](https://github.com/RustCrypto/hashes/compare/sha2-v0.10.9...sha2-v0.11.0)

---
updated-dependencies:
- dependency-name: sha2
  dependency-version: 0.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-27 09:34:04 +00:00
dependabot[bot] 612e2aafe5 deps: bump resvg from 0.45.1 to 0.47.0
Bumps [resvg](https://github.com/linebender/resvg) from 0.45.1 to 0.47.0.
- [Release notes](https://github.com/linebender/resvg/releases)
- [Changelog](https://github.com/linebender/resvg/blob/main/CHANGELOG.md)
- [Commits](https://github.com/linebender/resvg/compare/v0.45.1...v0.47.0)

---
updated-dependencies:
- dependency-name: resvg
  dependency-version: 0.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-27 09:33:40 +00:00
dependabot[bot] 679ce82eea deps: bump the cargo-minor-patch group with 4 updates
Bumps the cargo-minor-patch group with 4 updates: [ignore](https://github.com/BurntSushi/ripgrep), [tokio](https://github.com/tokio-rs/tokio), [tray-icon](https://github.com/tauri-apps/tray-icon) and [libc](https://github.com/rust-lang/libc).


Updates `ignore` from 0.4.26 to 0.4.31
- [Release notes](https://github.com/BurntSushi/ripgrep/releases)
- [Changelog](https://github.com/BurntSushi/ripgrep/blob/master/CHANGELOG.md)
- [Commits](https://github.com/BurntSushi/ripgrep/compare/ignore-0.4.26...ignore-0.4.31)

Updates `tokio` from 1.53.0 to 1.53.1
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.53.0...tokio-1.53.1)

Updates `tray-icon` from 0.24.1 to 0.24.2
- [Release notes](https://github.com/tauri-apps/tray-icon/releases)
- [Changelog](https://github.com/tauri-apps/tray-icon/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/tray-icon/compare/tray-icon-v0.24.1...tray-icon-v0.24.2)

Updates `libc` from 0.2.186 to 0.2.189
- [Release notes](https://github.com/rust-lang/libc/releases)
- [Changelog](https://github.com/rust-lang/libc/blob/0.2.189/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/libc/compare/0.2.186...0.2.189)

---
updated-dependencies:
- dependency-name: ignore
  dependency-version: 0.4.31
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-patch
- dependency-name: tokio
  dependency-version: 1.53.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-patch
- dependency-name: tray-icon
  dependency-version: 0.24.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-patch
- dependency-name: libc
  dependency-version: 0.2.189
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-27 09:33:15 +00:00
yetone c69db5fa83 feat(theme): add One Dark Pro built-in theme
Add One Dark Pro as a ninth built-in, slotted alphabetically among the
dark themes: background #282c34, foreground #abb2bf, the classic One
Dark syntax palette for the normal ANSI slots and One Dark Pro's bright
variants for the bright ones.

Two seeds deliberately diverge from the VS Code theme's terminal set:

* The accent is the editor cursor/focus blue #528bff, not the syntax
  blue #61afef — the accent doubles as the switch's checked track, and
  #61afef sits at the same luminance as the #abb2bf knob (1.11:1).
* The normal red is the classic #e06c75, not the Pro terminal #e05561 —
  conditioned for AA the latter lands within 37 channel-distance of the
  orange-yellow #d18f52, under the 40 separability floor danger/warning
  must clear.

Also bumps the theme count in README and docs (eight → nine).
2026-07-27 17:18:37 +08:00
l0ng-ai e68fcdefb6 feat(editor): readline parity at the prompt — ctrl-p/n, ctrl-y, meta-. and shell fallthrough
The local command editor swallowed every Ctrl chord at the prompt, so
engaging shell integration silently removed keys that worked before it:
Ctrl-P/N history motions, Ctrl-T transpose, and any zle widget the user
had bound. Kills (Ctrl-W/U/K) had no yank to put them back.

- Ctrl-P / Ctrl-N are rewritten into the arrow keys up front, so the two
  spellings can't drift apart across history recall, multi-line steps,
  the completion picker and reverse search.
- Ctrl-Y yanks the last kill back; the kill chords now stash what they
  cut in a one-slot ring. Answered locally — zle's kill ring is a
  different buffer and would yank text this editor never cut.
- Alt-. is readline's yank-last-arg, walking back through history on
  repeat and swapping the word the previous press inserted.
- Unrecognized Ctrl and Meta chords hand the line to the shell instead
  of dying at the prompt, the same escape hatch Ctrl-R already used.
- Keys handled locally now snap the viewport back to the live prompt
  (#208): the editor returns early, so it never reached the raw key
  path's housekeeping and could edit a line off screen.
2026-07-27 16:59:46 +08:00
l0ng-ai 4aedb1faf7 feat(daemon): tell every pane which terminal it is running in
`TERM` names terminfo capabilities; it cannot answer "which program is
this". The de-facto standard pair that does — `TERM_PROGRAM` and
`TERM_PROGRAM_VERSION`, introduced by Apple Terminal and set by iTerm2,
WezTerm, Ghostty, VS Code and tmux — went unset, so anything asking was
told nothing.

Plenty asks. Capability probes (`supports-color`, `supports-hyperlinks`,
and the CLI ecosystem built on them) read the program name to decide on
truecolor and OSC 8; editors branch on it for terminal-specific
workarounds; shell prompts adapt their glyphs to it. Absent, they all
fall back to their most conservative behaviour. The `TTY7` marker we do
export is no substitute: it exists so globally-installed agent hooks
stay silent in other terminals, and nothing third-party looks for it.

Both new variables stay overridable through `env` in `config.json`,
unlike `TERM` and `COLORTERM`. Those two state what the pane's decoder
implements, which isn't the user's to contradict; the program name is an
identity, and posing as another terminal is a legitimate way to get a
tool that only recognises a fixed list to light up.

Building the pane's environment is now one function returning the pairs
in application order, so that precedence is testable without a
`CommandBuilder` or a real `config.json`.

Local panes only. ssh forwards environment variables solely by agreement
between client and server (`SendEnv`/`AcceptEnv`, `LANG` and `LC_*` by
default), so a native-SSH pane still sees whatever the remote host sets
for itself — as is already true of `COLORTERM` and `TTY7`.

Closes #212
2026-07-27 15:11:58 +08:00
l0ng-ai 0030b98faa chore(release): v26.7.5 v26.7.5 2026-07-27 14:00:22 +08:00
l0ng-ai fcdfbfbfec Merge pull request #216 from mingrath/test/thai-multi-mark-cluster
test(terminal): pin that a base keeps every stacked combining mark
2026-07-27 13:50:20 +08:00
l0ng-ai 2da33d2e0d Merge branch 'main' into test/thai-multi-mark-cluster 2026-07-27 13:50:00 +08:00
l0ng-ai de5758d917 Merge pull request #217 from l0ng-ai/feat/remote-path-completion
Complete remote paths over the pane's SSH connection
2026-07-27 13:49:16 +08:00
l0ng-ai ab8ac37fd7 Merge branch 'main' into feat/remote-path-completion 2026-07-27 13:48:56 +08:00
l0ng-ai 7574acd728 Merge pull request #215 from l0ng-ai/fix/config-bom
fix(config): don't let a UTF-8 BOM silently reset every setting
2026-07-27 13:48:30 +08:00
l0ng-ai 4bcc4066d7 feat(completion): complete remote paths over the pane's SSH connection
Tab at a path position in a native-SSH pane had nothing to offer: path
candidates come off the local filesystem, and a remote pane deliberately
passes no local cwd, so `complete` returned `None`. A no-match hands the
line to the shell (#136), which costs the user the inline editor until
the next prompt — so in practice every path Tab in an SSH session dropped
them back to the raw shell for the rest of that line.

Ask the far side instead. The pane already holds an authenticated russh
connection, and `SftpList` — the same request the SFTP panel browses with
— lists a remote directory over it. No shell involvement: nothing is
echoed into the scrollback, no prompt hooks need suppressing, and there
is no background process to cancel when the user submits.

The listing is a network round-trip, so it can't answer the keystroke
synchronously. Results land on the main thread and only then behave as a
local Tab would; a line that moved on in the meantime drops the answer,
and an empty one falls back to the shell handoff, so a directory we
cannot read is no worse off than before.

completion.rs stays pure: it splits the word into a request and turns the
answer into candidates, both unit-tested. Candidate rules mirror the
local path engine exactly — hidden entries need a dot prefix, `cd` takes
directories only, symlinks follow to their target's kind, same ordering
and cap. Positions a listing cannot help (command position, `~` without
the remote's $HOME, no absolute remote cwd) decline and let the shell
have the Tab.

Only native-SSH panes qualify: a foreground `ssh` typed into a local
shell and a WSL pane have no tty7-owned connection to ask.
2026-07-27 13:38:59 +08:00
l0ng-ai b139568ca3 fix(daemon): report every prompt-boundary crossing in one chunk
The OSC sniffer folded a whole chunk's 133 marks into a single shell
state and `apply_signals` sent one `Prompt` frame for it. That loses
information the client depends on: it counts `at_prompt` false -> true
edges as prompt *cycles*, which is how it tells a fresh prompt from a
same-prompt redraw (a prompt framework re-emitting the PS1-embedded
`133;B` on reset-prompt bumps the report seq but must not count).

A chunk carrying a whole command cycle (`C` ... `D`) therefore reported
only the final at-prompt state, and the edge vanished. Over SSH that is
routine rather than exotic: a fast command's `C`, its output and its `D`
leave the remote together and arrive in one read.

Report one state per crossing instead. Marks on the same side of the
boundary still fold into their latest state, so the ordinary `D`/`A`/`B`
prompt draw costs exactly one frame as before.
2026-07-27 13:38:47 +08:00
thomasandClaude Opus 5 aad0a49ef7 fix(config): don't let a UTF-8 BOM silently reset every setting
Every config-dir file is read by a loader that treats any parse error as
"there is no file" and falls back to defaults. serde_json rejects the
U+FEFF a BOM puts before the opening brace, so a BOM never surfaced as a
broken config — it surfaced as an absent one, and the app booted on
defaults with nothing to explain it.

Windows makes that easy to hit by accident: PowerShell's `>`, `Out-File`
and `Set-Content -Encoding utf8` all write a BOM, so editing config.json
from a shell was enough to lose every setting.

Strip a leading BOM in the three loaders whose files people hand-edit:
config.json, session.json (which dropped every workspace the same way),
and themes/*.yaml. read_to_string decodes the marker to one U+FEFF char,
so this strips the char, not the three raw bytes — and only the first
one, since a second is content the parser should still reject.

window.json and update.json are left alone: they are machine-written
state a relaunch rebuilds, never hand-edited.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD
2026-07-27 13:12:41 +08:00
mingrathandClaude Opus 5 28aa52de20 test(terminal): pin that a base keeps every stacked combining mark
The combining-mark cases carry exactly one mark — `e` + U+0301, and
❤ + U+FE0F. Thai routinely stacks two on one consonant: `ที่` is U+0E17
with an above-base vowel and a tone mark both sitting on it, and
Devanagari, Arabic and Hebrew stack the same way.

Nothing pinned that. Truncating the mark iterator to its first element
leaves all thirteen `segment_row` tests green — the cluster still forms,
still spans one column, and still leaves its neighbour intact. Only the
trailing codepoint goes missing, which in Thai is the one carrying the
tone: ไม่ (not) and ไม้ (wood) both collapse to ไม.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 11:42:15 +07:00
l0ng-ai d3064e40f5 Merge pull request #207 from l0ng-ai/fix/pane-cwd-from-process-table
fix(daemon): track a pane's cwd from the process table when OSC 7 is absent
2026-07-27 11:00:10 +08:00
l0ng-ai 29a51b7460 Merge pull request #210 from l0ng-ai/fix/emoji-presentation-width
fix(terminal): render emoji presentation sequences at their real width
2026-07-27 10:56:54 +08:00
thomas 60ecadd5b7 Merge remote-tracking branch 'origin/main' into fix/pane-cwd-from-process-table 2026-07-27 10:55:13 +08:00
l0ng-ai 5bfea2c095 Merge pull request #206 from l0ng-ai/feat/confirm-window-close
feat(settings): let the last-window close confirmation be turned off
2026-07-27 10:54:25 +08:00
thomasandClaude Opus 5 0cfbe90455 fix(terminal): only treat real combining marks as marks
`Cell::zerowidth` answers out of alacritty's one lazily boxed `extra`,
which also holds SGR 58 and OSC 8 — so a cell carrying nothing but an
underline color or a hyperlink reports `Some(&[])`, not `None`.

`snapshot_cell` mapped that straight into `RenderCell::marks`, and an
empty list is still `Some`: every SGR-58 or OSC-8 cell then failed
`is_blank`, broke the run it sat in, and took a `RowSeg::Cluster` of its
own — one `shape_line` per character for a whole `ls --hyperlink`
listing, with the powerline fast path skipped on the way past.

Filter the empties at the source, where the `Option` is built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD
2026-07-27 10:52:23 +08:00
thomasandClaude Opus 5 64744fce61 test(config): pin that an unknown key can't silently re-enable the prompt
`Config::load` turns any parse error into *defaults*, so a config that
fails to deserialize doesn't fall back field-by-field — it comes back with
`confirm_window_close: true` and nothing said. The struct has no
`deny_unknown_fields` today; this pins that, since the opt-out is exactly
the setting whose silent reversal nobody would notice.

Also index the Windows/Linux spelling of the chord: the prompt is reached
by Ctrl-W off macOS, and search only knew "cmd-w".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD
2026-07-27 10:50:16 +08:00
thomasandClaude Opus 5 6e98f55981 fix(daemon): don't hand a new tab a Linux cwd the kernel marked deleted
`/proc/<pid>/cwd` still resolves after the directory is unlinked, and the
kernel spells that `<path> (deleted)` — a label, not a path. That was
harmless while the reading only backfilled `List` for a pane with no cwd
at all; now that the poll broadcasts it, one `rm -rf` from another pane
(or a `git clean`) leaves every new tab, split, git probe and path
completion pointed at a directory that cannot be opened, and the pane's
last good directory has already been overwritten.

Treat an unstat-able reading as no reading, so the foreground group's
answer is discarded and the shell pid gets its turn — the same shape the
rest of the chain already uses for a pid it cannot inspect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD
2026-07-27 10:50:02 +08:00
thomasandClaude Opus 5 714bf23f9e test(daemon): stop the symlink cwd test from failing on an ordinary Windows box
`probed_cwd_keeps_the_shells_spelling_for_a_symlinked_path` built its
fixture with `std::os::windows::fs::symlink_dir`, which needs
`SeCreateSymbolicLinkPrivilege` — a privilege an unelevated shell without
Developer Mode does not hold. The test therefore failed on any normal
Windows checkout with `ERROR_PRIVILEGE_NOT_HELD` (os error 1314) before
reaching a single assertion, observed here on Windows 11.

Nothing is lost by making it `cfg(unix)`: `foreground_cwd` answers `None`
off macOS/Linux, so no Windows pane ever reaches that comparison. What
every platform *does* run — a reading identical to the one already
reported, resolved with no filesystem access at all — gets its own test
instead, because a re-send there would push a `Cwd` frame and the git
probe behind it twice a second for the life of an idle pane.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD
2026-07-27 10:49:46 +08:00
thomas e07fc3703e Merge remote-tracking branch 'origin/main' into fix/emoji-presentation-width 2026-07-27 10:41:39 +08:00
thomas 88d21d19ed Merge remote-tracking branch 'origin/main' into feat/confirm-window-close
# Conflicts:
#	src/ui/settings.rs
2026-07-27 10:41:06 +08:00
thomas dc8faec5d9 Merge remote-tracking branch 'origin/main' into fix/pane-cwd-from-process-table
# Conflicts:
#	CHANGELOG.md
2026-07-27 10:40:53 +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
l0ng-ai c5a26a7794 fix(daemon): track a pane's cwd from the process table when OSC 7 is absent
A pane learned its working directory from OSC 7 alone, which only shells
tty7 manages to inject its integration into ever emit. A shell that execs
into another one from its rc file (`exec fish` at the end of .zshrc), a
nested shell started by hand, or any shell with no integration at all
emits none — and since a pane's cwd is seeded with its spawn directory,
such a pane does not report *no* directory, it reports a permanently
stale one. New tabs and splits, the git probe and path completion all
follow it to the wrong place, with nothing on screen to say why.

Read the cwd from the process table too, on the same half-second
foreground poll that already detects SSH sessions and coding agents, and
reconcile it with what the pane reports:

- A remote pane is left alone. The local process table can only see the
  ssh client's own directory, which is the confusion apply_remote_context
  clears the cwd to avoid in the first place.
- No reading is "nothing to read", never "no cwd", so it cannot clear one.
- When both name the same directory the shell's spelling wins. $PWD keeps
  the symlinked route the user walked in through, and that is the path a
  new tab should open in; only a genuine disagreement moves the pane.

The three platform cwd readers move from DaemonPane methods to free
functions so the reader thread can call them; the method stays as a thin
delegate for List.

Fixes #187
2026-07-27 10:20:06 +08:00
l0ng-ai f90dfd7c35 Merge pull request #205 from l0ng-ai/feat/theme-interaction-state
feat(theme): derive interaction state and status colors from the theme
2026-07-27 09:53:11 +08:00
l0ng-ai 7731eec5f7 Merge pull request #204 from l0ng-ai/fix/stale-hover-row-crash
fix(terminal): stop a stale hover cell from indexing a shrunken grid
2026-07-27 09:50:50 +08:00
thomas 6f91c9a640 Merge remote-tracking branch 'origin/main' into feat/theme-interaction-state 2026-07-27 09:48:20 +08:00
thomas 28d451ec2c Merge remote-tracking branch 'origin/main' into fix/stale-hover-row-crash 2026-07-27 09:46:09 +08:00
l0ng-ai b3bd61e867 feat(settings): let the last-window close confirmation be turned off
Closing the last window pops "Close Window?" every time. The prompt was
only ever teaching, not protection — Cmd-Q, the tray's Quit and the
palette's Quit all leave without asking, and nothing is lost either way
since the panes keep running in the daemon. Once the user knows that,
being asked on every quit is friction.

Adds `confirm_window_close` (default true, so nothing changes for
existing configs) and a Window & Tabs toggle. Off makes the last window
close like any other: detach the workspace, quit.
2026-07-27 09:44:36 +08:00
thomasandClaude Opus 5 dddd07a360 fix(ui): give the segmented control the surface it actually paints on
`segmented` paints its own opaque track — deliberately, since every rung
above it was derived against that ground. But it hardcoded the window
surface, and one of its sixteen call sites is the managed-forward form in
the right panel, which is a sunk `sidebar` rail: the track landed as a
faintly darker box cut out of the column around it, and its hover /
selected / label rungs were measured against a ground that was not there.

Split off `segmented_on`, which takes the `Surface`; `segmented` keeps the
window default the settings sheet wants.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD
2026-07-27 09:42:02 +08:00
thomasandClaude Opus 5 d3549bce23 test(terminal): pin the geometry the resize test starts from
The new hover-invalidation test read 24 rows into the pane because the
harness names that size, but the test window lays itself out first and
`set_grid_size` had already moved the terminal to the measured geometry
— so the "same size changes nothing" call was a size *change*, and it
cleared the cell the assert expected. Set the starting geometry from the
test instead of inheriting whatever the window measured. Unix-only
module, so this only shows up in CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD
2026-07-27 09:41:54 +08:00
l0ng-ai 0d0acb5828 Merge pull request #202 from l0ng-ai/feat/windows-titlebar-anchor
feat(chrome): make the title bar's line whole off macOS
2026-07-27 09:40:07 +08:00
thomasandClaude Opus 5 4107829bf8 fix(theme): finish the sweep — plain-button states and midtone grounds
Two gaps in the interaction-state change.

`Theme::input` reaches the *outline* button path, which reads the field
live, but not the plain one: gpui-component derives `tokens.button_hover`
and `tokens.button_active` from `input` once, inside the `apply_config`
that `Theme::change` runs — from the stock `#2f2f2f`, before any of
`apply_theme`'s overrides exist. A snapshot never sees the fix, so a plain
`Button` still hovered and pressed in that grey and `Button::selected`
(the terminal search bar's `Aa` / `.*` toggles, the last two left in the
app) filled from `tokens.secondary_active` the same way — ~1.03:1 on
Dracula, i.e. issue #197 again, one snapshot removed from the field that
fixed it. Point those state rungs at the window ladder. `tokens.button`,
the resting fill, is deliberately left alone.

`legible_ink` picked its extreme with `is_dark`, whose 0.5 luminance
threshold is the wrong question: on a midtone ground (luminance
0.18…0.5) it says "dark" while black outreaches white, so a status seed
was driven toward white and clamped there *below* the floor with its hue
gone — precisely when a user most needs to tell an error from a warning.
Take the reachable extreme instead, as `legible_foreground` already does.
Every built-in sits far enough from the midpoint that this picks what
`is_dark` did, so the derived palettes are unchanged; only an imported
scheme could reach the divergent range.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD
2026-07-27 09:39:26 +08:00
thomasandClaude Opus 5 b229fd18f0 fix(terminal): refuse a stale row in smart select, and drop the stale link
Two gaps left by the hover fix, both the same crash class.

`grid_smart_range` indexes `grid[click.line]` the moment it starts —
`hyperlink_run`, then `logical_line_at` — with the row a double-click
carried in from the frame that dispatched it. That row can outlive its
grid the same way a hovered cell can: a split, a window drag, or the
reader thread applying the daemon's replayed attach geometry between
paints. `Grid`'s `Index<Line>` only `debug_assert`s the bound, so a
release build walks off the storage, and the click arrives in a gpui
`extern "C"` callback where the panic aborts instead of unwinding.
Guard the row up front, exactly as `TerminalView::grid_line` does.

`set_grid_size` forgot the hovered *cell* but kept the link it had
resolved. That link is stored in grid coordinates, so after the reflow
it underlines whatever text now sits at those coordinates and holds the
pointing-hand cursor over it until the pointer moves again — clamping's
wrong-but-silent hover, one step removed. Drop it with the cell.

Both halves get a regression test; the smart-select one panics on the
storage `debug_assert` without the guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD
2026-07-27 09:35:26 +08:00
thomasandClaude Opus 5 c20c4e8c66 fix(chrome): zoom on Linux when a stand-in title bar is double-clicked
`title_bar_drag` reached for `window.titlebar_double_click()`, which gpui
only implements on macOS — the platform trait method is an empty default
everywhere else. On Windows that costs nothing (the row is a drag area,
so it is HTCAPTION and the OS has already toggled the window before the
app sees anything), but on Linux the row swallowed the double-click and
nothing happened, on all three rows the helper drives: the rail's top
zone and the code and diff overlays' headers.

`zoom_window()` is the maximise toggle there, and what gpui-component's
own `TitleBar` calls on Linux for exactly this reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD
2026-07-27 09:35:23 +08:00
l0ng-ai dcb8ba0c77 feat(theme): derive interaction state and status colors from the theme
Selected options were indistinguishable from their neighbours on every
bundled theme, worst on Dracula where a segmented control's active
segment measured 1.03:1 against its siblings (issue #197). The cause was
not Dracula: the app had no state model. Interaction state was a set of
fixed `mix(bg, fg, t)` ratios scattered across `neutrals()` and
`.opacity()` multipliers at call sites, and the fields nobody had
noticed — `input`, `ring`, `switch`, `switch_thumb`, `danger`,
`warning`, `success`, `link` — silently kept gpui-component's stock
values, greys and Tailwind hues with no relationship to the active
theme.

Introduce the state model the app was missing.

`Surface` is the interaction-state ladder for one painting surface, and
every rung is derived to hit a *contrast ratio* against that surface
rather than a fixed blend ratio. A fixed blend makes the perceived step
depend on the seed: selected-vs-resting ranged from 1.20:1 (Catppuccin
Latte) to 1.47:1 (Dracula). Every theme now lands on 1.70-1.72:1. The
target is 1.70 because that is where the already-signed-off Dracula
highlight sits, so the change is a no-op on the theme the look was tuned
against and a lift for the rest.

Ladders are per surface. Menu rows paint on `popover`, not the window
background, which is why the context-menu highlight measured 1.20:1
against the panel it actually sat on while nominally being the fill that
reads fine on the terminal ground.

A fill alone never communicates selection, so `Surface` carries the
label colours too: take a surface, take both channels. The app had
already learned this three times locally — the tab chip, the chrome
tiles and the sidebar each grew a hand-written fill-plus-text pair —
while every site that had not been hand-fixed shipped a fill and nothing
else. `ink_on` keeps a selected label readable on its own fill; without
it, raising a fill toward the foreground drags the label under the
resting ones around it (4.14:1 on Latte).

`Semantics` derives danger/warning/success/info/link from each theme's
own ANSI-16 instead of stock Tailwind, so a danger marker and an error
line of shell output are finally the same red. Conditioning drives a
seed toward black or white rather than toward the foreground: routing
through Rosé Pine Dawn's purple-grey foreground collapsed its red and
yellow into two indistinguishable mauves.

Switch toggles read inverted on every dark theme because the knob fell
back through two unset fields to a stock near-black. The knob now takes
the light end of the theme's axis, the unchecked track comes off the
ladder, and the checked track takes the accent — hue is the only axis
left once the knob has claimed lightness, which is why every system
switch is coloured.

`segmented` is hand-rolled. The stock `ButtonGroup::outline()` path gave
the selected segment the same border and the same label colour as its
siblings, leaving one fill as the entire signal, and exposes no way to
vary weight.

Nine tests pin the properties rather than the current taste, so retuning
the four contrast constants does not force a test edit but abandoning
the model does.
2026-07-27 09:07:29 +08:00
l0ng-ai c31826ea57 test(crash): match the panic location without a path separator
`panic!` records the location with the platform's own separator, so the
assertion on `src/core/crash.rs` failed the Windows job while the hook
itself worked fine there.
2026-07-27 08:35:48 +08:00
l0ng-ai 6edfb28200 fix(terminal): stop a stale hover cell from indexing a shrunken grid
Hovering a pane records the cell under the pointer so ⌘-hover can
underline links. Nothing invalidated that cell when the grid shrank
under it — a vertical split, un-maximizing a pane, a smaller window —
so the remembered row could name a line the grid no longer has.

The next modifier press then walked into it: ModifiersChanged →
set_link_modifier → every pane in every tab → link_span_at, which
indexed the grid with the stale row. Grid's Index<Line> only
debug_asserts the bound, so a release build underflows the storage
index and panics on the slice check. That panic happens inside gpui's
extern "C" input callback, where it cannot unwind — the process
aborts, and the OS crash report keeps the abort rather than the panic.

Validate the row against the grid before indexing (one choke point,
shared by hover and click), and drop the remembered cell on resize.

Two more ways the same class of crash reached users:

- `new_terminal` unwrapped a failed daemon spawn with `expect`, so a
  daemon that died took the app down with the next ⌘D/⌘T instead of
  reporting the failure. It returns a Result now; split, new tab,
  worktree tab and reopen-closed-tab surface a notification, and
  session restore drops the leaves it can't rebuild.
- A spawn that fails because nothing is listening on the socket now
  starts a daemon and retries once, the way a disconnect mid-Spawn
  already did. `connect()` had to stop interpolating the io::Error
  into a string for that classification to see it.

And so the next one is diagnosable at all: a panic hook writes the
message, location and backtrace to `crash.log` in the config dir. The
GUI has no logger and no stderr when launched from Finder, and the
crash report loses the message to the abort.

Fixes #201
2026-07-27 08:21:36 +08:00
thomasandClaude Opus 5 a67cf2b2ad feat(chrome): make the title bar's line whole off macOS
macOS fills the window's leading corner with the traffic lights and
`TITLE_BAR_LEAD` reserves them 80px. Everywhere else that corner held
nothing: the caption row's only contents are the rail's "+" and collapse
at the rail's right edge and the corner chrome at the window's, so the
left third of the row read as unfinished rather than restrained — while
Windows treats the top-left as the app's identity slot.

Three parts, all of them about that row:

- `window_mark()` draws the "duo" mark (the app icon's own art) at the
  head of the rail on `CONTENT_INSET`, the line the search box and every
  row label below it start on, and follows the rail's controls into the
  title strip when the sidebar collapses. It is drawn, never clicked: no
  hover capsule, and deliberately no `occlude()`, so the drag region
  underneath still takes the press and the strip stays grabbable.

- The rail's stand-in row now reserves the same hairline the real
  `TitleBar` draws inside its own height. Without it the bar centred
  content on 19.5 and the rail on 20, and the mark hopped half a pixel
  as collapsing the rail handed it from one to the other.

- With the detail panel open off macOS the bar is hoisted above
  `[terminal | panel]` so the window controls can reach the corner, which
  left the code and diff overlays — anchored to the terminal column —
  starting 40px down, with headers drawn to *be* the title bar landing a
  row low. They now hang on the row that owns the bar, inset by the
  panel's width. Covering the caption row that way needs the headers to
  carry its gestures, which neither ever did with the panel open or
  closed: `title_bar_drag()` gives both (and the rail's row, which grew
  the same wiring by hand) drag-to-move and double-click-to-zoom, and
  their controls are `occlude()`d so HTCAPTION stops eating the clicks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD
2026-07-26 23:08:18 +08:00
l0ng-ai 313c1c3c8b Merge pull request #195 from l0ng-ai/fix/windows-font-fallbacks
fix(fonts): name a CJK and emoji fallback the host platform actually ships
2026-07-26 21:03:33 +08:00
l0ng-ai 8bdcaf4290 fix(fonts): name a CJK and emoji fallback the host platform actually ships
The default `font_fallbacks` list was macOS-only -- Menlo, Hasklug Nerd
Font Mono, Maple Mono NF CN, Apple Color Emoji. Fallbacks resolve by
family name against installed fonts, so off macOS the whole chain matched
nothing and every glyph the primary lacked was left to the platform's own
cascade. Bundled Hack maps 1548 codepoints and zero ideographs, so on
Windows that was every Chinese character in every pane, and every emoji.

The fall-through is not only a matter of which face you get. `element.rs`
pins each wide cell to `2 x cell_width`, and Hack advances 0.60205em, so
a two-column slot is 1.2041em -- while every stock CJK face (Microsoft
YaHei, PingFang SC, Noto Sans CJK) advances 1.0em. `force_width`
left-aligns, so the ideograph hugs the left of its slot and the remaining
0.2em shows up as a gap on the right of every character. Measured on
Windows at font_size 15: left bearing 1.49px, right bearing 4.90px.

Branch the defaults per platform, keeping Maple Mono NF CN first
everywhere -- 0.6em Latin, 1.2em CJK, the one exact two-cell fit against
Hack (bearings 3.06px / 3.62px, ink centered). It stays referenced by
name only, never bundled, at ~20MB per weight.

Changing `Config::default` alone would reach nobody who already has a
`config.json`, which is every existing user. So `fallback_chain` appends
the platform's stock faces the same way it already pins Hack: a fallback
is consulted only after everything ahead of it has missed, so appending
can never displace a face the user chose, and the file is never rewritten.

Verified by driving two builds against one config naming only absent
macOS faces: before, the CJK line differed from an explicit Microsoft
YaHei chain by 3571 pixels (the cascade picked something else); after, it
is pixel-identical.
2026-07-26 20:59:11 +08:00
l0ng-ai 1504f92896 Merge pull request #193 from l0ng-ai/feat/detail-panel-scrollbar
feat(ui): give the right panel and tab rail an overlay scrollbar
2026-07-26 20:58:55 +08:00