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.
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.
- 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.
- Correctness measured against another team's implementation, not against itself. Every
build feeds 223 byte streams to this engine and to the real
libghostty-vtand compares the two grids cell by cell. Seventeen cases are pinned to disagree, so the harness can be seen to detect difference at all. - 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.
- 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.
- 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.
- 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.
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.
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 -uAbsolute 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.
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 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.
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.
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
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.
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.
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.
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.
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 you24, 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.
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 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.
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.
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:
After, the same bytes through the same renderer:
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:
After, the same bytes through the same renderer, one variable changed:
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.
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.
The second reason this project exists. Everything here is built on explicit signals from the child, never on guessing at a byte stream.
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.
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.
Above quiet, below ringing. Measured rather than eyeballed, because a 140ms fade cannot be caught by hand: 142.8 mean brightness against 134.1.
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.rsreads the resolved dependency graph out ofCargo.lockand fails if anything in it can open a socket. This is the way the claim would actually break: nobody writesTcpStream::connectin 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-offlinecovers the one part that could: the review panel is aWKWebView, so WebKit is linked into the process whether we like it or not. What makes the claim true anyway isPanelNavigation, 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.
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.
Four rules, enforced by crate boundaries rather than by convention.
- 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.
- Bidi lives in the renderer, never in the core. Reordering there would break cursor addressing and delete the differential signal at the same time.
- 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.jswould mean running a second VT parser in front of ours, which would make every test here measure code nothing calls. - 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,ChromeTransitionandPanelNavigation.
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
- 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
- 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
BidiCharacterTestcases, 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
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:
Minks 8x12,xinks 8x9, an emoji is 10x10, and all three share a bottom edge. The grid never moves, because aVS16cluster 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.
- 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:
KeyPresscarries aKeyEquivalent, which is a character
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+Btoggles it, on the physical key, which is free in a terminal because readline and every TUI reach forctrl+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+Wsays close this tab; answering it by quitting is the app deciding you meantcmd+Q. A child that exited on its own still terminates, becauseexitandlogoutare 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, andshift+dragto take a click back from a TUI that asked for mouse reporting.cmd+Cis conditional on purpose: with nothing selected it falls through to the child as^Cand 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, live. The copied text comes back as
select-me-with-the-mouse and nothing else.
cmd+Vbracketed paste,cmd+=/cmd+-/cmd+0live zoom across every pane,cmd+clickhyperlinks- A
cmd+Kcommand palette, OSC 133 block navigation with a gutter, git worktree workspaces, and an SSH host list read from your ownssh_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, andreports, off by default because it lets a program read back what is on your screen
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.
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 ~/Applicationsbuild-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 smokesThe 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 machineRunning 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.
- Termisu is the product: the macOS app, built from
swift/over the engine. termisu-vtis 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
-vtis heritage. The engine started as a drop-in for the C ABI Ghostty publishes aslibghostty-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 linklibtermisu-vt.awherelibghostty-vtwas 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.
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.
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.
- 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.










