`EditBuffer.measureText(text)` returns the editor offset at the end of a
text: its cells under the buffer's width method, tab width, and control
notation, plus one per line break. The result depends only on the text
and those three settings, but an app needed a live native `EditBuffer`
to get it. opencode passed a measure closure into otherwise pure helpers,
kept a spare detached `EditBuffer` for when no textarea was mounted, and
built a renderer and a textarea in tests only to measure strings.
`measureEditText(text, { widthMethod, tabWidth })` returns what
`measureText()` and the end offset after `setText()` give on an edit
buffer with those settings, without a buffer or an owner. `tabWidth`
defaults to the tab width of a new edit buffer and rounds as
`setTabWidth()` does. It calls the new `ot_edit_measure_text(context,
bytes, byte_count, width_method, tab_width, out_width)`. That entry point
checks the width method as `ot_unicode_create` does, rejects a tab width
over 255 and rounds it as `ot_edit_buffer_set_tab_width` does, applies
the byte limit and UTF-8 check of `set_text`, and leaves the output
unchanged on failure. Tab width 0 rounds to the tab width of a new
buffer, so TypeScript passes 0 for the default and keeps no second copy
of it.
Both entry points share one native function, `Context.measureEditText`.
It adds the width of each line between LF, CR, and CRLF, as
`textToSegments` splits them, and one per break. It does not build
segments, so neither entry point allocates any more. `validateTextBytes`
takes the tab width instead of a buffer.
Each native call needs a Context, so the library owns one for these
measures: `FFIRenderLib` creates it on the first call and releases it in
`dispose()`, as it does for audio engines. A module-level
`ResourceContext` would be smaller, but `dispose()` refuses while any
other Context is live, so one measure would make the public
`resolveRenderLib().dispose()` fail with no way to release that Context.
No test or tool otherwise checks for leaked Contexts. Creating the
Context and measuring need no Yoga mutation and use read admission, so a
measure works in input handlers, during render, and in a layout
callback, as `EditBuffer.measureText()` does.
A differential table in `edit-buffer.test.ts` runs under Bun and Node.
For every public width method and the tab widths default, 0, 1, 3, 8,
and 255, it measures C0 controls, DEL, C1, tabs, CJK with a combining
mark, ZWJ, skin-tone, and flag emoji, CR, LF, CRLF, mixed lines, a CRLF
across the 16-byte blocks of `findLineBreaks` before a long line, and the
empty string. `measureEditText()`, `measureText()`, and the end offset
after `setText()` agree on each. New rows in the native malformed-argument
table cover a null span, malformed UTF-8, an unknown width method, tab
width 256, a null output, and one byte over the limit; one byte less
measures the widest tabs within u32. The dispose test measures first in
a Yoga callback, keeps the measure Context through a refused dispose,
and releases it in the next. Hidden controls, CRLF as two breaks, LF-only
breaks, no break count, no tab rounding, a wrong default, each removed
argument check, write admission, an `assertMutable()` guard, and each
dispose change make a test fail.
The native renderer writes each changed run of cells as a cursor move,
its colors, its attributes, and the text, and writes `ESC[0m` when an
unchanged cell or a style change ends the run. A run that ended in a
row's last column got no reset. The next run, on a later row, sets only
its colors, so a bold, dim, italic, underlined, blinking, inverse,
hidden, or struck-through cell at the right edge leaked into the first
cells drawn after it. @FasterPHP reported this with exact bytes in
JetBrains and Zed terminals; any terminal behaves this way, because the
renderer only turns attributes on and relies on the reset to turn them
off.
The frame diff now closes the open run after each row when that run set
an attribute. A run always writes both colors, so a run without
attributes cannot leak and plain frames keep their size; a row that ends
in an attributed run costs 4 bytes more. This also covers a wide glyph
whose continuation fills the last column, rows skipped because they did
not change, and forced full draws. The `runStart` state always matched
`runLength == 0`, so it is gone. The split-footer and scrollback writer
already reset after every row and is unchanged.
A table test feeds the renderer's output into Ghostty's VT and checks
that the next run's cell has no attributes, for each attribute, a run
ending before the last column, in it, or with a wide glyph that fills
it,
a next run on the next row or after an unchanged row, and diff and
forced frames. It fails without the reset, with a reset keyed on the
last printed column, and with a reset only in diff frames.
Fixes#1633
`parseKeypress` decoded a single byte above 127 (the legacy 8-bit meta
encoding) by subtracting 128 from the caller's Buffer in place. Parsing
the same Buffer again returned the key without meta, and a caller that
logs, retries, or forwards the raw bytes saw changed data. The renderer
was not affected, because the stdin parser passes a fresh one-byte
Buffer, so only direct callers of the exported function saw it.
It now decodes the byte with `String.fromCharCode(byte - 128)` and
leaves
the Buffer alone. The returned key is the same as before for every
input; no other code in `parseKeypress` or its callers writes into an
input Buffer.
The Buffer tests in `parse.keypress.test.ts` are now one table that
checks the key and that the Buffer is byte for byte unchanged after the
call: ASCII, a two-byte character, 127, 128, the high-bit meta bytes
160, 225, and 255, a Kitty sequence, and an SGR mouse report. Restoring
the in-place write, decoding the wrong byte, or moving the bounds of the
single-byte check each fail it.
Fixes#1638
When two inserts put contiguous text in the editor's rope, the join gave
the merged chunk the sum of the two widths. A later part can continue
the grapheme that the first part ends with: a skin-tone modifier, a ZWJ
sequence, Hangul jamo, U+FE0F. Terminals send such an emoji as one
chunk and the key parser turns it into one key per code point, so
typing 👍🏽 in a Textarea stored 4 cells for a 2-cell emoji. The cursor
landed at column 4, the line was 2 cells wider than drawn, it wrapped
early, a selection by columns ended one character short, and Right
could not get past the emoji.
The rope now merges two chunks only through a merge callback that the
edit buffer passes with its split callback. The callback measures the
joined bytes with the buffer's tab width, width method, and control
display, the same as layout and `setText`. Rope operations without the
callback no longer merge; none of their callers joins contiguous text.
`insertText` and an empty-range replacement take the cursor column from
the rope weight, because a merge changes the width of the text before
the insert.
A differential table in `edit-buffer_test.zig` types each row in parts
and compares it with the same text set at once, for every width method:
the text, the width of every chunk against its bytes, the cursor after
typing, and every Right stop. Rows cover skin tones, ZWJ sequences,
regional indicators, Hangul, VS16, combining marks, Thai, a control,
line breaks, and merges before existing text and on a later line. It
replaces the segment-merge example tests that it covers. Summing the
widths again, measuring with hidden controls, dropping the merge, or
moving the cursor column or offset by one in either insert path each
makes a row fail.
Fixes#1644
When the host terminal speaks the Kitty keyboard protocol, the embedded
terminal sent no bytes for keypad Enter, the
keypad navigation keys (keypad arrows, Home, End, Page Up and Down,
Insert, Delete), Caps Lock, Scroll Lock, Num
Lock, Print Screen, Pause, and the left and right Shift, Ctrl, Alt and
Super keys. The printable keypad keys reached
the child only as text, so a Kitty child could not tell them from the
main keys, and Ctrl+keypad 0 arrived as a
bare `0`.
The parser reports these keys with Kitty-only names (`kpleft`,
`capslock`, `leftshift`, ...), and `physicalKey` had
no W3C code for them, so Ghostty's encoder got no key. It now maps the
41 names that Ghostty can encode. Ghostty then
encodes each key for the child's modes: keypad Left is `CSI D` to a
legacy child and `CSI 57417 u` to a Kitty child,
keypad 0 is still `0` to a legacy child and `CSI 57399 ; ; 48 u` to a
child that asks for key text, and a child in
application keypad mode with mode 1035 reset gets `ESC O p`, as in
xterm.
Some keys still give no bytes because Ghostty has no key or no Kitty
encoding for them: the media and volume keys,
Hyper, Kitty's Meta, the ISO level shifts, F26 to F35, and Menu.
A sweep sends all 111 Kitty functional keys through the parser into a
Kitty child, parses the child's bytes back,
and checks that only those 30 keys fail to come back with the same name.
Rows in the `preserves` table give exact
bytes for modified keypad keys, keypad Enter and navigation in legacy
mode, and lock and modifier keys in legacy and
Kitty modes. Deleting the keypad digit line, moving either end of its
range, and each of about twenty tried
deletions, swaps or wrong codes in the table makes at least one test
fail.
Fixes#1645
An embedded terminal did not redraw rows already on screen when the
child
changed the default foreground or background (OSC 10/11, reset with OSC
110/111) or toggled reverse video (`CSI ? 5 h` / `l`). Those rows kept
the
old colors until the child wrote them again or the host called
`invalidate()`, so a visual bell that flashes reverse video did not
show,
and since default colors are now always set, a bell that coincides with
output could leave the screen reversed.
Ghostty's library changes these colors and the reverse-video mode
without
marking any row dirty, and the compositor redraws only dirty rows.
Compose
now compares the render state's default foreground and background before
and after the update and does a full redraw when either one changes.
Ghostty applies reverse video to these two colors, so one comparison
covers
every trigger, in opaque and transparent-background mode. Palette
changes
(OSC 4) and RIS already force a full redraw. A compose with no color
change
does no extra work.
Tests: the embedded terminal sentinel test gains a table of writes (OSC
11,
OSC 10, reverse video on and off, OSC 110, OSC 111, OSC 4, RIS, and two
writes that change no color) that checks the composed colors without
`invalidate()` and whether every row was redrawn. Reverting the fix,
dropping either color comparison, forcing a full redraw on every
compose,
or promoting a partial redraw to a full one fails the table.
Fixes#1647
A combining mark in another rope chunk than its base glyph was not
drawn. Edits, `TextBuffer.append`, and streamed text split chunks, so
typing U+0301 after `abc` in an editor showed `abc`, while the text held
`abc\u0301`. A chunk of only marks has no columns, and the draw loop
stopped at the chunk's last column, so it never drew the mark. A mark
that starts a chunk with more text was dropped when its base was in the
last column of the target, the viewport, or the scissor, because those
checks ran on the mark's column.
The draw loop now walks the chunk's bytes, and a zero-width cluster
joins the glyph this draw wrote before it as soon as it is read. That
glyph already passed the edge and scissor checks. The skip for chunks
past the right edge waits while such a glyph can still take marks. A
mark with no drawn glyph before it is still dropped: at line start,
after control notation, after a base that is scrolled out or clipped,
and after a truncation ellipsis, which now never takes a mark. A
truncated line's prefix keeps a mark-only chunk that follows its last
glyph. A mark that would change the cluster's width, such as U+FE0F
after a 1-cell glyph, is dropped instead of putting a 2-cell emoji in a
1-cell slot.
The test that attaches marks is now a table. Each row draws split chunks
and the same text in one chunk, through `TextBuffer.append` (controls
hidden) and through editor inserts (controls shown as notation). Rows
vary the x offset, viewport, scissor, wrap, truncation, selection, and
opacity, and name the cell that must hold the mark. Each of eleven
plausible breaks of the change (column loop, chunk skip, attach order,
base reset, ellipsis, prefix, width check) fails a row. In random tests,
split chunks now draw the same as one chunk in every case except joiners
(ZWJ, U+FE0F that widens a glyph), word wrap, and one truncation prefix
shape that main also has.
Fixes#1646
Version 2 of JS Buffer/draw-box-titled-scissored took a checked buffer
lease on every iteration to observe one cell. The timed operation
became drawBox plus three FFI calls and a new external ArrayBuffer.
On Bun the lease was about half of each operation. On Node it made
batch times vary by 5% to 9% and failed the 5% inner RSD gate.
Version 3 times only drawBox. Each batch checks the last drawn
variant once, outside the timed loop, then clears the signal cell so
a batch without draws cannot pass on an earlier result.
Keep controls in editor text and render them as safe, visible notation.
This lets the cursor, selection, wrapping, and offsets handle controls
consistently without sending them to the terminal. Strip terminal escape
sequences from pasted text so pasted controls cannot change terminal
state.
Alternative to #1632 and aligned with how vim does it.
We accidentally in pre 0.6 and explicit in 0.6.x rejects edit text that
contains a C0 control other than tab, carriage return, and line feed,
DEL, or a C1 control: `setText()`, `insertText()`, `replaceText()`,
Textarea `initialValue`, and `Input.value` throw `NativeError` with
`InvalidArgument`. The reason is that the editor places its cursor by
display cell, and a control took no cells, so the cursor could neither
move past it nor delete it. In 0.5.14, which accepted controls, the
cursor in `"a\fb"` could not reach column 0, Backspace stopped at
`"a\f"`, and rendering `"A\x07B\x1bC\fD"` wrote raw BEL and ESC bytes to
the terminal while `C` and `D` vanished.
This is an alternative to #1632, which drops these characters. Instead,
editors keep them and show them the way Vim and Neovim do. Measured with
Neovim 0.12.5 and Vim 9.2, both show `a^Lb^@c^[d<85>e^?f`: a C0 control
or DEL as caret notation in 2 cells, and a C1 control as hex in 4 cells.
The cursor stops on each one, `x` deletes one, and writing the file back
gives identical bytes.
Editors now do the same. A per-buffer `ControlDisplay` setting is passed
with the tab width to the native width functions, so cursor movement,
deletion, wrapping, selection, history, and drawing share one rule:
- C0 controls other than tab, LF, and CR, and DEL, show as `^@`..`^_`
and `^?` in 2 cells. C1 controls show as `<80>`..`<9f>` in 4 cells. Tab,
LF, and CR keep their current behavior.
- Cells still never hold a raw control: drawing writes the notation's
printable glyphs.
- Each control is its own grapheme, so it no longer joins the next
character, and a soft wrap never splits its notation.
- `getText()`, undo and redo, and selection copy return the original
bytes.
- Read-only text keeps hiding controls with zero width, so
`TextRenderable`, the console, and other displayed text do not change.
Only `EditBuffer` and its placeholder use notation.
Text input changes with it:
- Paste keeps controls, as Vim does, and still removes escape sequences.
The stripper is now one shared implementation, so Bun and Node give the
same result, and core no longer depends on `strip-ansi`. A lone ESC and
a truncated sequence are removed, so a paste never inserts ESC. Key
sequences with a control stay ignored, so Escape does not insert `^[`.
- `Input` puts its cursor at the native end of the value instead of the
string length.
- Extmarks convert display offsets through native `offsetToPosition`
instead of JavaScript string widths, and Right stops at the native end
of the buffer before a virtual extmark. `deleteLine` adjusts extmarks by
native line offsets.
- `EditBuffer.measureText(text)` and `ot_edit_buffer_measure_text`
return the display offset at the end of a text as the buffer would lay
it out, without storing it. JavaScript width functions give a control
and a tab no cells, so an app that computes editor offsets in JavaScript
needs this: in `"\f@fi"` the `@` is at offset 2, not 0.
Along the way, these bugs that already exist in are fixed:
- A standalone zero-width cluster (zero-width space, word joiner, BOM)
blocked Left and Backspace in the Unicode width methods.
- After `"日\u{fffd}"`, Backspace deleted the wide character. In the
no_zwj method, one emoji step had the wrong size.
- U+FFFD inside a joining sequence, such as `"क्\u{FFFD}\u{301}ष"`, made
layout and cursor movement disagree: Right got stuck, and Backspace
deleted the wrong cells.
- An extmark ending at a line end after a tab lost its last cell. Right
could step into a virtual extmark after CJK text. `deleteLine` moved
extmarks by UTF-16 indexes. `Input` placed its cursor in the middle
after CJK text.
Backspace no longer measures the whole chunk for each step. The native
"backspace 500 chars" benchmark takes 0.21 ms instead of 25.3 ms on
v0.6.0; other benchmarks match v0.6.0 within noise.
Most of the diff passes the new setting to the width functions: of the
added lines, most only add `.hidden`, `.notation`, or `control_display`,
and others of them in existing tests. Net source is +86 lines and net
tests +95.
Not included: a distinct style for the notation (like Vim's SpecialKey),
a literal-insert key, `^M` for a lone CR, controls as word boundaries,
and clipping wide units cell by cell at the left edge of a horizontally
scrolled view.
Fixes#1642
Release context can lack detail needed to describe new behavior or
useful
migration examples. Let drafts inspect the checkout in a restricted
session
so published notes stay accurate without write or secret access.
An app started inside an SSH or mosh login runs in auto-detected remote
mode. In that mode, native detection
returned before it read `TERM` and `COLORTERM`, so neither `ansi256` nor
`rgb` was ever set, and the renderer sent
truecolor SGR to every remote terminal. A 256-color endpoint such as GNU
screen 4.x then showed the UI without
color, while the same `TERM` worked locally.
This supersedes #1492 by @yurial and keeps its commit as authored. The
fix is that commit's design: read the color
depth keys (a `TERM` that contains `256color`, a `COLORTERM` of
`truecolor` or `24bit`) before the auto remote
return, and keep the return for the identity keys such as
`TERM_PROGRAM`, the multiplexer variables, and the
`OPENTUI_*` overrides, which can describe the wrong endpoint on the
process host. The later commits port its
renderer test to the current test fixtures (it used the pool API that
#1479 removed, so `test:native` did not
compile), read the two keys in one place instead of through a helper
called on both sides of the return, and fold
the tests into one table. Local and explicit remote mode, which
`@opentui/ssh` uses, give the same result as
before. Color depth values in `environment` now take effect in auto
remote mode too; the other values still need
`remote: true`, and the terminal capabilities docs now say so.
There is a trade-off. A truecolor terminal that reaches the server with
`TERM=xterm-256color` and no `COLORTERM`,
for example iTerm2, WezTerm, or Ghostty over a stock macOS ssh client,
used to get exact colors by accident and
now gets 256-color approximations, which is what it gets locally. A
Kitty XTVERSION reply still turns on `rgb`.
The docs explain how to keep truecolor: export `COLORTERM=truecolor` on
the server, forward it with
`SendEnv`/`AcceptEnv`, or pass it in `environment`.
A table in `terminal_test.zig` runs the same `SSH_CONNECTION`, `TERM`,
and `COLORTERM` rows through local, auto,
and explicit remote mode, with both an `env_map` passed at init and
forwarded `setHostEnvVar` values. The renderer
fallback test now gets `ansi256` from an auto remote forwarded `TERM`
and checks for `38;5` output, and the
remote TypeScript tests check `ansi256` and `rgb` from forwarded and
explicit values. On main the forwarded
auto remote rows and these tests fail. Moving the checks back after the
return, dropping the `TERM` check or `24bit`,
accepting any `COLORTERM`, reading only forwarded maps, matching
`256color` only as a suffix, or deleting the
return each makes at least one test fail.
Fixes#1491
---------
Co-authored-by: Yuri Dyachenko <pub.yurial@gmail.com>
The stdin parser gets a group of byte sequences wrong on terminals
without
the Kitty keyboard protocol. Escape followed by another sequence (a
mouse
report, a reply, a paste, Alt+key) loses both parts; Alt with
punctuation or
a non-ASCII character gives a key with an empty name or garbage keys; a
wheel
release ends a drag; an OSC string cut short by a new sequence swallows
it;
an unknown CSI sequence becomes an empty-name key; and an X10 report
with a
0 byte or a control byte gives wrong coordinates.
The fix will land in the native parser port, so this change does not
touch
the TypeScript parser. It adds the correct behavior as data:
`stdin-parser.conformance.json` holds 38 rows of byte steps (with
explicit
Escape-timeout steps) and the events each must produce, and its `format`
field defines the notation and string encoding so a native test can load
the same file. The 32 rows that main gets wrong run as `test.todo`; the
6
rows it gets right are normal tests. Every row also runs one byte at a
time.
`bun test --todo src/lib/stdin-parser.test.ts` fails a todo row that
starts
to pass, which is how the port shows a fixed row. Existing tests that
pin
today's behavior are marked so the port knows to flip them.
Each todo row fails on main with exactly the wrong output reported in
the
issue, and breaking the parser six ways (Alt prefix, timer boundary,
ESC-less
mouse recovery, SGR motion) fails a normal row each time.
Refs #1614; the native parser port closes it.
A Context's native object table had a fixed size of `object_capacity`
slots, and a renderer creates its Context with 65,536. Every Markdown
table cell holds a text buffer and a view, so a long document or chat
transcript failed after about 32,500 cells with
`ot_text_buffer_view_create failed: ObjectLimit`, whether it was set at
once or streamed. Plain renderables hit the same limit at 65,534.
The table now starts at `object_capacity` and doubles when it is full,
up to 4,194,304 slots, or `object_capacity` if that is larger. Past
that, creation still returns `ObjectLimit`. Handles keep their slot
index and generation, so existing handles stay valid, and no code holds
a pointer into the slot array across an insert. The cap comes from
memory, not the handle format: a slot is 32 bytes, so the cap is 128
MiB, and each live slot owns a much larger native object. There is no
ABI change. The comments in `opentui.h` and the docs now call
`object_capacity` the initial size. A clipboard start that fails to grow
the table reports out of memory instead of limit exceeded.
The handle-table model test starts at 2 slots and checks growth to 4 and
5, old handles, stale handles, and `ObjectLimit` at the maximum. A
Markdown test renders a table that needs more objects than a 64-slot
renderer, and it fails on main with the issue's error. Tests that check
cleanup at `ObjectLimit` now pin the maximum. Reverting growth, the
doubling, the clamp, the `>=` maximum check, or growth inside `insert`
each fails a test.
Fixes#1611
Serve npm's latest documentation at /docs and main-branch docs at
/docs/next. Record each release's API and notes so users can inspect
the reference and behavior that applies to the version they run.
The bundled typescript, typescriptreact, and zig highlight queries come
from nvim-treesitter and use the `#lua-match?` predicate.
web-tree-sitter does not evaluate it, so the predicate matched every
node: each lowercase identifier also got the `type` and `constant`
captures, and every block comment got `comment.documentation`. With a
theme that colors these groups differently, every identifier in a
TypeScript or Zig code block was drawn as a constant.
The tree-sitter worker now rewrites `#lua-match?` (and its `not-` and
`any-` forms) to `#match?` when it loads a query, so bundled queries and
queries that apps register are both covered. It translates only the Lua
pattern items that the bundled queries use: literals, anchors at either
end, sets of literals and `%d`, `.` (which also matches a newline in
Lua), `*`, and `+`. Any other pattern, or one that RegExp rejects, keeps
`#lua-match?` and behaves as before, so a query never fails to load
because of the rewrite. That covers about 220 of the 385 uses in
nvim-treesitter.
A table in `client.test.ts` checks the captures for TypeScript, TSX,
Zig, and doc comments, and a second table checks the translation on a
registered query, including patterns that stay untranslated (from
nvim-treesitter's kitty and clojure queries) and must still load. Each
of 12 breaks of the translation (dropping the guard, the `not-`/`any-`
mapping, `%d`, `.`, the anchors or quantifiers, or accepting `-`, `(` or
`%`) fails one of the tables.
Fixes#1613
A highlight that reached the end of its line also colored text that
later
grew the line. TextBuffer.append, EditBuffer inserts and undo, and a
line
that setText emptied and that then grew again all drew the new cells in
the
highlight's style, while getLineHighlights still reported the old range.
Styled text did the same when its chunks covered the whole line. The
cause
is that the last style span ended at the line width, and the renderer
keeps
the last span for every later column.
Span building now always ends a highlighted line with a default-style
span
that runs to the end of any line, so the spans depend only on the
highlights
and stay right when the line grows without a rebuild. The line width is
no
longer needed to build spans, so its computation goes away from the
callers.
The styled-text span builder closes its spans the same way.
A new native table compares the spans after append, insert, setText("")
followed by append, and replaceText("") followed by undo with a fresh
build of
the final text; it replaces two duplicated span tests. Dropping the
closing
span, shortening it, or reverting either builder each makes a test fail.
Fixes#1612
An empty Textarea whose placeholder takes more than one row let Down
move the cursor off the text. That happens when the placeholder has a
newline, or when it is wider than the Textarea and wraps (the default).
While the text is empty, the view lays out the placeholder instead of
the text, and Down read a row and column of the placeholder as a cursor
position in the empty buffer. The next edit (typing, Enter, Backspace)
then failed with `InvalidArgument`. In a Textarea the key handler only
logs that error, so typing looked dead until the user pressed Left.
Code that calls `moveDownVisual()` and then `insertText` got the
exception.
Down now does nothing while the placeholder is shown, because an empty
buffer has one position. The check is part of the existing row-count
check in `moveDownVisual`. Up already stops at the first visual row,
which is the only row the cursor can have then.
The vertical-move table in `editor-view_test.zig` gets rows for an
empty buffer with a two-line placeholder and with a wrapping
placeholder: they move up and down, check that the cursor stays at
(0, 0), and then insert text. One row with text now also has a
placeholder, so Down must still move when the text is not empty. The
seeded Textarea undo/redo model now uses a placeholder that wraps and
has two lines. It presses Down on an empty buffer and fails on main as
soon as it types. Removing the check, making it depend on whether a
placeholder exists instead of whether it is shown, or changing `or` to
`and` each makes a row fail.
Fixes#1609
In split-footer mode, native keeps appended output above the footer with
a temporary scroll region (`ESC[1;Nr`, N = rows above the footer). With
one row above the footer it sent `ESC[1;1r`, which terminals ignore,
because a scroll region needs two rows; with no row it sent no region.
Each CRLF on row 1 then moved into the footer and erased it, later
frames
did not repaint it, and nothing reached the scrollback. The default
footer height (12) hits this in any terminal of 13 rows or fewer, for
example a small tmux pane or after a resize.
With fewer than two rows above the footer, native now uses the region
`1;2`, which borrows the footer's top row, sends one more `ESC D` after
the payload so that the last line is back on row 1, and repaints the
footer. When a first row wraps onto the borrowed row, the existing
pre-scroll also clears that row. With no row above the footer, a row
that
fills the width ends there and scrolls into the scrollback, and the next
commit starts at column 1. A one-row terminal skips the extra `ESC D`.
One case stays lossy: when the footer covers the whole terminal, a write
shorter than the width and without a newline has no row to stay on, so
it is lost if a later write continues it.
The screen table in `renderer.native-scene-split.test.ts` gets rows with
a terminal size: panes of two rows, one row (also under the default
footer and in a two-row terminal), no row, a one-row terminal, and a
footer that shrinks after a lost partial row. They write captured lines,
a two-row writer snapshot, a wrapping first row, and a full row, then
grow the VT with the cursor on the bottom row so the scrollback shows
above the screen. The native ordering test runs at pinned offsets 2, 1,
and 0. Reverting the region, dropping the extra `ESC D`, the footer
repaint, the borrowed-row clear, the full-row end, the tail reset, or
the
one-row guard each makes at least one row fail.
Fixes#1607
In split-footer mode with captured stdout, a write that continued a row
(the previous write had no newline) was cut into terminal rows as if it
started at column 0. A wide character that would start on the last
column then sat in a commit that crossed the terminal edge: the terminal
moved it to the next row, but the renderer's row count did not. The next
write overwrote a character, a padding space landed in the middle of a
row, or the wide character was lost. Streamed CJK or emoji output (for
example tokens written as they arrive) hit this whenever a chunk
boundary left a wide character on the last column.
The cut now starts the first row of each write at the predicted tail
column, so no commit crosses the edge and the native row count stays
right without a native change. This also counts tab stops from the start
of the terminal row instead of the start of the write, and a tab now
stops at the last column as in a terminal instead of pushing the next
character to a new row.
Every captured write now reads the predicted tail, so the renderer keeps
the tail after the last queued commit instead of scanning the queue.
Output faster than a frame drains stays linear: 4,000 progress-bar
updates take 6,400 tail steps, not the 6.4 million a scan would.
The split-footer screen tests gain rows that feed the writes through a
VT and compare the screen (wide character at the last column, padding at
the edge, tabs, a wide character that wraps early, unpinned and pinned),
and the captured-stdout commit table gains the matching input-to-rows
vectors. Each row fails on main, and reverting each changed expression
(the row column, the tab clamp, the cache order) fails at least one row.
Fixes#1608
An embedded terminal never answered OSC 10 or OSC 11 color queries, so
programs such as Neovim that query the background to choose a dark or
light scheme had to guess. A child that set only one default color was
also ignored: after `OSC 11 ; rgb:ff/00/00` the background stayed black
until an OSC 10 set the foreground too.
The terminal was created without default colors, so Ghostty left its
dynamic foreground and background unset. An unset color has no query
reply, and the render state copies the terminal colors only when both
are set. Native now passes the colors the compositor already used when
they were unset: a black background, a white foreground, no cursor
color (the cursor follows the foreground), and the default palette. As
in xterm and Ghostty, OSC 12 and Kitty OSC 21 color queries now reply
too, and OSC 110/111 reset to white and black.
Reverse video (`CSI ? 5 h`) now also takes effect without OSC 10/11;
this makes nothing worse for shells, Neovim, less, or htop by default,
but rows already on screen are not redrawn when it changes, which shows
only with an opt-in pausing visual bell or a program that leaves reverse
video on, and a follow-up fixes that.
Tests: the PTY response table gains OSC 10 (BEL) and OSC 11 (ST) query
rows; the transparent-background test sends OSC 11 alone and checks the
exact opaque and transparent background; the cursor state test sends
OSC 10 alone and compares the whole cursor, including its color. Leaving
the background, foreground, or cursor default unset or seeded wrongly
fails at least one of them.
Fixes#1605
The embedded terminal sent no bytes for F1 through F12 in any form that
the key parser recognizes, with or
without modifiers, so F-key bindings in programs such as htop and
Midnight Commander did nothing. It also
dropped keys that a host terminal sends in SS3 form (`ESC O A` and the
other arrows, Home and End in application
cursor mode, keypad Enter in application keypad mode), Kitty F13 to F25,
and the keypad Begin key.
`physicalKey` turns a key event into the W3C key code that Ghostty's
encoder needs. #1423 made it skip parser
codes that start with `[`, but codes that start with `O` still went to
Ghostty as `OP` or `OA`, which it does
not know, and the name table had no F-keys. It now treats `O` codes like
`[` codes and maps `f1`-`f25` to
`F1`-`F25`. The child gets the key encoded for its own modes: Up from
`ESC O A` sends `ESC [ A`, or `ESC O A` to a
child in application cursor mode, and Alt+F5 sends `ESC [ 15;3 ~`.
The parser calls the keypad Begin key (`CSI E`, `SS3 E`, Kitty 57427)
`clear`. It now maps to `NumpadBegin`,
which Ghostty encodes as the same key: `CSI E`, `SS3 E` in application
cursor mode, `CSI 1;5 E` with Ctrl, and
`CSI 57427 u` to a Kitty child.
Some keys still give no bytes because Ghostty has no encoding for them:
the menu key (`CSI 29 ~`, Kitty 57363),
F26 to F35, and F13 to F25 to a child that is not in Kitty mode.
Kitty-only names such as the keypad navigation
keys, the lock keys and the modifier keys are also still dropped; they
will be filed separately.
A sweep checks that every key name in the parser's table except `menu`
encodes to bytes, and rows in the
`preserves` table give the exact bytes for F-keys with Ctrl and Alt, SS3
keys, Kitty F13 and F25, and keypad Begin
in default, application cursor, application keypad and Kitty modes.
Reverting the `O` check, deleting the F-key
line, moving either end of the F-key range, or deleting or renaming the
Begin entry each makes at least one row
fail.
Fixes#1606
Store positions in a fixed array indexed by marker type. Marker types
are
known at compile time, so a hash map and per-node marker counts add
overhead
without improving lookup behavior.
https://opentui-bench.fly.dev/investigations/3
The default `typescript` parser served `typescriptreact` as an alias, so
`.tsx` files and `tsx` Markdown fences were parsed with the TypeScript
grammar, which cannot parse JSX. Every JSX element became an ERROR node:
closing tags highlighted as regular expressions, attributes as
variables.
Add a separate `typescriptreact` parser that loads
`tree-sitter-tsx.wasm`
from the same tree-sitter-typescript v0.23.2 release and the
nvim-treesitter
`ecma`, `typescript`, and `jsx` highlight queries, in the order that
nvim-treesitter's `tsx` query inherits them. The `jsx` query is pinned
to
a8c082b4f5, the last revision before its `#set!
https://github.com/capture commentstring`
directives, which web-tree-sitter 0.25.10 rejects (the whole query then
fails
to compile and the filetype gets no parser). `.ts` files keep the
TypeScript
grammar, so `<T>value` type assertions still parse.
The react alias test is now a table over `javascriptreact`,
`typescriptreact`, and `typescript` with positioned tokens, and the
`tsx`
fence injection test checks the JSX attribute group and the absence of
`string.regexp`. The dist smoke test expects two more Node assets.
Source additions: parsers-config.ts +11 for the new parser entry (config
data the fix needs; nothing to delete in its place). Docs +1.
Generated, counted separately: assets/typescriptreact/highlights.scm
(+758)
and tree-sitter-tsx.wasm (1,445,638 bytes) from `bun assets/update.ts`;
default-parsers.ts (+5 -1) and default-parser-assets.bun.ts (+4). Assets
grow by 1,458,687 bytes; the packed package by about 0.2 MB (2.7 -> 2.9
MB,
unpacked 16.2 -> 17.6 MB).
Issue: 059
Fixes https://github.com/anomalyco/opentui/issues/1585
Since the native render tree, every renderable constructor throws
"Native
scene is destroyed" for a destroyed renderer. Both adapters already
skipped
engine.attach() for a destroyed renderer but still mounted, so Solid
render() rejected and React logged the error from its ErrorBoundary
fallback when a mount ran after destroy (for example after exitOnCtrlC).
Return before the mount instead; core keeps throwing.
Source +1 line per adapter: the early return needs its own line next to
the
now unconditional engine.attach().
Issue: 068
Fixes#1600
An @opentui/ssh session created its renderer in explicit remote mode
with an empty environment. The client's TERM from its PTY request
reached only session.term, and the server ignored env requests, so
native detection could not see GNU Screen or tmux, kept Unicode cell
widths, and reported no 256-color, truecolor, or hyperlink support from
the environment.
The server now accepts env requests for COLORTERM, TERM_PROGRAM, and
TERM_PROGRAM_VERSION before the shell request, and rejects other keys
and values that native would refuse. When the client sent a PTY
request, the bridge passes { TERM, ...accepted values } to the renderer
through CliRendererConfig.environment. Without a PTY request the
renderer still gets an empty environment.
Native refuses a whole environment that holds a NUL or exceeds 64 KiB
of UTF-8, and either makes renderer creation fail. ssh2 decodes TERM
and values as UTF-8 and turns each invalid byte into U+FFFD (3 bytes),
so its packet limit does not bound the encoded size. TERM and each
value are therefore limited to 256 UTF-8 bytes and must not hold a NUL;
a rejected value gets a failure reply, and a TERM that fails the check
sends no environment, as before. The allow-list bounds the stored values
to three entries; a repeated request replaces one.
Source additions: the allow-list (2 lines with its comment), the value
check and its bound (6 lines), the env request handler (6 lines), the
env option and its pass-through in the bridge (5 lines), and the option
on the bridge call. The connection test table is the first observation
of capabilities through an SSH session; the slow-channel bridge test now
counts frames after the renderer's first paint, which a 256-color TERM
makes non-empty.
Fixes https://github.com/anomalyco/opentui/issues/1590
native: drop highlights on removed lines when replacing text
setText kept user highlights on every stored line, also on lines past
the
new line count. getHighlightCount and getLineHighlights reported them,
and
text that grew back into such a line (append) was colored by them.
setPlainText now builds the kept highlight lists during preparation, for
lines below the new line count only, and swaps them in when it publishes
the text, like the styled-text path. The lists past the new end are
freed
with the old storage. This replaces the in-place internal-highlight
filter.
The fix covers setText, setTextFromMemId*, setTextFromMemRangeWithUndo
(EditBuffer.replaceText), and replaceText (EditBuffer.setText).
Source additions (net -5 lines): the staged highlight lists and their
cleanup hold the kept highlights until publication, and one comment
states
the line-count bound.
Fixes https://github.com/anomalyco/opentui/issues/1588
Up and Down kept the desired display column and committed it as is. When
the target line had a wide character (CJK, emoji) or a tab at that
column, the cursor landed on its second cell: the logical moves
(`moveUp`/`moveDown`), the visual moves that Textarea's arrow keys use
(`moveUpVisual`/`moveDownVisual`, also Shift+Up/Down), and
`setViewport(..., moveCursor = true)`. The terminal cursor was drawn in
the middle of the character, and typed text went in front of it while
the cursor moved on to the next mid-character column.
Each of these now passes its column through `EditBuffer.snapColumn`,
which returns the start of the cursor unit under the column. Left/Right
and mouse clicks already snap to that start. The desired column stays
unsnapped, so the cursor returns to it on the next line that has a
boundary there. A cursor unit never spans two virtual lines, so the
snapped column stays on the target visual row.
Source additions: `snapColumn` is the one shared snap (3 call sites);
`moveToRow` replaces the two copies of the move body in `moveUp` and
`moveDown`; `makeCursorVisible` keeps an unsnapped desired column in
its own variable.
Tests: one table in `editor-view_test.zig` replaces seven vertical-move
tests (each old assertion is a row, most now exact) and adds CJK, tab,
skin-tone and ZWJ emoji rows for both width methods, the
https://github.com/anomalyco/opentui/issues/1289 lines,
a wrapped https://github.com/anomalyco/opentui/issues/1289 line, and
`setViewport(moveCursor)`. The seeded
Textarea model test presses Up/Down and requires a grapheme boundary.
The Textarea Shift+Up/Down tests become one table with the
https://github.com/anomalyco/opentui/issues/1289 rows
in both selection occupancies. One `editor-view.test.ts` assertion
pinned the mid-emoji column 8; it is now 7.
Issue: 001
Fixes https://github.com/anomalyco/opentui/issues/1289
A width-only rewrap in word mode reset the virtual-line arena with
retain_capacity, so the arena kept the capacity of its largest layout
until the next edit. A 94 KiB text wrapped at width 1 and then at width
80 still held about 46 MB; char wrap returned to about 3 MB.
Reset the arena with the same text-bounded limit in every mode. The
width-independent word layout cache is still kept across width-only
changes. Rewraps at normal widths still reuse the arena without new
allocations; a squeeze to width 1 and back now costs 3-4 allocations
per cycle, as in char mode.
The rewrap test now runs in char and word mode and checks the arena
capacity after width 1 and then width 80. The test grows by one line,
the
mode loop; the capacity check is a new observation for word mode.
Issue: 011
Fixes#1589
core: fall back to the base-layout key only for non-ASCII keys
Kitty's alternate-key reports carry the base-layout (US QWERTY) key, and
key-binding lookup fell back to it on every layout. On Latin layouts
that
remapped shortcuts by position: Dvorak Ctrl+J matched exitOnCtrlC and
quit,
Dvorak Ctrl+T ran Textarea's Ctrl+K, AZERTY Ctrl+Q ran Ctrl+A.
Fall back only when the typed key is one non-ASCII character (one code
point), so Ctrl+ㅊ still matches Ctrl+C. Source lines: net zero (the
condition joins the existing guard; comments rewritten in place).
The getKeyBindingAction/matchesKeyBinding cases become one table of
conformance vectors, and the Ctrl+ㅊ exit test gains the Dvorak row.
Issue: 050
Fixes https://github.com/anomalyco/opentui/issues/1587
In split-footer mode native records the column where the last scrollback
row ends, so the next inline commit (captured stdout without a newline,
or a writer or surface with startOnNewLine: false) continues on that
row.
snapshotRowEnd trimmed trailing continuation cells as if they were
empty, so a row that ended in a wide character ended one cell early. The
next commit started on the wide character's second cell, and the
terminal erased the character: the writes "你好", "世界", "!\n" showed
as "你 世 !". A row that filled the width with a wide character was
treated as one cell short, so the next row started on the last column
instead of a new line, and the footer sat one row too high. The
pre-scroll before a first row that wraps used the same short width, so a
writer row on a background color that ended in a wide character could
still fill the wrapped line with that color.
snapshotRowEnd now trims only empty cells. A continuation cell belongs
to
the wide character before it, and writeSnapshotCommit writes nothing for
it, so the commit bytes do not change. The end now matches the
TypeScript prediction (getSplitTailColumnAfterCommit), which already
counts the full cell width. The orelse arm goes: both callers pass a
limit within the snapshot width and a row within its height, so the
lookup cannot fail.
Source additions: a doc comment that says why continuation cells count,
and the loop and return that replace the 15-line body.
The screen table gains a captured line continued after wide characters
(["你好", "a世界", "x\n"]: the second write continues mid-row, the
third starts after a full row that ends in a wide character). It
replaces the ASCII "continued" row and still fails when the first erase
widens to ESC[2K. The pinned row gains a red writer row that ends in a
wide character, and pins with blank lines so that it stays on one line;
its top rows scroll out. Both rows fail on main. Reverting the trim only
where the model publishes the row fails the first; reverting it only in
the pre-scroll test fails the second.
Issue: 014
Fixes#1583
Review found two gaps in the Alt conformance rows: no row combined Alt
with another modifier, so overwriting the modifier mask with Alt passed,
and no row sent a legacy-host Alt key to a Kitty child or a legacy Alt
special key, so a port that adds Super only for ESC-prefixed input passed.
Replace the duplicate legacy Alt+f row with legacy Ctrl+Alt+a into a Kitty
child and add legacy Alt+Backspace and xterm Alt+Up; each fails on the
parent of the Alt fix.
Issue: 066
Core's key parser sets `meta` for Alt: a legacy `ESC a` sets only `meta`,
and a Kitty Alt key sets `meta` and `option`. The embedded terminal mapped
`meta` to Super, so legacy children got the plain letter for Alt+b, Alt+f
and similar, and Kitty children got Alt+Super for Alt. Encode
`alt || option || meta` as Alt and only `super` as Super; Kitty's own Meta
bit, which the parser folds into `meta`, now also encodes as Alt.
This changes the documented behavior; the embedded terminal docs now say
that `meta` encodes as Alt.
Issue: 066
Fixes#1594
Ghostty's key encoder treats macOS Option as a text modifier unless
macos_option_as_alt is set, and Options.fromTerminal always clears it.
On macOS the embedded terminal therefore dropped the ESC prefix for
legacy Alt keys (Alt+b sent b), cleared Alt for modifyOtherKeys, and let
Alt keep associated text in Kitty report-associated mode. The host
terminal has already applied its own Option setting before a key reaches
OpenTUI, so an Alt bit in an embedded terminal key means Alt. Set
macos_option_as_alt to true; on every other OS the field is compiled out
of these branches, so only macOS changes, and it now matches Linux.
Source +3: export KeyEncodeOptions so the encoder options can be
overridden after fromTerminal; one line to set the field; one comment.
The core Alt conformance rows are the check; they reach this branch only
on the macOS CI runners.
Issue: 066
EmbeddedTerminalRenderable never answered Primary, Secondary, or
Tertiary Device Attributes (`CSI c`, `CSI > c`, `CSI = c`). Ghostty
answers them only through its `device_attributes` effect, and the
embedded terminal set only `write_pty`. Programs that send DA1 last in a
batch of queries and wait for its reply, such as fish 4.1 and later at
startup, stalled for about two seconds and then printed a warning.
The embedded terminal now sets the effect to Ghostty's default
attributes, so DA1 replies `ESC[?62;22c`, DA2 `ESC[>1;0;0c`, and DA3
`ESC P!|00000000 ESC\`, through the same bounded response queue as DSR.
The PTY response drain test is now a table of query and reply, with the
existing DSR row and one row each for DA1, DA2, and DA3.
Source additions: the effect assignment and its 3-line callback are the
fix; the 2-line alias in ghostty.zig names the attributes type, which
lib-vt does not export. Test lines grow by 3: the three new rows, with
the old buffer-copy lines removed by draining into one buffer.
Issue: 003
Fixes#1584
`setInternal` pinned `new_link_id` on entry before checking whether the
old or new cell was a grapheme cluster, forcing a stack frame and
register spills on every scalar cell write. Handle non-cluster cells
directly in `setInternal` and move cluster span cleanup to a cold
helper, and draw printable ASCII in `drawTextChecked` without copying
the input or building prepared runs.
| Commit | Run | Avg time |
|---|---|---|
| `31a93fbe6` (baseline) | 793 | 67.26 µs |
| `c1bb64ba1` (regression) | 794 | 77.26 µs |
| `70b56b8ed` (this PR) | 795 | **49.89 µs** |
https://opentui-bench.fly.dev/investigations/2
In split-footer mode, native wrote `ESC[K` after each scrollback row to
clear what was left to its right. After a row that ends on the last
column, the cursor stays on that column with a wrap pending, and `ESC[K`
erases from the cursor, so terminals such as Ghostty removed the row's
last character. Captured stdout splits long lines into rows of the full
width, so every line longer than the terminal lost one character at each
wrap point, and a line of exactly the width lost its last one. Writer
snapshots and surfaces that fill the width lost it too.
Native now erases before each row instead: once where the commit starts,
then after each CRLF. That erase also clears the row that a trailing
newline makes current, so the separate erase for it goes. A row that
fits clears the same cells as before.
The old erase after a row also reset the rest of the line that a row
wraps onto. When a row continues mid-line at the pinned bottom and
wraps, the wrap scrolls that line in, and terminals with background
color erase fill it with the background of the cell that wraps, so a
writer row on a background color left that color across the rest of the
line. When the first row of a commit does not fit in the rest of its
line, native now scrolls the next line in first, with default
attributes, and moves back up (`ESC D`, `ESC[A`), so the wrap moves onto
a line that already exists. Nothing is erased after a row, so no cell
can be lost at a pending wrap, and the number of scrolled lines does not
change.
A table in `renderer.native-scene-split.test.ts` feeds the renderer's
terminal bytes into libghostty-vt (`EmbeddedTerminalRenderable`) and
compares the screen: captured lines shorter than, equal to, and longer
than the width, with ASCII and wide characters, a line continued mid-row
over old text on the start row, and writer rows on a red background at
the pinned bottom, whose runs show in brackets. A footer marker on the
screen checks the row count that the table used to read from
`renderOffset`, and the full-width rows fail on main with the last cell
missing. Deleting the first erase, widening it to `ESC[2K`, moving it
back after the row, deleting the pre-scroll, dropping its `ESC[A`, or
changing its test to `>=` or to the snapshot width each makes at least
one row fail.
Other split-footer defects found along the way are not part of this PR;
they will be filed separately.
Fixes#1580
Fixes#1578.
npx skills add anomalyco/opentui --skill opentui found the opentui skill
only through the skills CLI's fallback search, and the CLI skips that
search when a conventional location such as .opencode/skills/ holds a
skill. The harden skill added in #1479 sat there, so the command
reported "No matching skills found for: opentui".
Move harden out of the repository and make validate:docs:skill fail
when git tracks any SKILL.md other than the public skill.
TypeScript used to own the renderable tree. Each frame walked it in
JavaScript, read layout back from Yoga, and made one FFI call per draw,
so frame cost grew with node and draw count. Native objects lived in
global registries and crossed FFI as raw pointers, which made renderer
teardown fragile.
A Context-owned Scene now holds the retained tree, its Yoga nodes, and
its paint state. Renderables stage property changes and flush them in
one call per frame. Zig lays out, culls, and paints the built-in
widgets, fills the hit grid, and hands the frame to Session for the
terminal diff. A frame without paint hooks completes in one step call.
Paint hooks run in one batch before painting and record into a byte
stream that native replays in paint order, so painting never pauses
for JavaScript and a hook cannot tear the frame.
Every native resource sits behind a generational handle, so a stale
handle fails with an error instead of touching freed memory, and
destroying a Context releases everything it owns.
Without OSC 8 the terminal detects URLs one row at a time, so a wrapped
link opens only the part on the clicked row. Enable hyperlinks for
xterm.js (XTVERSION), TERM_PROGRAM vscode/zed/mintty, and VTE 0.52.2 or
newer. TERM_PROGRAM and VTE_VERSION count only outside a multiplexer and
are revoked when one is detected later. FORCE_HYPERLINK overrides
detection; 0, false, or off disables hyperlinks.
@opentui/solid pinned @babel/core 7.28.0, which GHSA-4x5r-pxfx-6jf8
(CVE-2026-49356) affects. The published package keeps the exact pin, so
packages that depend on @opentui/solid could not move to a fixed
version. 7.29.7 is the 7.29.6 fix re-released with npm provenance.
Close#1568
The v0.5.15 release run failed to sign the Windows DLLs. It ran on the
push of the release commit to main, and Azure, which signs them, trusts
only runs on a tag (subject repo:anomalyco/opentui:ref:refs/heads/main
had no match).
`bun run release` now tags the release commit and pushes it together
with the tag, and release.yml releases again on the push of a vX.Y.Z
tag, so signing runs on the tag as it did before. A push to main that
raises the version still releases when the version has no tag yet, as
when a release pull request is merged, but that needs Azure to trust
main first.
The prepare job decided GitHub's latest release from npm's latest tag,
and the publish job decided the npm dist-tag again minutes later. Two
releases of different lines at the same time could disagree, or move
npm's latest back to the older line.
The prepare job now decides both from the release tags, which every
release creates before it builds, and passes the npm dist-tag to the
publish job. npm-publish.ts still chooses a tag itself when none is
passed, for snapshots and publishes run by hand. A failed tag lookup
now fails the job instead of leaving the release notes unanchored.
A push that creates a branch, such as a new maintenance branch, has no
previous commit and is not a release. It no longer fails, which would
have blocked the line's first release.
A patch of an older line, such as 0.5.16 after 0.6.0, had no branch to
come from. Publishing it would also have moved npm's latest tag and
GitHub's latest release back to it.
A maintenance branch named after its line, such as 0.5.x, cut from the
last release tag of the line when a patch is needed, now releases like
main. CI runs on its pushes and pull requests, release.yml releases its
version commits, and `bun run release` and the release button work on
it. It takes only versions of its own line.
A version older than npm's latest is published under latest-X.Y, is
not marked as the latest GitHub release, and its release notes start at
the release before it.
The check wait now also counts a commit's runs on main, where a
maintenance branch starts, and waits while a commit has no CI run yet.
The prepare job treated any failure of the version comparison, such as
a missing export, as "the version did not go up". The run then passed
with every job skipped, and `bun run release` reported a release that
never happened.
The comparison result is now read as a value, so a failure fails the
job. `bun run release` also reports success only when the publish job
succeeded. The prepare job reads both versions first and sets up Bun
only when they differ, so a push that keeps the version needs no Bun.
- Pushes to main get their own CI concurrency group. In the shared
group, a push cancelled the waiting runs of the previous commit, so
that commit got no CI (cbec2687c), and a release built on it failed
its check wait.
- release.yml releases only when the version goes up. A reverted
release commit started a release that failed at the tag step.
- The tag step keeps a tag that already points at the release commit,
annotated or not, and fails with a clear error for any other commit.
- release.ts deletes a branch or tag on failure only if it created it,
and retries a failed read of the required checks.
- prepare-release prints the new release steps. Comments now say that
a dry run creates a GitHub prerelease, that the tag step needs the
"Main tags" ruleset to allow tag creation, and what signing rezips.
- The npm package extraction loops over packages, and build-examples
requires the artifact name its only caller passes.
A release needed a local commit and tag pushed by someone who can
bypass the rulesets, and nothing checked main's CI first.
release.yml now releases each push to main that changes the version in
packages/core/package.json. It waits for the checks of the commit the
release commit builds on, tags the commit, and then builds and
publishes as before. Dry-run and snapshot tags work as before.
`bun run release <patch|minor|major|version>` waits for main's checks,
commits "Release vX.Y.Z", pushes it to main, and follows the release run
until npm serves every package. With --pr it opens a release pull
request instead; the "Prepare Release" workflow runs that mode as the
release button, with only GITHUB_TOKEN. With --dry-run it pushes a
vX.Y.Z-dry.N tag and leaves main unchanged.
The smoke tests of the packed packages move to their own job, which runs
while the Windows DLLs are signed.
Bun wraps a negative u32 FFI argument and Node FFI rejects it, so a
negative width or height reached native code as a huge extent. That
trapped in Debug and ReleaseSafe builds and was undefined behavior in
ReleaseFast. A zero packed-buffer width divided by zero on all builds.
drawBox and drawPackedBuffer now skip non-positive extents, matching how
an empty rect already draws nothing. pushScissorRect clamps them to an
empty clip rect and still pushes it, so the matching pop stays balanced.
Negative JavaScript coordinates arrive through Bun FFI as wrapped u32
values. Reject them before signed conversion so off-screen blended
writes
cannot overflow or address an invalid cell.
Fix#1552
Each push started a Windows job for React, Solid, Keymap, QRCode and
SSH. Each spent about a minute on setup for 3-11s of tests. Build and
test them in one Windows job in Build Core instead. It gets the native
library the same way as the core tests, and the required Core check now
covers it.
Core was published before its native packages, so an install in that
window got core without its native library. A failed publish also
blocked a rerun of the same version, because pre-publish rejected every
version that already existed on npm.
Publish from one script: core's native packages, then core, then the
packages that depend on it. A rerun skips each version that npm already
has with identical contents, compared by npm pack integrity, and fails
if the contents differ. The publish step then waits until npm serves all
15 packages the way installers read them.
Core test jobs started only after the macOS cross-compile finished. They
now set up and run native tests right away, then use this runner's
cached library. Without one, they wait for this run's build and download
its artifact as before, so the tests use the same cross-compiled files.
Run the Bun and Node TypeScript lanes at the same time. The Node lane
gets its own temp directory, and the tree-sitter cache test lets the OS
pick its port, so the two lanes cannot collide.
Solid layout placeholders inherited a no-op destroyRecursively(), so
removed or unmounted placeholders never freed their Yoga nodes. Destroy
them on that path, free placeholders a host rejects, and make the slot
forget destroyed placeholders so a reinserted slot never reuses a freed
node.
LineNumberRenderable now frees its gutter in clearTarget() and destroy(),
which previously threw. DiffRenderable frees views it cached while
detached and the default syntax style it creates.
Linux and Windows jobs never restored the native bottle saved on macOS,
so 14 package jobs compiled Zig on every push. Core test jobs now save
their runner's cross-compiled library under a per-OS key, and package
jobs restore it instead of building.
Remove the Zig cache steps. setup-zig points Zig at
<workspace>/.zig-cache, so they only saved an empty directory.
Yoga can report NaN dimensions for nodes attached after a layout pass.
Keep their prior size until the next pass provides valid dimensions.
Because NaN != NaN, this caused continues rerender and CPU hog.
Issue #1543
Rope nodes live in the text buffer's arena and are never freed one at a
time. setText and setTextFromMemId cleared the rope and built the new
document in the same arena without resetting it, so every call kept the
whole previous rope. A 2000-line text grew RSS by about 1.24 MB per
call; 2000 calls reached 2.5 GB. EditBuffer.setText leaked the same way
even though it clears undo history first. setStyledText did not leak
because it reset the arena itself.
clear() now resets the arena when the rope has no undo or redo history,
so nothing can reference the old nodes. With history it still clears in
place. The arena keeps its capacity, as setStyledText's reset did, so a
document of similar size reuses the same pages. After the reset the rope
points at a static empty document, so the release never allocates and
running out of memory cannot leave the rope dangling. reset() and
setStyledText use the same release instead of resetting the arena and
rebuilding a rope that could fail after the old one was freed.
loadFile stored the file bytes in the rope arena while the memory
registry pointed at them, so a reset would hand those bytes to the next
rope. The registry now owns them.
#1543
bufferDrawText and drawBox titles allocated a new Uint8Array through
TextEncoder.encode on every call. Text now encodes into a reused scratch
buffer with encodeInto. A UTF-16 code unit encodes to at most three
bytes,
so text that fits a third of the scratch never overflows it, and longer
text allocates as before.
Text still converts as TextEncoder.encode converted it: undefined draws
nothing and other values use ToString. Both box titles convert before
either encodes, so a title whose toString draws cannot overwrite the
other
title's bytes.
On Node, a 10-character drawText drops from 9,859 to 6,012 user-space
instructions and a box with two titles from 28,849 to 22,154. On Bun,
where encode was already cheap, short text drops about 5%.
Baseline x86-64 has no rounding instruction, so @round in
rgbaComponentToU8 called roundf for every channel of every blended,
filtered or faded cell. Values are clamped to [0, 255], so truncation
plus
an exact fraction test gives the same result.
The gain benchmark process drops from 37.3 to 19.6 billion user-space
instructions and the colormatrix benchmark from 23.9 to 13.7 billion.
OptimizedBuffer.resize reallocated all four cell arrays on every size
change. When a reallocation moved, it copied cells that clear() then
overwrote. If one reallocation failed, the arrays were left with
different lengths while width and height kept the old size, so a failed
shrink left a cell array shorter than the buffer's cells.
Resize now reuses the arrays while the new cells fit and the capacity
stays within half again the cells. Growing past the capacity reserves
half
of it again, so a buffer that grows a row at a time or alternates sizes
reallocates rarely. A larger jump or a shrink takes the exact cells. All
four arrays are allocated before any is freed, so a failed resize leaves
the buffer unchanged.
TextTable resets the local selection of every cell view on each
selection
update, and almost all of them have no selection. Only TextBufferView
sets
a wrapped view's selection, and either native reset clears the whole
selection, so the wrapper now remembers when its view is clear and skips
the native call. A destroyed view still throws.
A TextTable selection update drops from about 2,960 native resets to 35.
On Node that is 16.4 to 15.2 million user-space instructions per update.
On Bun, where each call is cheap, the change is within noise.
On Node, a server socket keeps Nagle's algorithm enabled. A frame
written as more than one small packet then waits for the client's
delayed ACK. On Linux that adds about 40 ms to each reply. Bun's sockets
are not affected.
Call setNoDelay(true) on each accepted ssh2 connection, after the close
and error handlers are registered, so a failure is reported through the
normal cleanup path.
Loopback ssh2 probe, two small writes per reply, client with TCP_NODELAY
(as interactive OpenSSH sets it), median of 60 replies:
node 26.4 off 41.011 ms on 0.107 ms
node 26.7 off 41.003 ms on 0.106 ms
bun 1.4 off 0.048 ms on 0.045 ms
When a scrollback snapshot starts in the middle of a line, its first
fragment continues text already written to the terminal. Keep it flush
with the preceding tail so rendering and hit testing identify the same
cells.
Clarify that alignment applies after wrapping and uses display-cell
width.
Distinguish it from Yoga layout so measurement and selection behavior
stay
predictable.
0924679971
A width shrink could clear below a settling footer, leaving stale rows
visible. Clear from the visible footer origin while preserving pinned
cleanup.
Supersede #1487Fix#1486
Make named and bare links clickable without exposing their destinations
when the terminal supports OSC 8. Preserve visible URL fallbacks
elsewhere and keep links intact across formatting, streaming, wrapping,
and late terminal capability detection.
Fix#1439
Speedup some ci things.
Releases still publish all eight native symbol archives, including
Windows. The symbol generation, verification, and release-upload steps
are unchanged.
Requiring a pinned footer forces scrollback images to fall back to
blocks during startup and after resize replay resets. Allow Kitty and
Sixel images before pinning when they fit above the pinned footer to
preserve image quality while the footer settles.
Reduce frame preparation and transport costs so local terminals can
consume image updates with less CPU work. Bound pending frames when
output stalls and fix per-frame GPU resource growth so sustained
rendering does not accumulate work or retain native handles.
Keep raw transport as the default and make file transport opt-in for
terminals that acknowledge image uploads.
#1328 partially superseded, but it has `NativeImage.adoptRgbaFile(path,
width, height)` that adopts an existing producer-created RGBA file
lazily. Eligible renders send that same path without reading, copying,
or rewriting its pixels.
The native viewport lookup reports whether a view exists, but the FFI
binding discarded that result. Preserve the boolean so callers can
distinguish missing views from successful lookups.
A backpressured frame could schedule a retry after its callback paused
or
stopped the renderer, reviving a cancelled continuous loop.
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>