Skip to content

Termisu

Termisu

A terminal written from zero in Rust, checked case by case against a real one.
No account, no telemetry, no network. It opens on a plane.

License: AGPL-3.0 Rust 1.93+ macOS arm64 822 tests 223 corpus cases version 0.47.0

bidirectional text, rendered by this terminal

Hebrew, Arabic and Latin on one grid. Rendered by this terminal, not mocked up: cargo run -p termisu-vt-render --example screenshot. The number does not flip, the code keeps its direction, and the box drawing stays in its own columns.
Details and the Unicode conformance numbers: Bidirectional text.


Six things you will not find in another terminal

  1. Bidirectional text that is actually correct, at 91,707 of 91,707 Unicode conformance cases, with Hebrew and Arabic drawn rather than reordered into blank cells. The logical order stays in the core and the visual order lives in the renderer, so selecting the character you see gives you the character you meant.
  2. Correctness measured against another team's implementation, not against itself. Every build feeds 223 byte streams to this engine and to the real libghostty-vt and compares the two grids cell by cell. Seventeen cases are pinned to disagree, so the harness can be seen to detect difference at all.
  3. A stable port block per workspace. Four agents in four worktrees stop fighting over port 3000, and the block is derived from the path so it never moves.
  4. A dock that says which agent needs you, from explicit signals only. Working, done, error, and a fifth state that means stopped and waiting, raised by the bell.
  5. No network path, and it is gated rather than promised. One test reads the resolved dependency graph, another proves the review panel's webview cannot leave its local document.
  6. Every gate here has been watched go red. A test that has never failed is not evidence, and the list of real defects that rule caught is further down this page.

Why this exists

I wanted a terminal that renders Hebrew properly, and there wasn't one.

Not "supports Unicode". Properly: text that reorders right to left without dragging the numbers backwards with it, brackets that mirror, and niqqud placed where the font's own tables say they belong rather than wherever the default bearings drop them. Every terminal I tried got some part of that wrong, and the ones that tried at all did it as a late addition on top of a renderer that had been built assuming left to right.

That is not something you can patch in. It changes what a row is, so it has to be in the design from the first commit.

The second reason arrived later and turned out to matter more. I run coding agents, several at once, and every tool for doing that rents its terminal from xterm.js or tmux. That means the agent's state arrives as a stream of escape codes that something has to guess its way back through with regular expressions. If you own the terminal, you do not guess: the agent's model, its working directory and its git branch are already sitting in cells, typed, because your own parser put them there.

So the terminal came first, and the surface on top of it is growing from the engine outward.

Written from zero

There was a predecessor. It was a fork of Ghostty, and forking taught me where the seams were without teaching me why any of it worked. So I archived it and started again from an empty directory on 2026-07-28.

The parser, the grid, scrollback, reflow, the pty, the renderer, bidi, shaping, the C ABI and the app are all written for this project: 54,000 lines of Rust and 9,000 of Swift. The one piece not written here is the escape-sequence tokenizer, a vendored fork of vte, kept in-tree with its licences and one addition of our own.

No Ghostty code ships here. The app binary contains zero of its symbols and links zero of its libraries, which is checkable with nm and otool.

The history carries no personal data either, and that is checkable rather than asserted. Every commit is authored by one GitHub noreply identity, no commit message names an account, and the only address anywhere in the log is the reserved example domain:

git log --all --format='%an <%ae>' | sort -u                          # one noreply identity
git log --all --format='%b %s' | grep -oiE '[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}' | sort -u

Absolute paths do appear, and all of them are synthetic test fixtures such as /Users/example/src, which exist because OSC 7 reports a file:// URI and the corpus has to pin what the parser does with one. Home-relative paths appear too, and they are this program's own: ~/.termisu/config.toml and the ~/.ruuah fallback it still reads.

How it is checked, and against what

Ghostty did not disappear from the project. It became the instrument.

The differential harness was written before a single line of terminal logic. At test time the real libghostty-vt is built and linked, and a corpus feeds the same bytes to both implementations and compares the resulting grids, case by case.

the differential corpus, 223 cases

The corpus, rendered by the terminal it is testing.

Two hundred and six cases agree. Seventeen are pinned to disagree on purpose, because a corpus where nothing ever differs cannot demonstrate that the harness detects difference at all. The last line of that run is the point: agreement and disagreement are both detected.

A case pinned to disagree is a to-do rather than a failure. When the behaviour gets implemented the case fails, and promoting it to match is the evidence the change worked.

one disagreeing case, both grids

A pinned divergence: the reference grid, our grid, and every difference located to the cell.

