text-os architecture

The host, the macOS app, and how the renderer puts text on screen. Written for someone who has built TUIs, CLIs and web UIs but has not worked inside a renderer.

How this was produced. I read the code and docs in text-os-native. Nothing was built or run: the client is macOS-only and I was working in a Linux container. Where I am interpreting rather than quoting the code, I say so.

1. The big picture

text-os is two processes. A host written in TypeScript (run by Bun) owns all state and logic and decides what every cell of a 120-column text grid shows. A client written in Swift paints that grid in a macOS window and sends input back.

If you know terminals, it is a terminal emulator and a TUI program. The difference is the protocol between them: JSON messages over a WebSocket rather than escape codes over a pty.

macOS app (Swift, AppKit, Metal) AppKit input: keys, mouse, resize GridView: holds the latest frame Metal renderer: frame → pixels Host (Bun, TypeScript) route event to the focused view update(state, event) → new state render(state) → cells → segments input events WebSocket 127.0.0.1:28890 frames spawns as child process
The app is "just the view". Everything that decides what is on screen lives in the host.

2. How the host makes a frame

The host's loop is Elm-style: state → event → update → render. One root (src/ui/runtime/runtime.ts) holds a single JSON-serialisable state tree. Apps, the tab bar and dialogs are all views with the same shape: { init, update, render }.

state one JSON tree render() views paint grid 120 × rows cells toRows() merge into runs serialize names → #rrggbb ws.send skip if same cells F i l e s m a i n . t s segments { t: " Files ", bg, bold } { t: " main.ts" }
Top: the pipeline. Bottom: what toRows() does. Adjacent cells with the same style collapse into one segment, which is what travels over the wire.

One Unicode scalar is one cell, on both ends. Wide glyphs (CJK, emoji) are shrunk to fit one cell instead of taking two. The cost is small emoji; the gain is that host and client can never disagree about column positions, which is the classic terminal alignment bug.

3. The wire

Every message is one complete JSON object with a type. A frame looks like this (field names are real, values are illustrative):

{
  "type": "frame",
  "background": "#161616",
  "foreground": "#e6e6e6",
  "rows": [
    [ { "t": " Files ", "bg": "#2a2a2a", "bold": true }, { "t": " main.ts" } ],
    [ { "t": "╭──────╮", "fg": "#808080" } ]
  ],
  "cursor": { "row": 3, "col": 14 },
  "layers": [ { "x": 30, "y": 10, "w": 60, "h": 12, "rows": [ ] } ],
  "dim": true
}
DirectionMessages
Host → clientframe; client:call (do something where the human is: openUrl, clipboard.write, ssh.forward, host.switch); projects; alive; farewell; drop:pull; audio:* and speech:*
Client → hostkeydown, keyup; mousedown, mouseup, click, drag, mousemove, wheel; flags (modifier keys); resize (row count); filedrag; probe; client:result; clipboard:changed

4. The macOS app

The client is about 7,300 lines of Swift in src/client/, compiled with plain swiftc (no Xcode project). About 1,600 of those lines are audio and speech, which this report skips.

FileJob
main.swiftProcess setup, menu bar, one borderless NSWindow, starts the socket
launcher.swiftFinds bun, spawns the host, owns the ssh tunnels
transport.swiftWebSocket, message dispatch, reconnect
connection.swiftConnection state, the liveness probe, the border that shows an outage
grid.swiftGridView: holds the current frame, turns AppKit input into JSON, hosts overlay views
layout.swiftFont fitting and cell metrics
renderer.swiftBuilds per-cell data each frame, drives Metal
atlas.swiftGlyph cache textures
shaders.swiftThe GPU code, as a Swift string compiled at startup

GridView is an MTKView: an ordinary NSView whose backing store is a GPU texture. It is paused, meaning it has no render loop. It draws only when a frame arrives, the cursor blinks, or the window resizes.

The window stays hidden until the first valid frame arrives. The client owns no colours of its own, so there is nothing sensible to show before then.

What is not drawn by Metal

Four things are ordinary AppKit views or layers stacked on top of the grid:

5. Renderer primer: three ideas

The renderer is a "GPU glyph atlas" design. The client README says this is the model Ghostty, Alacritty and WezTerm use. Three ideas carry it, and each has a web analogy.

Texture atlas
A sprite sheet. Each glyph is rasterised once into a tile of one big texture, then copied from there every time it is needed.
Instancing
One draw call that says "draw this rectangle N times", plus an array of N small structs. The GPU runs the same code for every copy and hands each copy its own struct. Think of array.map(drawCell) where the loop runs on the GPU.
Shaders
Two small functions that run on the GPU. The vertex shader runs once per rectangle corner and answers "where on screen is this corner?". The fragment shader runs once per pixel inside the rectangle and answers "what colour is this pixel?".

The older design drew each cell with CoreText on the CPU and cost about 40ms per frame in the worst case. The README puts the current design at under 2ms, which is why the client repaints the whole grid every time with no dirty-rectangle tracking.

6. A frame's journey through the client

