TacidOS
Developers
Documentation

Decision 0047

The face is two surfaces, software-rendered, and a client like any other

Architecture decision record 0047: The face is two surfaces, software-rendered, and a client like any other

Status
accepted
Date
2026-09-05

Status: accepted, with its renderer clause amended by ADR-0088 · Date: 2026-09-05 · Refines: ADR-0019, ADR-0021

The shape of the face — three surfaces, each its own process, each a client of the same protocol — is what this record decided and it stands. The renderer does not: tiny-skia was chosen here so the face would come up on a machine whose driver is being repaired, and that machine has no compositor either, so the requirement was already met by the tty and the terminal interface. The cost was that the shell could never be translucent. ADR-0088 has the argument.

Context

ADR-0019 chose Niri and a shell of Tacid’s own in Iced, and left the shape of that shell open: a bar, a command surface, an agent panel, notifications, settings. Written as five surfaces it is a desktop environment, which is months of work and a second place where a machine is configured. The question this record answers is what the first face is, and what it may depend on.

Three constraints made the answer narrower than the list.

The first is that a client is a renderer. Everything the shell shows it must learn by asking the daemon, and everything it changes it must change by proposing a transaction and bringing the owner’s consent to the keeper, exactly as tacid does. A graphical surface that could do more than the command line would be a second API to keep honest, and the day the two disagree the owner is the one who finds out.

The second is what a shell drags in. Iced with its default renderer brings wgpu, and wgpu brings a graphics stack that has to work before a single pixel is drawn. A shell that needs a GPU is a shell that cannot come up in a virtual machine, on a server’s remote display, or on the first boot after a driver change — which is the one boot where a person most needs to see what their machine thinks is wrong. It is also several hundred crates in a workspace whose test run is currently seconds.

The third is the crash policy: any Tacid process dies alone, and the terminal and a tty always work. That applies to the shell’s own parts as much as to the daemons.

Decision

The face is three surfaces, each its own process, all drawn from the tokens and all speaking the one protocol. Three rather than the five the plans wanted, and the third arrived only once there was an event stream for it to render: a panel that polls the daemon is a worse tacid status, and a panel that renders what the machine publishes is the machine thinking out loud.

  • tacid-shell bar — a strip along the top that says what this machine is: its name, the generation running, the channel it follows, the autonomy level in force as a badge, the clock, and a badge when the machine runs code it built for itself. It refreshes every five seconds by asking the daemon, and when the daemon does not answer it says so rather than going blank. It never takes the keyboard.

  • tacid-shell command — one box, opened by the compositor’s Mod+Space, which the person types into without first deciding what kind of thing they are doing. A name that matches a desktop entry opens that program; anything else is said to the agent in a session, and the reply, what it would change, and — when the change needs the owner’s word — the two buttons that give or withhold it appear in the same box. Escape gives the screen back.

  • tacid-shell panel — a column down the side, opened by Mod+A, that fills in as the machine works: the tool it reached for before it ran, the change it wants and why, the question waiting for a yes, and what came of it. It is a renderer of the event stream and nothing more — it holds no state the daemon does not have, and the yes it sends is the same one the terminal sends, to the keeper, which decides whether this principal may give one. It is opened rather than started with the session, because a panel that is always there is a panel a person stops reading.

Notifications and settings remain deliberately absent: a settings application would be a second place where this machine is configured, and the genome is the first.

Software-rendered. iced is taken with default-features = false and the tiny-skia renderer, so there is no wgpu, no Vulkan and no GPU driver on the path between the daemon’s answer and the pixels. A bar and a text box do not need a graphics card, and the machine that cannot start one is the machine that most needs its shell.

Its own workspace. crates/tacid-shell is excluded from the root workspace and built as its own package, with tacid-protocol and tacid-design as path dependencies. The nervous system’s test run does not wait for a renderer to compile, and a headless machine’s closure has no shell in it. The path dependencies are what keep the two from drifting: the shell renders TurnResult — the same structure the terminal renders — and draws design/tokens.toml, the same file the terminal theme and the compositor’s borders are generated from.

The turn result moved to the protocol crate. It had lived in tacid-daemon, so a client had to link the daemon to read a reply. It is the wire shape of session.ask and now lives beside the method that returns it; the daemon re-exports it.

The session is declared, not configured. tacid.desktop.enable composes the compositor’s configuration from the generated colours and Tacid’s own bindings — into /etc/niri/config.kdl when this was written, and into /etc/hypr/hyprland.conf since ADR-0053 settled on one compositor — starts the bar at session start, and puts greetd in front with the smallest greeter there is. Rolling the machine back rolls the session back with it, because the session is part of the generation and not a file in somebody’s home.