oracle.lock records the exact reference commit the verdicts were measured against, so a case flipping overnight can be told apart from a regression.

The rule underneath all of it

A test must be seen to fail. Every gate here has been run against a deliberately broken version and watched go red. That is not a slogan; it is what found:

  • a retry path matching the wrong errno on Darwin, which had never once executed
  • a seqlock whose torn reads carried valid generation numbers, so every cell agreed with every other cell while the frame was wrong
  • a Metal command-buffer pool deadlock that only appeared at a real window size
  • a selection that outlived the pane it was made in and underflowed the renderer, found by splitting panes in the live window until it died
  • every Arabic letter drawn in its isolated form, invisible to a suite whose other scripts have only one form per letter
  • a pty shutdown that could deadlock forever, which had been hanging a test for at least a day without ever going red
  • every pane telling its child the terminal had no truecolor, which drew one agent's mascot in the wrong colour for fourteen days
  • two of the three spinner glyphs missing from the work-state table, so the dock announced done while an agent was still working

Vacuous passes are the enemy, not failures

A gate that cannot fail is worse than no gate, because it is read as evidence. Four found and fixed in this repository, each one the same shape:

  • A constant folded into both sides of a comparison. A dock-width assertion compared a delta against the very constant under test, so setting that constant to a wrong value passed. Absolute bounds now, never deltas against the thing being measured.
  • A shape signature that read only the root layer. An invisible state dot with a live sublayer still scored as a distinct shape. A shape gate counts geometry; only ink says whether anything can be seen.
  • A distinct-value count that an anti-aliased edge satisfied on its own. Counting ink levels could not tell a monochrome engraving from a filled silhouette, because an emoji bitmap's edge supplies 123 levels by itself. The metric is where the ink piles up now: 0.023 against 0.251.
  • A pty helper that matched the first marker on screen, so a second question returned the first answer. It failed loudly on one test and passed silently on another, where "the stale value was not reported" was satisfied by re-reading the value being compared against.

A hang is worse than a failure

Host::shutdown stopped the pty pump before killing the child, and the pump is the only reader of the pty master; a child exiting with a controlling terminal blocks in the kernel until its output queue drains, so it could never finish exiting and the parent waited on it forever. The reader has to outlive the child.

Nothing went red. Cargo simply never returned. One instance was found still running seven and a half hours after it started, holding its child processes, with two more beside it, while every suite run that looked green ran alongside them.

What named it was a discriminating pair rather than a theory: 120 rounds of spawn-then-shutdown with a silent child pass in 0.74s, and the same loop with a child that prints one line first hangs within 40. A third attempt at a deterministic repro, using a large piped write, did not reproduce, and that was evidence too: the pipeline was slow to start, so the child was killed before writing anything.

Two consequences, and the second matters more. The kill happens first now, so the pump keeps draining while the child exits. And the reap is bounded: a shutdown that can block forever is a defect whatever the ordering, because it takes the caller's thread with it. That bound is also what lets the regression test go red instead of hanging, which is the only reason it counts as a gate at all.

Tests measure their own claim, not the machine

Two tests here were green locally and red on a shared CI runner, for reasons that had nothing to do with the code. A synchronized-output test split its batch 60ms apart and was measured against a 150ms anti-stuck budget, a 2.5x margin that is no margin at all on a machine somebody else is using. A seqlock test required the reader to land at least one accepted read inside a fixed 400ms window, which a two-core runner cannot promise.

Neither was a real defect and neither was harmless: a gate that reports on how loaded the machine is teaches you to ignore it. The budget became a per-test setting so the gate test and the budget test each set the value their own claim needs, and the liveness window now extends while nothing has been accepted rather than failing. The workload, the race and the invariant are untouched in both.

The gates

Five exit-code gates run before anything merges.

gate what it proves
cargo test --workspace 822 tests: units, pixels, concurrency, C-surface round trips, live-pty contracts
cargo run -p termisu-vt-difftest 223 corpus cases, every verdict met, 17 of them pinned to disagree
./scripts/build-lib.sh 28 termisu_vt_* C symbols present in libtermisu-vt.a, verified by name
./scripts/build-host.sh 61 symbols in libtermisu-vt-host.a: the ABI plus the embedder surface
./scripts/build-swift.sh 15 headless smokes: what AppKit, WebKit, the bridge and the child processes actually did, with no screen required

Two more run by hand, because neither belongs in a loop:

suite result
esctest2, through our own pty 373 pinned passes of 568, both directions: a regression fails, and so does an unpromoted pass
./scripts/agent-live-tap.sh starts a real authenticated agent, so no automated gate may run it

