| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Name | Name | Last commit date | ||
|---|---|---|---|---|
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.
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 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:
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:
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:
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.
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
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.
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.
A drag, live. The copied text comes back as select-me-with-the-mouse and nothing else.
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.
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.
| Back | FazBrowse Home | New Git URL |