Windows environment blocks are case-insensitive: portable-pty's
CommandBuilder keeps one slot per lowercased key, so a configured
`Term`/`ColorTerm` in `env` would land in the same slot as
`TERM`/`COLORTERM` and, coming later, replace it -- sidestepping the
rule that user env may rename the terminal but not contradict what the
pane's decoder implements. Filter capability keys with the platform's
own notion of "the same variable": case-insensitive on Windows, exact
elsewhere (where a differently-cased key is a genuinely distinct
variable and stays the user's to set). Pinned by a Windows-only test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
native_cell_residue was inserted between seg_clip_width's doc block and
the function itself, so rustdoc attached the clip-width prose to the
residue helper and left seg_clip_width undocumented. Move the helper
(with its own doc) above the block instead. No code change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The render call site still said "show focus rings only when split" -- the
same misdescription the parameter rename in this PR removes: nothing ever
drew a ring, the flag only gated the fade, and the fade condition is now
spelled out two lines below.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two follow-ups to #229.
Underlines ride on the `TextRun` that `paint_glyphs` builds, and the Solo
arm returned early for every natively-drawn cell — so an `ESC[4m` span or a
hovered URL showed a one-column hole wherever it crossed a box-drawing
character. The mechanism predates #229 (the Powerline branch has always had
it), but #229 widened it from a dozen private-use separators to all 256
characters of U+2500–U+259F. Such a cell now shapes a space in its own
style instead of returning, so gpui draws the line from the same
`UnderlineStyle` — curly and double included — that every other cell uses.
Stroke weight varied between cells at fractional device scale. `rectb`
snaps a rect's two edges independently, which is what makes neighbouring
cells tile, but two edges `w` apart land `w × scale` device pixels apart:
when that is not a whole number the two roundings straddle it, so a
1-logical-pixel rule came out 1 device pixel wide in one column and 2 in
the next. At Windows' default 125%/150% scaling that alternated thin/thick
across every column of a TUI table, and down every row for horizontal
rules. Integer scales are blind to it by construction, which is why 1x and
2x looked right.
`light_thickness` now quantises to whole device pixels, and `vstroke` /
`hstroke` lay that width off from the snapped near edge rather than
inferring it from a second snap — float ties at `.5` made the quantisation
alone insufficient. Stroke ends still snap, so #229's tiling guarantee is
untouched. Block elements stay on `rectb`: they are area fills, not
strokes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
#214 added the switch itself; this is the wiring around it that a new
setting in this codebase is expected to carry.
- Index the row in `settings_search_entries`, which the settings search
box matches against. Without an entry, searching "dim", "fade" or
"unfocused" — the words someone actually looks for — finds nothing,
and the switch is only reachable by scrolling to it. Pinned in
`index_titles_match_rendered_row_labels` so the title cannot drift.
- Pin the default and the round trip, as every other `default_true`
flag here does (see `confirm_window_close_defaults_on_and_round_trips`):
a config written before the switch existed must still dim, and a
`false` must survive save/load or the effect comes back next launch.
- Hand the flag to `Pane::render` instead of reading the `Config` global
from inside it. `pane.rs` had no global state before, deliberately —
the leaf type is generic so the tree logic can be tested with plain
values. The caller already computes the split test the dimming was
gated on, so it can compute this too: one lookup per frame rather than
one per leaf, and the tree stays renderable without a Config global.
While there, `show_focus` is now named for what it does — nothing
drew a focus ring; it only ever gated the fade.
- Move the row below "Follow theme". That button clears the opacity and
blur overrides only, and a third row directly above it read as
something it would also reset.
- Changelog entry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The polyline arc band couldn't match kitty's corners on two counts,
both dictated by how gpui rasterizes paths:
* Separately painted segments composite as premultiplied sprites, so
two abutting antialiased edges meet at 75% opacity — a lighter seam
at every joint. All contours now ride in ONE Path (via move_to),
where the 4x-MSAA samples partition cleanly across shared edges.
* Straight path edges only get 4-level MSAA antialiasing, while
`curve_to` quadratics are antialiased analytically (Loop–Blinn
signed distance) — a continuous ramp, the same quality kitty gets
from supersampling. The outer arc edge is now a real quadratic per
30° slice (control point at the tangents' intersection).
The inner edge stays a fine polyline: gpui accumulates coverage with
no winding cancellation, so a concave-side curve bulge can only
over-cover (the origin of the original quarter-disc blob). Its chord
error at 7.5° steps is under 0.1px and hides inside the MSAA. Each 30°
contour is star-shaped from its start vertex, which is what the fan
fill actually requires.
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.
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.
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>
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).
`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
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.
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.
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
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>
`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
`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
`/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
`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