Where the 822 live:

crate tests what it covers
core 190 the parser and the grid: a pure state machine, no I/O and no clock
render 164 glyphs, atlas, bidi drawing, shaping, both backends byte-equal
host 158 the embedder surface, agent launching, and live-pty contracts
frame 79 the seqlock frame channel and Unicode bidi conformance
ghostty 67 differential encoders: keyboard and mouse, against the reference
pty 54 the pty, and esctest2 through it
snapshot 39 the typed grid other layers read
vte 31 the vendored tokenizer plus our APC addition
abi 16 header parity, against C static assertions generated from Rust's own layout

The screenshots on this page were taken with cargo run -p termisu-vt-render --example screenshot, which runs a command in a real pty and renders the result with this terminal's own CPU backend. A picture of some other terminal agreeing with our numbers would not be the claim being made.

Bidirectional text

This is the feature the renderer was shaped around, and the one thing here that no other terminal does.

Every line in the image at the top of this file is a rule that a plausible implementation gets wrong:

  • The number does not flip. גיל 42 שנה becomes הנש 42 ליג. An implementation that reverses the run wholesale gives you 24, and it looks entirely reasonable until somebody reads a port number backwards.
  • Word order reverses, the words do not.
  • Code keeps its direction while the string inside it reorders.
  • Brackets mirror inside an RTL run.
  • Box drawing bounds the segment, so a framed table keeps its frame where the program drew it. Whole-line reordering across a table moves the frame relative to the text it encloses.
  • Niqqud are placed by the font's own GPOS tables, one cell each, rather than at default bearings.

What you type, and what lands on the grid

Logical order is what the program writes; visual order is what the terminal draws. Read the right column right-to-left where it is Hebrew or Arabic, and note what does not move.

script logical (what the program writes) visual (what this terminal draws)
Hebrew שלום עולם םלוע םולש
Hebrew + digits גיל 42 שנה הנש 42 ליג
Hebrew in code let msg = "שלום"; let msg = "םולש";
Hebrew in a frame │אבג│abc│ │גבא│abc│
Arabic مرحبا بالعالم joined, right-to-left, each letter in its contextual form
Persian سلام دنیا same, and پ چ ژ گ resolve through the same stack

Arabic and Persian go through the same reordering as Hebrew, because the algorithm is script agnostic, and additionally through the shaper, because they are cursive: a contiguous run in one cursive script is shaped as a run so each letter takes its initial, medial, final or isolated form.

Render any of them yourself. The example runs a command in a real pty and draws the result with this terminal's own CPU backend, so it needs no window and no display:

cargo run -p termisu-vt-render --example screenshot -- \
  --cols 30 --rows 3 --font 32 --out /tmp/shot.bmp --until "42" -- \
  /bin/sh -c "printf 'gil 42 shana\nגיל 42 שנה'"

--until is the text the example waits to see on the grid before capturing; without it you photograph an empty screen. The output is a BMP, and it is deliberately not a PNG: writing one would mean an encoder in the render crate's dependency tree to serve a debugging tool.

The proof

The oracle is Unicode itself, not an opinion. crates/frame/tests/bidi_conformance.rs runs our own layout against the Unicode Character Database:

suite result
BidiCharacterTest.txt 91,707 applied, 91,707 passed, 0 excluded

Every case in the file is applied; none is skipped by the segment rule. 2,162 of the first 4,000 require real reordering, so the comparison provably distinguishes visual from logical rather than passing on text that happens to be direction-neutral. The test also refuses a truncated suite, so a vendoring accident fails loudly instead of quietly measuring less.

BidiTest.txt is vendored alongside it by scripts/fetch-ucd.sh and is not yet run; the reference-vs-property form it uses needs its own harness. Stated rather than counted, because a number nothing computes is not evidence.

The suite caught a genuine bug on its first run: the fast path that skipped the algorithm for plain text ignored the base direction, and under an RTL base even a row of neutrals resolves to level 1 and reverses.

Where it lives, and why that is the whole design

Bidi is in the renderer and never in the core. Reordering in the core would break cursor addressing, because a cursor-addressed TUI has no mapping for where its cursor went after a reorder. It would also make every RTL line diverge from the differential oracle by construction, deleting the only correctness signal this project has. Reordering landed with the corpus untouched, which is that separation demonstrated rather than asserted.

Fonts, and what each script needs

The algorithm is script agnostic. Drawing is a separate question, and it is answered by the font stack rather than by the reordering.