background queue main thread GPU WebSocket receive one JSON message decode JSON → Frame struct pending slot newer replaces older GridView.setFrame store, then draw() buildInstances 1 struct per cell ring buffer copy into 1 of 3 encode + commit 2 or 3 draw calls vertex shader 4 corners × every cell fragment shader once per pixel present drawable → screen
The two highlighted boxes are the two places where the client protects itself from running behind.

7. Layout: fitting the grid to the window

top bar app area tabs, apps, dialogs: all host-drawn bottom bar foot row row 0: top bar + drag handle rows: 41 minimum, more when the window is taller last 2 rows: bar + foot bottom padding, 8pt side padding, one cell wide bar colour fills the padding too 120 columns, always
The window has no separate titlebar. Row 0 of the grid sits where the titlebar would be, with the traffic lights on top of it.

Layout.fit (layout.swift) picks the font size, and everything else follows from it:

8. Building the instances

Every frame, on the CPU, buildInstances (renderer.swift) walks the segments and writes one small struct per cell. This is the "array of N structs" from the primer.

0 4 8 12 16 20 24 atlasPos 2 × u16 fg 4 × u8 bg 4 × u8 behind 4 × u8 below 4 × u8 meta 4 × u8 tile origin in the atlas (px) ink colour paper colour colour outside top corners colour outside bottom corners kind, flags, 2 payload bytes 24 bytes per cell × 120 × rows, rebuilt from scratch every frame
CellInstance. The Swift struct and the shader's struct must match byte for byte; the renderer asserts the size at startup.

9. The glyph atlas

coverage atlas: one 8-bit texture a b c d e f H i l o w r unused tiles fill as new glyphs appear cell instance A atlasPos (210,0), fg red cell instance B atlasPos (210,0), fg blue tint tint H H screen cell screen cell Each tile is drawn once by CoreText, the first time its glyph is seen. Slot 0 is the blank space.
The atlas stores coverage (how much of each pixel the glyph covers), not colour. A red H and a blue H share one tile.

10. One draw call

1 quad, 4 vertices instance buffer screen vid 0 vid 1 vid 2 vid 3 × iid 0atlasPos fg bg … meta iid 1atlasPos fg bg … meta iid 2atlasPos fg bg … meta … iid 4919atlasPos fg bg … meta 1 draw call col = iid % 120, row = iid / 120 pixel = gridOrigin + (col, row) × cellPx
4,920 is 120 × 41, the minimum grid. vid is the vertex index and iid the instance index; the GPU supplies both.
enc.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4, instanceCount: maxCells)

11. The fragment shader

For each pixel of each cell, grid_fragment (shaders.swift) looks at meta.kind and takes one of seven paths.

a 0 · text coverage atlas 1 · emoji colour atlas 2 · box lines computed 3 · braille 8 on/off dots 4 · shades dither pattern 5 · blocks fractions of cell 6 · pixels 8 coloured dots
Only kinds 0 and 1 read a texture. Kinds 2 to 6 are "procedural": the shader computes each pixel from arithmetic on its position inside the cell.
KindCharactersHow the pixel is decided
0 textEverything not listed belowSample the coverage tile; mix bg toward fg by that amount
1 emojiScalars with emoji presentationSample the colour atlas and lay it over bg
2 box lines─ │ ╭ ╮ ╰ ╯ ┌ ┐ └ ┘ ├ ┤ ┬ ┴ ┼Is this pixel inside the stroke band or the corner curve?
3 brailleU+2800–28FFDistance to the nearest of 8 dot centres; unlit dots get a faint tint
4 shades░ ▒ ▓A dot pattern computed from the pixel's position in the whole grid, so it tiles across cells
5 blocks▀ ▄ █ ▌ ▚, sextants, octants and similarSplit the cell into a small lattice (2×2, 2×3, 1×8…) and look up a bitmask
6 pixelsAny cell whose segment carries dotsBraille geometry with all 8 dots lit, each in its own colour. This is how ui.pixels shows images

Why draw box lines with maths instead of the font?

Cell width is rounded up to whole pixels, so the font's own line and block glyphs stop slightly short of the cell edge. A run of them shows hairline seams. Computing them guarantees that a line leaving one cell meets the line entering the next at the same pixel. Rounded backgrounds (pills, buttons) are also computed here, by rounding the corners of any cell flagged as the end of a rounded shape.

12. Colour

13. Overlays and dialogs

pass 1: base rows only grid pipeline, dimmed colours baseTex offscreen; only for dialogs pass 2: base + dialog layers grid pipeline, opaque quads drawable the window's next image sampled under the dialog's border overlay pipeline cursor, underlines, padding alpha-blended on top
Without a dialog, pass 1 is skipped and a frame is two draw calls.

14. Input and connection

Input

Connection

15. Where to read next

  1. src/client/README.md, the "Rendering" section.
  2. src/client/renderer.swift: render(), then buildInstances, fillRows, cellInstance.
  3. src/client/shaders.swift: grid_vertex, then grid_fragment.
  4. src/client/tests/rendering-main.swift: pixel-readback tests that show what each piece is meant to guarantee.
  5. docs/platform-spec.md for the reasons behind the host/client split and the wire.

Not covered here: ssh tunnelling to remote hosts (tunnel.swift, src/remote-setup/), the audio and speech engine, file drop uploads, and the app-authoring API (docs/api-one-pager.md).

16. Stale references found along the way