Commit Graph
1208 Commits
Author SHA1 Message Date
Simon Klee 07225659bb native: snap vertical cursor moves to the start of a wide character (#1596)
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
2026-10-09 14:01:59 +02:00
Simon Klee 19869ee15f native: bound word-wrap layout memory after a width change (#1602)
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
2026-10-09 13:39:48 +02:00
Simon Klee 7e8024f8c3 core: pin the ASCII boundary of the base-layout fallback rows (#1599)
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
2026-10-09 13:38:12 +02:00
Simon Klee f813c7a142 native: count a trailing wide character in a split scrollback row's end (#1597)
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
2026-10-09 13:36:58 +02:00
Simon Klee be14991e61 core: pin embedded terminal Alt with Ctrl and special keys
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
2026-10-09 13:16:42 +02:00
Simon Klee 1b25377b60 core: encode embedded terminal Meta as Alt
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
2026-10-09 13:16:42 +02:00
Simon Klee f0d7d1d3be native: encode embedded terminal Alt as Alt on macOS
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
2026-10-09 13:16:42 +02:00
Simon Klee 15eb073f81 native: reply to device attribute queries in the embedded terminal (#1595)
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
2026-10-09 12:55:33 +02:00
Simon Klee e535b30ee0 buffer: keep non-cluster cell writes off the cluster path (#1591)
`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
2026-10-09 11:25:55 +02:00
Simon Klee 8df8295534 rope: build slice trees in one allocation (#1582)
Slice construction previously allocated every node separately,
increasing
allocator pressure and scattering a fixed-size tree. Keep endpoint
splits
allocation-free and rebalance joins so ropes retain bounded depth.

| Commit | Run | Avg time | TB arena |
|---|---|---|---|
| `6f26ed24d` (baseline) | 784 | 50.85 ms | 7.97 MB |
| `c1bb64ba1` (regression) | 785 | 62.37 ms | 14.32 MB |
| `942dc326a` (this PR) | 786 | **36.30 ms** | **4.30 MB** |

https://opentui-bench.fly.dev/investigations/1
2026-10-08 22:11:28 +02:00
Simon Klee af4b9592df native: keep the last cell of a split scrollback row that fills the width (#1581)
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
2026-10-08 18:39:00 +00:00
Simon Klee 5f6f286c3b skills: keep the opentui skill installable (#1579)
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.
2026-10-08 14:28:53 +00:00
Simon Klee 5494865060 docs: clarify runtime API contracts (#1577) 2026-10-08 11:24:26 +00:00
Simon Klee f29427796d skills: simplify hardening skill (#1576) 2026-10-08 12:18:29 +02:00
Simon Klee c1bb64ba19 native: move the render tree into Zig (#1479)
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.
2026-10-08 11:47:53 +02:00
Boris FaureandSimon Klee 6f26ed24d5 Terminology supports hyperlinks (osc8) and notifications through osc777 (#1566)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-10-08 11:08:18 +02:00
Simon Klee 6f0efd3330 Release v0.5.17 v0.5.17 2026-10-08 10:21:20 +02:00
Simon Klee 6fe23b8320 native: detect OSC 8 hyperlinks in more terminals (#1575)
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.
2026-10-08 10:21:06 +02:00
Simon Klee 4c625fa63e Release v0.5.16 v0.5.16 2026-10-07 21:04:01 +02:00
Simon Klee 3868b64b62 ot npm served timeout (#1574)
- **ci(release): wait up to 30 minutes for npm to serve a release**
- **ci(release): wait for npm in its own job**
2026-10-07 20:55:43 +02:00
Simon Klee cb8e9c7dc5 deps: update @babel/core to 7.29.7 (#1573)
@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
2026-10-07 16:32:48 +00:00
Simon Klee f1d3c93469 ci(release): release from the tag that bun run release pushes (#1572)
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.
v0.5.15
2026-10-07 13:03:58 +02:00
github-actions[bot] 615e3e0d47 Release v0.5.15 (#1571)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-07 12:35:02 +02:00
Simon Klee c95f29714a ci(release): release without waiting for main's checks (#1570) 2026-10-07 11:58:25 +02:00
Simon Klee 5a3876d75f ci(release): decide npm latest from the release tags
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.
2026-10-07 11:19:31 +02:00
Simon Klee 72a2738975 ci(release): release patches of older lines from X.Y.x branches
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.
2026-10-07 11:19:31 +02:00
Simon Klee cfd99a92b2 ci(release): fail when the version check fails
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.
2026-10-07 11:19:31 +02:00
Simon Klee c2d3bf89c2 ci(release): fix gaps found in review
- 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.
2026-10-07 11:19:31 +02:00
Simon Klee 998c6d6127 ci(release): release each version commit on main
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.
2026-10-07 11:19:31 +02:00
Kit LangtonandSimon Klee 665233f72e feat(core): preserve host-provided bare specifiers in runtime plugin (#1569)
## Why

Host applications such as OpenCode register shared host packages (for
example `effect`, `effect/*`, and `@opencode/plugin/*`) via Bun's
`build.module(specifier, ...)`. When an external TUI plugin imports
`@opentui/*` or `solid-js` alongside those packages, OpenTUI's runtime
plugin rewrites remaining bare specifiers through
`import.meta.resolve(specifier, resolveParent)` to `file://` URLs.
Because `import.meta.resolve` ignores `build.module` registrations and
resolves on the filesystem, any plugin with its own
`node_modules/effect` gets pinned to the duplicate disk copy instead of
the host's `build.module` instance unless the host installs a fragile
`file://` `onResolve` reverse-mapping hook.

## What Changes

- Add `preserve?: RuntimeSpecifierPreserve` (`Iterable<string> |
((specifier: string) => boolean)`) to `CreateRuntimePluginOptions`,
`SolidRuntimePluginSupportOptions`, and
`ReactRuntimePluginSupportOptions`.
- Keep `core` and `additional` (`runtimeModuleIdsBySpecifier`) higher
precedence than `preserve`, then skip bare specifiers matched by
`preserve` during parent-based bare-specifier rewriting
(`rewriteImportsFromResolveParents`), `node_modules` ESM prescanning
(`collectNodeModulesRuntimeRewritePaths`), and rewrite-loader
installation when a file's only bare imports are preserved specifiers.
- Validate `preserve` compatibility across repeated
`ensureRuntimePluginSupport()` calls in `@opentui/core`,
`@opentui/solid`, and `@opentui/react`.
- Document `preserve` in
`packages/web/src/content/docs/extend/runtime-plugins.mdx`.

## Verification

- `cd packages/core && bun run typecheck && bun test
src/tests/runtime-plugin.test.ts
src/tests/runtime-plugin-support.test.ts`
- `cd packages/solid && bun test tests/runtime-plugin-support.test.ts
tests/runtime-plugin-support-preload.test.ts
tests/runtime-plugin-support-node-modules.test.ts`
- `cd packages/react && bun test tests/runtime-plugin-support.test.ts`
- `cd packages/web && bun run validate:docs && bun run
validate:docs:examples -- extend/runtime-plugins.mdx`
- `bun run fmt:check && bun run lint:ci`

---------

Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-10-07 08:40:21 +02:00
Simon Klee de8dc97080 core: treat non-positive draw extents as empty (#1559)
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.
2026-10-02 05:32:51 +00:00
Simon Klee ddb7fbd691 native: reject invalid blended cell coordinates (#1556)
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
2026-10-01 22:12:39 +02:00
Simon Klee cbec2687c1 ci: test all packages on Windows in one job (#1557)
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.
2026-10-01 22:12:22 +02:00
Simon Klee aaefcc244f fix(release): publish dependencies first and resume failed publishes (#1558)
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.
2026-10-01 22:11:55 +02:00
Simon Klee 384fd75819 ci: start core tests without waiting for the native build (#1555)
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.
2026-10-01 13:25:24 +02:00
Simon Klee 8ec0082307 rendering: free slot placeholders and cached internal views
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.
2026-10-01 13:17:37 +02:00
Simon Klee 2895ffbc22 ci: cache each runner's native library (#1553)
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.
2026-10-01 11:04:24 +02:00
Simon Klee 31a93fbe66 Release v0.5.14 v0.5.14 2026-09-30 16:38:46 +02:00
0a8c302b37 fix(core): repaint after net-zero resize bursts (#1549)
## What changed

- Preserve whether a debounced SIGWINCH burst observed a size different
from the last applied size. If it returns to the original geometry,
force one full repaint instead of dropping the resize as a no-op.
- Cancel that pending debounce when split-footer applies a later resize
immediately, avoiding a stale follow-up repaint. Preserve the burst's
size-change history so a mode transition back to the original geometry
still repaints.

## Why

A terminal multiplexer can reflow or clear its visible cells on a
shrink→restore round trip inside OpenTUI's 100 ms debounce window.
Previously the final dimensions matched the cached dimensions, so
`processResize` returned without repainting. In an isolated
Herdr/OpenCode TUI reproduction, the session history remained visible
while the composer disappeared; another resize restored it. A normal
render request did not repair the screen, but a forced repaint after the
raw SIGWINCH burst did.

The same isolated two-tab fixture reproduced with OpenCode v2.0.19,
v2.0.20, and current V2, so this is not specific to the v2.0.20 tab-menu
change. See [OpenCode
#52253](https://github.com/anomalyco/opencode/issues/52253). This does
not assert that every similar report outside Herdr has the same cause.

## Verification

- Regression test fails before the change and passes after: net-zero
debounce requests a frame with native `force=true`, without a geometry
change or `RESIZE` event.
- Controls cover an unchanged-size signal, split-footer taking ownership
of a pending resize, and a net-zero resize across a screen-mode
transition.
- `bun test src/tests/renderer.console-startup.test.ts`
- `bun run test:js` (using the published 0.5.13 Linux native package;
Zig is unavailable on this test host)
- `bun run typecheck`, `bun run fmt:check`, `bun run lint`

Requested by: @simonklee (Simon via Slack)

---------

Co-authored-by: simonklee <52795+simonklee@users.noreply.github.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-09-30 16:37:44 +02:00
Simon Klee 5a4c7d2d33 ci: avoid certain flaky tests on win + node (#1547) 2026-09-30 09:56:15 +02:00
Simon Klee c9d3569778 Release v0.5.13 v0.5.13 2026-09-30 07:44:53 +02:00
Simon Klee 89bdbb6d64 renderable: defer layout for late children (#1545)
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
2026-09-30 07:04:48 +02:00
Simon Klee 8742fa72b9 native: release replaced text buffer ropes (#1544)
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
2026-09-29 23:02:45 +02:00
Simon Klee 32d67005d0 core: encode draw text into reused storage (#1538)
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%.
2026-09-27 15:43:01 +02:00
Simon Klee 205a48b78a native: round color bytes without a libm call (#1536)
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.
2026-09-27 15:36:36 +02:00
Simon Klee ca25b66882 native: resize buffers in place while the cells fit (#1537)
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.
2026-09-27 15:31:30 +02:00
Simon Klee 684d70ce1b core: skip native resets of a text view with no selection (#1539)
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.
2026-09-25 11:49:41 +02:00
clang.engineer 0450fadc05 terminal: keep tmux capability replies in the pane that asked (#1460)
Inside tmux, startup probes went through DCS passthrough. The outer
terminal's replies carry no pane identity, so a pane switch before a
reply delivered the bytes to the newly active pane as input.

Send all probes unwrapped; tmux answers the ones it implements for the
pane that asked. Skip the Kitty graphics and DA probes under tmux,
remove the passthrough retry after late XTVERSION detection, and ignore
graphics replies once tmux is known.

tmux before 3.6 does not answer DECRQM, but it has reported pane focus
for mode 1004 since 1.8, so enable focus tracking whenever tmux is
detected.

An explicit Sixel protocol is now honored under tmux through
passthrough, like explicit Kitty. Automatic selection still uses
blocks.

Closes #1459
2026-09-25 11:49:07 +02:00
Simon Klee b726707b9b chore: cleanup CODEOWNERS 2026-09-25 10:59:47 +02:00
avrlandSimon Klee a11bf6851e fix portal teardown leak (#1522)
Hi @simonklee | @kommander ,

Fixes: #1518 

This PR fixes the portal destroy leaking its children listeners in the
memory by recursively destroying its children.

---------

Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-09-25 10:09:20 +02:00