script face state
Latin, box drawing, powerline, Nerd icons JetBrains Mono NF (SIL OFL 1.1) ships inside the app; leads, so the cell box is its advance
geometric shapes Menlo ships with macOS; 96/96 where the lead is 43/96
Hebrew Cousine (SIL OFL 1.1) ships inside the app; monospaced, GPOS attaches niqqud to its base, marks carry zero advance so a pointed cluster stays one cell
Arabic, Persian Kawkab Mono (SIL OFL 1.1) ships inside the app; covers both blocks and joins correctly
Arabic fallback SF Arabic, Geeza Pro ship with macOS, so Arabic and Persian render on a bare machine

The three bundled faces ship inside Termisu.app; assets/fonts/README.md records why each one is there and what it is licensed under. They were ~/Library/Fonts lookups described as "optional and user-installed" until 2026-08-11, which made a correct grid depend on a manual step nobody performs on a fresh machine. A user-installed copy still wins, so an operator who has their own build of a bundled family keeps it.

Until 2026-08-07 the stack was Menlo -> Miriam Mono CLM -> Arial Hebrew, and none of the three carries the Arabic block, so every Arabic and Persian codepoint drew as a blank cell while the bidi algorithm reordered them perfectly. Correct algorithm, empty row, no error anywhere. It was found by rendering the sheet above across three scripts and looking at it, not by a test.

A monospaced face is not enough, which this file claimed otherwise for a day. Measured at 32px against a 19px cell:

codepoint face that answers advance
A Menlo 19.27px
א Miriam Mono CLM, the Hebrew face at the time; Cousine advances identically 19.20px
ب Kawkab Mono 22.40px

Monospaced is a property of a face on its own: uniform advance within itself. A terminal grid needs something else entirely, an advance equal to the primary face's, and no font ships promising that. Miriam Mono matching Menlo to 0.07px is luck rather than design, and it is the whole reason Hebrew never showed this symptom and Arabic always did: a letter overhanging its cell, and because a run paints in logical order, on a right-to-left row that spill lands on a cell already painted and never cleared. An empty space beside an Arabic letter kept 50 inked pixels of its neighbour.

Fixed 2026-08-08. Each fallback face is rasterised and shaped at a size whose advance fits one cell: 32 * 19 / 22.46 = 27.07px for Kawkab. Uniform scaling, so letter shapes are preserved, which is what separates it from the two alternatives rejected on measurement. Scaling a run horizontally distorts the letters; clipping each glyph to its cell cuts exactly the strokes that make cursive readable.

Before, three letters where each one's overhang pushes the next along and the last is clipped:

Arabic overhanging its cells

After, the same bytes through the same renderer:

Arabic inside its cells

Both are native-resolution crops of the same region, magnified with point sampling so no pixel is invented.

Arabic letters take their contextual forms. A contiguous run of cells in one cursive script is shaped as a run, so each letter resolves to its initial, medial, final or isolated glyph, and every glyph is then positioned in its own cell: shape for form, position for grid. Measured at 32px, beh alone is glyph 867 and the same letter inside a word resolves to 870 / 869 / 868.

Before, every letter drawn in its isolated form:

Arabic drawn unjoined

After, the same bytes through the same renderer, one variable changed:

Arabic drawn with contextual forms

The Hebrew and the Latin are unchanged between them, which is the control: a change that moved those would be a change to something other than joining.

Until 2026-08-08 this rendered as isolated forms, and the note here blamed terminal grids. That was wrong. Shaping was gated on a cluster having more than one codepoint, so a lone Arabic letter never reached the shaper at all and got the charmap's nominal glyph, which is the isolated form: a bug in this renderer, not a property every terminal shares.

Hebrew coverage is pinned across the classes that appear in real text rather than one letter: base letters, final forms, niqqud, the dagesh GSUB composes into its base, and punctuation.

What is not done

The mandatory lam-alef ligature is refused rather than approximated. Two characters collapse into one glyph, which does not fit a cell grid, so the run shaper returns nothing and those cells fall back to the isolated forms drawn before. Wrong in a known way beats overlapping a cell.

Joining strokes do not always meet, because each glyph is positioned in its own cell rather than at the previous glyph's advance. The letters are in their correct joined forms, which is the part that makes Arabic readable. That is the real grid limit.

Running agents in parallel

The second reason this project exists. Everything here is built on explicit signals from the child, never on guessing at a byte stream.

A stable port block per workspace

Git worktrees are the standard answer to running agents in parallel, and the number that keeps appearing is four to eight per developer. The isolation works for files and does nothing for anything that is not a file. Every dev server defaults to 3000: agent A starts one, agent B dies on EADDRINUSE, and agent B reports a broken build that is not broken.

