TeleTUI uses a server-authoritative architecture for remote terminal sessions.
The server owns the full widget tree and the App instance. The client is a
dumb cell renderer: it holds a flat Buffer (a grid of Cell values) and a
keystroke pipe. Nothing above the Buffer layer — no widget objects, no layout
state, no event handlers — ever crosses the wire.
This document specifies the ET wire (Embedded Terminal wire): the JSON message shapes, session lifecycle, and reconnect semantics for Phase 2a.
Transport (TCP / Unix socket / WebSocket) and authentication are not part of Phase 2a scope. They are deferred to Phase 3 (see Non-goals).
All messages are newline-delimited JSON objects. Each object carries a type
discriminator field and a session_id field (32 hex characters, 128-bit
opaque identifier).
Sent when a client first connects, or when the server cannot produce a meaningful diff (e.g. after reconnect or buffer geometry change).
{
"type": "snapshot",
"session_id": "a3f8c1d2e4b96701a3f8c1d2e4b96701",
"buffer": {
"area": { "x": 1, "y": 1, "width": 80, "height": 24 },
"content": [
{
"char": "H",
"style": {
"fg": { "kind": "rgb", "code": 0, "r": 0, "g": 255, "b": 128, "a": 255 },
"bg": { "kind": "none","code": 0, "r": 0, "g": 0, "b": 0, "a": 255 },
"bold": false, "dim": false, "italic": false,
"underline": false, "strikethrough": false, "hyperlink": ""
},
"suffix": ""
}
]
}
}The buffer.content array is row-major, width × height entries. Index
i (0-based) maps to column area.x + (i % area.width), row
area.y + (i / area.width).
Color kind values: "none" (terminal default), "256" (xterm-256 palette,
code field), "rgb" (24-bit, r/g/b fields), "rgba" (32-bit, all
four fields).
Sent on every rendered frame after the initial snapshot. Contains only the cells that changed since the last snapshot or diff.
{
"type": "diff",
"session_id": "a3f8c1d2e4b96701a3f8c1d2e4b96701",
"cells": [
{
"x": 5,
"y": 3,
"cell": {
"char": "X",
"style": {
"fg": { "kind": "256", "code": 196, "r": 0, "g": 0, "b": 0, "a": 255 },
"bg": { "kind": "none","code": 0, "r": 0, "g": 0, "b": 0, "a": 255 },
"bold": true, "dim": false, "italic": false,
"underline": false, "strikethrough": false, "hyperlink": ""
},
"suffix": ""
}
}
]
}x and y are 1-based terminal coordinates matching the buffer's area
origin. An empty cells array is valid — it means nothing changed this frame.
Sent upstream whenever the user presses a key, moves the mouse, or the terminal geometry changes.
{
"type": "input",
"session_id": "a3f8c1d2e4b96701a3f8c1d2e4b96701",
"event": "{\"type\":\"key\",\"key\":\"char\",\"char\":97,\"action\":\"press\",\"modifiers\":1}"
}The event field is a JSON-escaped string containing the inner event payload
as produced by encode_input. Inner event type values: "key", "mouse",
"resize". The outer envelope is routed by the multiplexer; the inner payload
is decoded by the server's input dispatcher without re-parsing the envelope.
KeyEvent carries an xterm modifyOtherKeys=2 modifier bit-flag in the
modifiers field (1-based, per xterm spec):
| Value | Modifiers |
|---|---|
| 1 | none (default) |
| 2 | shift |
| 3 | alt |
| 4 | alt+shift |
| 5 | ctrl |
| 6 | ctrl+shift |
| 7 | ctrl+alt |
| 8 | ctrl+alt+shift |
The field is additive: pre-P2.3 senders may omit it, in which case the
server substitutes 1 (no modifiers). New consumers must accept missing
field without error.
Client Server
│ │
│── connect (send session_id?) ────►│ new_session(app) → SessionID
│◄─ snapshot ───────────────────────│ diff_message(session, initial_buf)
│ │ (returns snapshot because last_buffer=nothing)
│◄─ diff ────────────────────────── │ diff_message(session, next_buf)
│◄─ diff ────────────────────────── │ diff_message(session, next_buf)
│── input (key/mouse/resize) ──────►│ decode via input_message / decode_input
│◄─ diff ────────────────────────── │ (server re-renders after input)
│ │
│── disconnect ────────────────────►│ close_session!(id) → registry removed
-
Connect: The server calls
new_session(app)and obtains aSession. The server renders the initial frame into aBufferand stores it on the session (session.last_buffer). It then callsdiff_message(session, buf)which, becauselast_bufferwasnothing, produces asnapshotmessage. The server sends this snapshot to the client. -
Stream: On every subsequent render tick the server calls
diff_message(session, new_buf). This compares against the storedlast_buffer, produces adiffmessage (possibly with an emptycellslist if nothing changed), updateslast_buffer, and returns the JSON. -
Input: The client wraps each event using
input_message(session, evt)and sends it upstream. The server callsdecode_inputon the innereventfield and dispatches to its App'supdate!handler. The server re-renders and sends the resulting diff. -
Disconnect: The server calls
close_session!(id). The session is removed from the registry. The App instance is garbage-collected.
If the client reconnects with a previously issued session_id:
- The server looks up the session via
get_session(id). - If found, the server resets
session.last_buffer = nothingso the nextdiff_messagecall will produce a fresh snapshot. - The client applies the snapshot with
apply_snapshotto rebuild its buffer. - Streaming resumes normally from that point.
If the session has expired (removed from the registry), the server creates a
new session with new_session(app) and sends a new session_id to the client
via an application-level reconnect envelope (Phase 3 transport concern).
| Function | Direction | Description |
|---|---|---|
apply_snapshot(msg_str) |
client | Decode snapshot; returns a new Buffer |
apply_diff!(buf, msg_str) |
client | Patch buf in place; returns buf (or new Buffer if message is a snapshot) |
The client render loop:
buf = apply_snapshot(initial_msg)
render_all_cells(buf)
while connected
msg = recv()
buf = apply_diff!(buf, msg) # no-op on empty diff
render_changed_cells(buf) # client decides how to minimize terminal writes
end- Transport: No TCP/Unix/WebSocket server is implemented here. Phase 3.
- Authentication / TLS: Not in scope. Phase 3. See Phase 3 auth stub in
src/. - Multiplexing: Multiple sessions per connection — Phase 3.
- Compression:
cellsarray is uncompressed JSON. Phase 3 may add msgpack or zstd framing. - Widget state: Widgets never appear in any wire message in any phase.
The client holds only
Buffer(cell grid). This is the core invariant of the server-authoritative architecture.