terminal_ui
The terminal view of every client: the renderer xterm behind an own escape filter, one
controller per tab that runs the flow of opening, the stream and reattaching, key pinning
through ports, the header, the tabs, the dialogs and the extra keys bar
(15 Client architecture, section 23;
24 Terminal, sections 5, 8.4, 11.2 and 12).
Status: built in M4-35 (docs/decisions.d/M4-35.md D-M4-35). Kind: Flutter package, a member of the
Dart workspace at the repository root (resolution: workspace, publish_to: none).
Dependencies
Allowed members (docs/spec/15-client-architecture.md, 3.1; tools/arch-check/rules.yaml):
design_system, auth, api_client, bothy_noise, domain (arrows of the diagram), and
through them l10n. Every other member is out of reach; make arch fails on a dependency or
an import of it. Outside the workspace: xterm (the renderer, see below) and the types of
api_client and bothy_noise it touches (built_collection, cryptography).
Further rules checked by make arch:
- never depends on
sync_engineorlocal_store, directly or through another member (rule 7). - no native source (rule 4).
Ports per app
The controller sees nothing of the app but the ports of lib/src/ports.dart (15 23.1). Each
app implements them its own way:
| Port | Desktop and mobile app | Web console |
|---|---|---|
TerminalServerApi (getHost, openTerminalSession, attachTerminalSession, closeTerminalSession) |
the REST client of api_client |
the same, with the cookie session |
TerminalTransport, TerminalChannel (one WebSocket per stream, text until identify, then binary only) |
own WebSocket; identify returns the access token and a DPoP proof with the nonce of terminal.hello, both from auth |
the browser WebSocket on the same origin; the cookie session of the upgrade signs in, identify carries no token (08 Identity, 7.5) |
HostKeyPinStore (per host noise_public_key, key_generation, fingerprint, first seen) |
the encrypted table host_key_pins of local_store, deleted with all local data on sign-out |
IndexedDB of the origin, deleted on sign-out (ADR 0070) |
TerminalStepUp (the step-up before opening and when the server asks) |
session.stepUp.confirm of auth (step-up device key with biometrics or PIN) |
the passkey step-up (M6) |
HostEvents (changes of the host and of the own sessions; optional) |
sync_engine: host.updated, host.presence_changed, terminal_session.changed |
none: the console reloads through REST when the view opens and after each own action |
LinkOpener (opens a confirmed https or http link outside the view) |
the app's own opener | the console's own opener |
TerminalPrompts (the dialogs the flow asks) |
DialogTerminalPrompts of this package |
DialogTerminalPrompts of this package |
Flow
TerminalController runs 15 23.2 for one tab; every outcome is a phase of its state
(TerminalTabState), its methods never throw.
open:getHost, then the pin check (checkPin, 15 23.3). No pin: the pin is written after the first successful handshake. Same key: no notice. Other key: the dialog "Key changed" with both fingerprints; "Do not connect" opens nothing, accepting sendspin_changed = true. A host assigned to another person shows the warning of 24 8.4, before any step-up.- The dialog "Open terminal" (profile, working directory), then a fresh ephemeral X25519 key
in memory and its
client_ephemeral_key_hash. - Step-up, then
openTerminalSessionwith profile, size, hash andpin_changed. Anoise_public_keyorkey_generationin the answer other than the checked one blocks the tab and closes the session. - The stream: wait for
terminal.hello(protocol version 1 required), sendidentify, Noise message 1 with the session token, check message 2, destroy the ephemeral private key, sendstart. Only then the tab accepts input. - Input goes as
data, size changes asresize, at most oneresizeper frame tick.exitshows the exit code and leaves the tab open. detachcloses the stream, the session keeps running.attachchecks the pin again, makes a new key and handshake and runs a step-up only when the server answers401 step_up_required(or before the request after4101). The replay betweenreplay_beginandreplay_endreplaces the screen in a fresh terminal model.- Item 7 (mobile background) is the app's part: it calls
detachandattach; the package has no lifecycle hook.adopt(M4-36) shows a session the server already lists as a detached tab with "Reattach", without a request, so an app keeps one tab per own session;attachthen runs as above. - Close codes (
closeReactionFor):4100ends the tab with theclose_reason, a revocation blocks it;4101,4102,4103and a lost connection detach it with their cause;4104and every Noise or frame error block it as tampering, without a retry. A closed session interminal_session.changedreacts like4100. Local work is no tampering: a failedidentify(no access token or DPoP proof) detaches the tab withauthenticationFailed, and a pin that cannot be stored leaves the session connected (D-M4-35-21). A refused opening or attach keeps the server'sreasonnext to the problem code (TerminalTabState.problemReason), and the banner shows its text (problemText, M4-36).
Blocks
- Escape filter (
EscapeFilter, 24 11.2, SEC-TERM-6, TM-T14): on the byte stream before UTF-8 decoding, in front of xterm, so the locks do not depend on the renderer version. Text, C0 controls and plain ESC sequences pass. CSI passes except window operations and reports (finalt), status and other reports (nother than CPR6 n), device attributes other than DA1 and DA2,$requests, DECREQTPARM and XTVERSION; so the answers that reach the host are exactly CPR and DA1/DA2. DCS, SOS, PM and APC are dropped with their content. Every OSC is dropped (OSC 52 clipboard, titles, color queries) except OSC 8. CSI sequences known to make xterm 4.0.0 throw or hang are dropped too (parameters aboveEscapeFilter.maxParameterValue, SGR 38/48 without sub-parameters or with an index above 255, REP counts aboveEscapeFilter.maxRepeatCount,CSI 0 X). Sequences may be split at any byte across frames; memory per sequence is bounded. The full allow list is the doc comment ofEscapeFilter. - OSC 8 links: rewritten into inert text plus a record (
TerminalLink). A click shows the full target in a confirmation; only absolutehttpsandhttpURIs with a host open (isOpenableLink), throughLinkOpener. - Paste guard: pasted text loses ESC and the other controls except tab and line breaks (they could end a bracketed paste early or run a line); text with a line break is then shown with its line count before it is pasted ("Paste with line breaks?"); the paste keys map to the guard, never to xterm's own paste.
- Copy only by the person: selection plus the copy shortcut (Cmd on Apple platforms,
Ctrl+Shift elsewhere, so Ctrl+C and Ctrl+V stay with the shell). An app hands its own
shortcuts of the terminal to
TerminalPane.shortcuts; the renderer handles them before it sends a key to the shell (M4-36). The host cannot write the clipboard: OSC 52 is filtered and xterm has no clipboard handler. - No content in logs (R-CL-17): the filter logs nothing and keeps no content beyond the
current sequence; errors carry codes only; a renderer exception is caught and dropped without
its text; the scrollback lives in memory only (
client.terminal.scrollback_lines). - Second layer: the screen (
TerminalScreen) leavesonTitleChange,onIconChangeandonPrivateOSCof xterm unset, so a sequence the filter missed changes nothing and answers nothing.
Renderer xterm
Assessed on 2026-10-20 (24 Terminal, 12): xterm 4.0.0 of
TerminalStudio, MIT, published 2024-02-27, the latest release; repository not archived, no
known vulnerability in OSV or the GitHub advisory database, no security report among its
issues. What xterm itself does with hostile output (read in its source):
- OSC 0/1/2 call
onTitleChangeandonIconChange; every other OSC, OSC 52 and OSC 8 included, goes toonPrivateOSC; there is no clipboard handler. All three stay unset here. - It answers DA1, DA2, DA3, DSR 5, CPR (zero-based, a bug:
ESC[0;0Rat the home position; the screen adds one to row and column) andCSI 18 tthroughonOutput;CSI 8;r;c tresizes the terminal. The filter drops all of these but CPR, DA1 and DA2. - DCS is not parsed; its content would be printed as text. The filter drops it.
- Known bugs: palette index 15 (SGR 97) resolves to white instead of bright white; selection
and block cursor are painted over the text, underline and bar cursor in row 0;
TerminalView.onTapUpis never called (the view detects link clicks with its own pointer listener); several sequences throw or hang (see the escape filter above).
The theme comes from the tokens: buildTerminalTheme maps DsColors color.terminal.* to
xterm's TerminalTheme. xterm's cursor and selection are transparent; TerminalMarksPainter
paints both over the view, opaque in the pairings of components.md 12: the cursor cell in
cursor with its character in cursor-text, a selected cell in selection-bg with its
character in fg, and a frame in cursor without the focus (15 23.4, D-M4-35-18).
Layout
| Path | Content |
|---|---|
lib/terminal_ui.dart |
the public library; every public symbol has a doc comment (make lint, public_member_api_docs) |
lib/testing.dart |
test support: FakeHost (answers the handshake with NkResponder) and FakeTerminalTransport; import only from tests |
lib/src/ports.dart |
the ports of 15 23.1 |
lib/src/controller/ |
TerminalController, the tab state, the close reactions, the frame pump (data and coalesced resize), the prompts interface |
lib/src/pinning/ |
the pin check of 15 23.3 |
lib/src/render/ |
the escape filter, the screen (xterm model behind the filter, link records, replay swap), the view, the theme, the paste guard, the semantics |
lib/src/widgets/ |
pane, header, tabs, states, extra keys bar and the dialogs (open, key changed, foreign host, link, the TerminalPrompts with dialogs) |
test/ |
unit, widget and golden tests: the filter, the controller flow, the ephemeral key, the theme, the paste guard, the extra keys bar, the semantics, no terminal content in logs, TM-T14, TM-T18 and the client part of TM-T26; test/support/ holds the fakes of the ports and widget helpers, test/goldens/ the golden images |
Tests
Run from the repository root: make lint test coverage arch COMPONENTS=apps. The API pages
come from make docs (Dart API reference). In the package
alone: flutter test or flutter test test/<file>_test.dart, and
flutter analyze --fatal-infos.
Libraries
- terminal_ui
- The terminal view of every client (docs/spec/15-client-architecture.md 23,
docs/spec/24-terminal.md): TerminalController runs the flow of 15 23.2 through the ports
of
ports.dart; TerminalPane shows a tab with the rendererxtermbehind the EscapeFilter, the header, the tabs, the states and the extra keys bar; the dialogs "Open terminal" and "Key changed" and the warning of 24 8.4 answer TerminalPrompts (DialogTerminalPrompts). - testing
- Test support of the terminal view (brief M4-35 Design note 4): a FakeHost that answers
the handshake with
NkResponderofpackage:bothy_noise/testing.dart, and a FakeTerminalTransport that pairs the controller with it in memory. Import it only from tests; M4-36 and M4-38 use the real host service instead.