The formula everyone hand-rolls is PORT = BASE + INDEX * 10 + OFFSET, plus a script that rewrites a .env per tree, and it does not survive being moved to CI.

Termisu hands the base down. Every pane's child gets:

TERMISU_PORT_BASE   # the first port of this workspace's block, 20000..39990
TERMISU_PORT_SPAN   # how many ports the block holds, 10
// vite.config.js
const base = Number(process.env.TERMISU_PORT_BASE ?? 3000)
export default { server: { port: base }, preview: { port: base + 1 } }

Derived from the workspace path, never handed out in sequence. A base assigned in launch order moves whenever the panes open in a different order, and then a bookmark, a proxy rule and a database URL all rot. The same worktree is the same block forever and on every machine, with no state kept anywhere. A worktree reached through a symlink is the same workspace, which matters on macOS where /tmp is a symlink to /private/tmp.

PORT itself is deliberately not set. Silently moving a dev server off the port its operator typed is a bigger surprise than a variable nothing reads yet.

It is a hash, so collisions are possible and the number is written down rather than implied. With N workspaces the chance any two share a block is about 1 - exp(-N(N-1)/2/2000): 0.3% at four worktrees, 1.4% at eight. Removing that entirely would need a registry file, which is state on disk that has to be cleaned up and is wrong the moment a worktree moves.

The hash took three measured attempts, and the spread gate is the only reason that was visible. Sibling worktrees differ in their last byte, which is the adversarial input for a cheap hash:

derivation distinct blocks out of 64
FNV-1a, hash % BLOCKS 54 (a fair hash gives 63)
FNV-1a, high half 3
FNV-1a + fmix64 + multiply-shift 64

Neither slice of the raw hash was good. The fix was an avalanche step, not a different set of bits.

The dock knows which agent needs you

the dock, expanded, collapsed and mid-scroll

Rendered offscreen by --shot-sidebar: expanded, collapsed to its 28pt rail, and parked mid-scroll at fourteen sessions.

Five work states, and every one of them comes from something the child actually said:

state shape signal
idle nothing
working filled circle a spinner glyph in the OSC 0 title, or OSC 9;4 progress
done hollow ring the spinner stopped
error filled square OSC 9;4 state 2
attention a bar BEL: the session has stopped and cannot continue without you

Attention is sticky. A bell means stopped, so everything arriving afterwards is the program repainting rather than you answering. A spinner glyph one frame later must not erase it, and neither may a progress report. Only focusing that pane clears it.

The states are shapes, not colours. The whole chrome is achromatic by law, one file with eight levels of NSColor(white:), so a hue is not expressible. Three brightnesses of one circle read as one circle at three distances, especially on a 28pt rail.

There is no "waiting on you" signal to read, and that was measured rather than assumed. With one agent CLI frozen on its own permission prompt, unable to proceed without a keystroke, it emitted no title, no bell and no OSC 9;4. The only escape sequence on the wire was a background-colour query. Across a full session, all ten BEL bytes were OSC 0 string terminators and not one was a real bell. So the terminal cannot infer the block, the child has to say it, and \a is the one gesture every CLI already has.

A bell washes the pane, it does not chime. NSSound.beep() was the answer until 2026-08-26, and the system chime says something needs you while saying nothing about which pane, which is useless the moment two agents are running.

the same pane, quiet and ringing

Above quiet, below ringing. Measured rather than eyeballed, because a 140ms fade cannot be caught by hand: 142.8 mean brightness against 134.1.

No account, no telemetry, no network

Termisu never talks to a server. Not to check a licence, not to phone an analytics endpoint, not to see whether there is an update. There is nothing to sign in to, there is no telemetry to turn off, and the whole thing runs with the wifi off.

That is a fairly ordinary thing to claim and a fairly common thing to be wrong about, so it is gated rather than asserted:

  • crates/host/tests/no_network.rs reads the resolved dependency graph out of Cargo.lock and fails if anything in it can open a socket. This is the way the claim would actually break: nobody writes TcpStream::connect in a terminal on purpose, but a convenience crate arrives for an unrelated reason and drags an async runtime and a TLS stack in behind it. 248 crates resolve today and none of them are networked.
  • --smoke-offline covers the one part that could: the review panel is a WKWebView, so WebKit is linked into the process whether we like it or not. What makes the claim true anyway is PanelNavigation, which permits exactly one navigation, the local document the panel was handed, and refuses every other scheme, path and navigation type. A remote URL carrying that same path is refused too, which is a case a mutant found rather than a case anyone thought of.