Consequences

  • The shell is the fourth client of the same API, after tacid, the terminal interface and the agent itself. Anything it can do can be done from a serial console, which is the test that keeps the protocol honest.
  • The bar says when the machine is waiting for an answer. A change waiting for a word is a machine that has halted, and before this the only way to find out was to open the panel or run tacid pending — which is to say, to already suspect. It is a fact about the machine like every other word on that line, and it is worded as one: “waiting for an answer” and not “waiting for you”, because the keeper holds what it holds and does not sort its queue by who is looking, and on a machine with two people the second wording would say it to somebody who cannot answer. Whether this person may is the panel’s answer and the terminal’s, where the question itself is. A count and not a summary, for the same reason a bar does not truncate a question. The count comes from the keeper rather than the daemon, in the order consent goes: the daemon is the process being authorised, and a number it reported would be a number it could be wrong about in its own favour. Amber rather than red, because a machine that stopped and asked is the design working, and painting that as a fault teaches a person to dread the thing the whole design is for.
  • Notifications as a surface of their own — a toast, the freedesktop protocol, other programs’ messages — are not here. What Tacid itself has to say fits in a word on a strip that is already on the screen, and a second always-mapped surface that is empty most of the time is a rectangle swallowing clicks for the minority of seconds it has anything in it.
  • A machine with no screen builds no renderer. tacid.desktop.enable = false — the default everywhere but the workstation profile — leaves the graphical closure out entirely.
  • Two processes rather than one means the command surface can crash without taking the bar with it, and the bar can be restarted without the person losing what they were typing. It also means the key binding is a spawn, which is what a compositor is good at.
  • The dependency tree brought two pins with it: the software renderer needs its Wayland backend feature to have any backend at all, and the layer-shell crate’s developer tools do not compile against the newest winit beta, so the lock holds the one before it. Both are recorded in the shell’s own Cargo.lock, which is separate from the workspace’s.
  • Notifications and a settings surface are not written, and are not meant to be. The agent’s panel now is: it renders the stream tacid watch renders, so a long turn’s tools can be watched on the screen as well as in a terminal.
  • What has been proven is that the workstation configuration evaluates with the session in it and that the shell builds and passes its own tests. It has not yet been started against a compositor: the graphical test that boots the session, reads the bar off the screen and opens the command surface is written down as the next piece of work, not claimed here.

Alternatives rejected

  • wgpu, as iced defaults to. Faster for a surface that animates, and this one does not. The cost is a graphics stack between the owner and the one window that tells them what is wrong with their machine.
  • One process with two layer surfaces. Fewer moving parts, and a crash in the box takes the bar down with it. The crash policy is not a preference.
  • A separate agent-panel window. It would make a person choose between talking to the machine and using it. One box removes the choice, which is the point of the command surface.
  • libcosmic or another desktop’s shell toolkit. Someone else’s identity, someone else’s release cycle, and a settings surface Tacid did not design.

What the first boot of it found

Written before the session had ever started. It has now, and two things came back.

An emulator cannot show a Smithay compositor’s output. QEMU’s virtio-gpu without host GL is a display-only device: no render node, so Mesa offers no allocator, and Smithay — which Niri was built on — has no software renderer to fall back to the way wlroots has Pixman. Niri came up fully, served Wayland, started the shell, and had no output to give a layer surface: WARN niri::handlers::layer_shell: no output for new layer surface, closing. So the graphical test proves the session on the real path and proves the drawing under a wlroots compositor with the Pixman renderer, where the pixels can be read without a card. Which compositor accepts a layer surface is not what is being tested; that the shell draws words is — and that separation is what later made the compositor itself replaceable (ADR-0053).

A shell whose surface is closed did not notice. The bar was still running five seconds after the compositor closed its surface, with nothing on the screen and nothing said. That is the exact failure this test was written to go looking for — a surface that is running and drawing nothing — and it was the shell’s, not the compositor’s. A session can restart a shell that exits; it can do nothing about one that sits there invisible.

Both surfaces now leave when the compositor takes theirs away, and say so on the way out. The protocol’s zwlr_layer_surface_v1.closed arrives as an ordinary window event, so it is one more subscription beside the clock and the escape key rather than anything special. For the command surface it is the same answer as Escape from the other side: the box is gone, so the process that drew it should be too.