Two things that are honestly not covered by either gate. The shell you run inside Termisu is your shell and can do whatever you tell it to; the terminal does not police its child. And macOS itself may talk to Apple about a signed binary the first time it runs one, which is Gatekeeper, not us.

The licence is AGPL-3.0, so this is checkable rather than a promise. The audit is grep, and the two gates above run on every build.

How it compares

Bidi is where the difference is, and it is not close. Sources are each project's own material, checked 2026-08-07.

bidi in the terminal owns its VT core checked against a reference
Termisu yes, UBA in the renderer, 91,707 Unicode conformance cases, Hebrew and Arabic scripts drawn yes yes, 223 corpus cases
kitty no, by its own documentation yes no published gate
Ghostty no bidi surface in its C ABI yes it is the reference here
WezTerm, Alacritty, iTerm2 not claimed by this project; check their docs yes no published gate
Warp not claimed by this project yes no published gate
Agent tools on xterm.js or tmux inherits whatever it rents no no

kitty's own documentation states it does not support bidi, and names the consequence precisely: in the Hebrew word ירושלים, selecting the character that appears on screen to be ם puts י into the selection buffer. Its suggested workaround is GNU FriBidi outside the terminal, with kitty forced to treat all text as left to right.

That failure is exactly what this project avoids by keeping the logical order in the core and the visual order in the renderer: selection is answered from the logical model, so the character you select is the character you get.

The rows marked "not claimed" are honest gaps rather than findings. I measured kitty and Ghostty; I have not audited the others, and I would rather leave a cell empty than fill it from memory.

Architecture

Four rules, enforced by crate boundaries rather than by convention.

  1. The core is a pure, deterministic state machine. Bytes in, grid mutations out. No pty, no GPU, no clock, no I/O. That is what makes headless CI and differential testing possible at all, and I/O lives in exactly one crate which is not the core.
  2. Bidi lives in the renderer, never in the core. Reordering there would break cursor addressing and delete the differential signal at the same time.
  3. No terminal bytes in a webview. Not pixels, not keystrokes, not frames. Document-shaped panels get a browser engine; the terminal surface never does. Renting xterm.js would mean running a second VT parser in front of ours, which would make every test here measure code nothing calls.
  4. A policy a gate cannot reach is a policy that has never been asserted. Four have been pulled out of live objects for exactly this reason, after one of them turned out to be wrong in a way nothing could see: LastPaneOutcome, WorkStateRule, ChromeTransition and PanelNavigation.
crates/vte        the vendored tokenizer, plus APC dispatch
crates/core       the parser and the grid. No I/O, no clock
crates/snapshot   the typed grid every other layer reads
crates/frame      the seqlock frame channel, and bidi reordering
crates/render     glyphs, atlas, shaping, CPU and wgpu backends
crates/pty        the only crate that touches the operating system
crates/abi        the C surface: libtermisu-vt.a, the libghostty-vt drop-in
crates/abi-types  the C layouts, pinned byte for byte against the real library
crates/difftest   the differential harness and its oracle
crates/host       the embedder surface: panes, agents, worktrees, launch
crates/markdown   pipe tables laid out as box drawing. Built, wired to nothing yet
swift/            the macOS app: window, chrome, panels, input

Features

Terminal core

  • Full VT parsing on a vendored vte fork, one addition: APC dispatch
  • True colour; styled underlines, single / double / curly / dotted / dashed, with SGR 58 underline colour
  • OSC 8 hyperlinks whose stamps survive scroll and resize; OSC 52 clipboard write; OSC 9 and OSC 777 notifications; BEL
  • OSC 7 working directory, stored exactly as reported, including two rules the reference implementation's own source gets wrong
  • OSC 133 semantic regions (prompt, input, output), the rails everything else rides
  • DSR / DA / DECRQM query replies through the pty, so programs that probe get real answers
  • Synchronized output (mode 2026) with an anti-stuck budget, so a wedged frame cannot freeze the display
  • Bracketed paste (mode 2004) with the reference-measured encoding
  • Selection as a query rather than as state: word, line and select-all ranges plus the clipboard text they format to. The word rules are ported from the reference's code, not its doc comment, which is wrong, so ., /, - and _ are not boundaries and a path, a filename and a flag select whole
  • Kitty graphics: direct transmission, RGB / RGBA / PNG, chunked, queries answered, z-ordering, and unicode placeholders where the cells are the image, so it scrolls, reflows and erases with the text
  • Sixel, decoded into the same image pipeline, and advertised in DA1
  • Grapheme clusters from day one; wide glyphs and spacer tails; VS16 emoji presentation
  • Paged scrollback with an exact row budget

Rendering

  • Glyph atlas with damage-driven redraw; a CPU reference backend and a wgpu compute backend, byte-equal to each other by specification
  • Bidi in the renderer: UBA reordering at 91,707 of 91,707 BidiCharacterTest cases, mirrored brackets in RTL runs, niqqud placed by GPOS shaping
  • Per-glyph font fallback, with each fallback face scaled so its advance fits the cell the primary face defines
  • Synthesized block mosaics
  • Font ligatures behind a substitution guard, so a non-ligating font renders byte-identically with the feature on or off
  • Selection drawn as a tint blended over the finished row rather than as a cell background, which would be erased by the next cell's

Emoji are glyphs, not stickers

emoji rendered as monochrome engravings beside mixed-case text

A coloured sticker is not a terminal glyph. The artwork's own colours are dropped and it is painted in the cell's foreground, down the same blit path as a letter. Dropping the face instead was rejected: the codepoint still occupies its cells, so a removed face leaves a hole the line reflows around.

  • Alpha alone is a filled silhouette, so the artwork's luminance carves the interior. It is stretched across the glyph's own range first, because emoji are authored against white and an unstretched mask reads as mud.
  • A floor on every opaque pixel, so a black heart does not vanish on a dark ground.
  • Rec.709 in integers, so the CPU and GPU backends still receive byte-identical pixels.
  • Every emoji is one size: a square of one cell advance, sitting on the text baseline. Measured at 16px, cell 10x22: M inks 8x12, x inks 8x9, an emoji is 10x10, and all three share a bottom edge. The grid never moves, because a VS16 cluster stays one cell wide, which the corpus pins against the reference.
  • Known limit: the polarity assumes ink is lighter than ground, true of every scheme shipped today. On a light theme an emoji reads as a negative.

Input

  • Kitty keyboard protocol: the full flag stack, CSI-u encoding, modifyOtherKeys, byte-compared against the reference encoder across 135,216 cases with zero divergent
  • SGR mouse (1000 / 1002 / 1003 / 1006) plus alternate scroll, against a ~65k-case differential matrix; the wheel routes by mode to report, arrow keys, or viewport
  • Chords match physical key codes, never characters, so a Hebrew layout keeps every one of them. This is also why the pane cannot be SwiftUI: KeyPress carries a KeyEquivalent, which is a character

The app

macOS, AppKit, one window. The reasoning is written down in docs/decisions/2026-08-25-appkit-not-swiftui.md; the short version is the keyCode line above, plus the geometry gates, which are assertable only because the layout types exist apart from the views.

  • One bar, and it starts shut. A collapsible dock down the right-hand side of the window, listing every session with the directory and branch it is in. cmd+B toggles it, on the physical key, which is free in a terminal because readline and every TUI reach for ctrl+B
  • It collapses to a 28pt rail rather than to nothing. A sidebar that collapses to zero has a state you can enter and not leave, and a chord is no way out for anyone who has not been told the chord exists. The non-idle state dots stack down the rail, so closing it does not hide which agent is working
  • The dock scrolls, so no session is unreachable. It used to stop building rows past a capacity limit, silently, with no scrollbar and no ellipsis
  • The chrome takes the terminal's own background and paints only white at an alpha over it. When themes land, one file is where they plug in
  • Closing the last tab opens a shell, it does not quit. cmd+W says close this tab; answering it by quitting is the app deciding you meant cmd+Q. A child that exited on its own still terminates, because exit and logout are the universal "done with this terminal" gesture
  • Splits with a real gutter, a pane whose child exited closing itself and giving its width back, and splitting that stops before a pane becomes too narrow to use
  • Click-drag, double-click word and triple-click line selection, cmd+A, cmd+C, and shift+drag to take a click back from a TUI that asked for mouse reporting. cmd+C is conditional on purpose: with nothing selected it falls through to the child as ^C and can still interrupt a command. The copied text comes from the engine's own formatter, the one the corpus measures against the reference, so soft-wrapped lines join the way they should instead of pasting with a break in the middle

a drag selecting one phrase on the grid

A drag, live. The copied text comes back as select-me-with-the-mouse and nothing else.

  • cmd+V bracketed paste, cmd+= / cmd+- / cmd+0 live zoom across every pane, cmd+click hyperlinks
  • A cmd+K command palette, OSC 133 block navigation with a gutter, git worktree workspaces, and an SSH host list read from your own ssh_config
  • Agent launcher: ten agent CLIs with the fields that actually differ, a PATH probe that measures availability rather than trusting a table, spawn-observe-retry with counted backoff, and a guard that refuses an auto-approve bypass rather than stripping it
  • ~/.termisu/config.toml: font size, family, ligatures, shell, themes, auto-direction, and reports, off by default because it lets a program read back what is on your screen

Status

Working as a terminal, and in daily use on macOS, Apple Silicon. Pre-1.0, no stability promises.

What is finished is the engine. The core, the renderer, the pty, the C ABI and the differential harness are the parts this project is about, and they are done to the standard the gates describe.

What is not built is a workbench. There is no session map, no canvas, no way to see what several agents are doing at once beyond the dock. The review panel renders documents and is read only: editing from it needs a conflict model, undo, save semantics and an answer for the agent editing the same file in the same second, and none of those is a diff viewer. Sessions do not yet outlive the window, which is the one reason people stay on tmux.

The GUI layer is deliberately uncovered by the differential harness and its debt is declared per slice rather than hidden. Features landing there close as untested - needs your eyes unless a picture was taken.

Linux is compiled on every push (cargo check) and is otherwise unrun: the key and clipboard paths exist and no key has ever been pressed on a real Linux machine. Windows is not started; there is no POSIX pty there, so the pty crate needs ConPTY.

Building

Requires Rust 1.93 or newer, and Xcode for the app.

git clone https://github.com/Orellius/termisu.git
cd termisu
./scripts/build-app.sh          # assemble, sign and install to ~/Applications

build-app.sh is the only supported install path. It takes the version from git describe, so tag before you build. It also needs GNU timeout (brew install coreutils) and librsvg (brew install librsvg) for the icon, and it fails loudly rather than shipping something wrong if either is missing.

The gates:

cargo test --workspace                 # 822 tests
cargo run -p termisu-vt-difftest       # 223 corpus cases, against libghostty-vt
./scripts/build-lib.sh                 # libtermisu-vt.a       (the drop-in ABI, 28 exports)
./scripts/build-host.sh                # libtermisu-vt-host.a  (ABI plus embedder, 61 exports)
./scripts/build-swift.sh               # the host, then 15 headless smokes

The rest:

sh scripts/demo-features.sh            # the one-screen feature tour, inside the terminal
./scripts/make-signing-identity.sh     # a stable local signing identity, once per machine

Running the differential harness additionally needs a Ghostty checkout to build its oracle from, pointed at by TERMISU_VT_ORACLE_SRC. See CONTRIBUTING.md for that and the rest of the development setup.

On signing. codesign -s - makes the designated requirement a bare hash of the code, so every rebuild is a different application to macOS and it re-asks for every permission. make-signing-identity.sh creates a stable local identity and fixes that. It is not Developer ID and it is not notarization, which is what a public release would need.

The names

  • Termisu is the product: the macOS app, built from swift/ over the engine.
  • termisu-vt is the engine, and it keeps its name. The VT core, pty, renderers and C ABI are the part somebody else could embed, so the crates carry the engine's name while the repository carries the product's.
  • The -vt is heritage. The engine started as a drop-in for the C ABI Ghostty publishes as libghostty-vt, meant to sit behind somebody else's GUI, then grew its own window, renderer, panes and agent launcher. The ABI promise is still real and still tested: you can link libtermisu-vt.a where libghostty-vt was expected.
  • The product was Mind2t until 2026-08-25 (terminal plus tiramisu) and the repository was ruuah-vt until 2026-08-06. GitHub redirects the old URLs.

Contributing

Read CONTRIBUTING.md first. The short version: extend the harness before you change behaviour, and a new test must be seen to fail.

Security reports go through a private advisory, not a public issue. SECURITY.md documents what a remote byte stream is and is not allowed to make this terminal do.

License

AGPL-3.0-only. The vendored crates/vte fork remains MIT OR Apache-2.0, with both licence texts kept in-tree.

Ghostty is the measuring instrument, not the foundation: linked by exactly one crate at test time, and absent from the shipped binary. NOTICE states the position in full, including which parts of the core's behaviour were derived by reading the reference's source and why that is derivation rather than transcription.

Acknowledgments

  • Ghostty (MIT), the reference implementation this engine is measured against at test time, and the origin of the ABI.
  • vte, the tokenizer this project vendors and extends.
  • esctest2 (GPL-2.0, test time only), the conformance suite run against our own pty.
  • Cousine, the Hebrew face, Kawkab Mono, the Arabic face, and JetBrains Mono via Nerd Fonts, the lead.
  • Culmus, for Miriam Mono CLM. It was the Hebrew face until 2026-08-25 and is still the GPOS control the mark-placement tests are measured against.

About

A terminal for macOS written in Swift. Real shell, real mouse selection, no Electron.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages