Changelog / 0.2.0
0.2.0
Released 2026-08-11. 79 entries.
This release carries 79 entries, more than fits one comfortable read. It is not paginated further: the release is the unit, not the page. Jump to a category below, or read straight through.
In this release
Added: a machine is a machine, with or without a session on it
The desktop is untouched by all this: it still spawns and owns its own daemon from its own binary, and there is nothing separate to install. The operator side of the server case is docs/SERVER.md.
termuna-daemon is the daemon on its own: shells, the session tree, the mux socket and the relay, with no window bolted beside it. 10MB against the desktop's 32, and no GPU stack, fonts or display connection for a box in a rack to not use. termuna-daemon login joins it to an account by typing what the sign-in screen asks; installer/linux/install-daemon.sh fetches it, joins it and starts it as a user service that survives logout. Per-user, no root, nothing outside your home directory.
A server can join by borrowing a token instead of being told a password: `TERMUNA_TOKEN=<any of the account's tokens> termuna-daemon login` mints this machine its own, with its own name and id. An account password typed into a box in a rack is a password that now lives in that box's shell history and in whatever provisioned it; the borrowed token is used for one call and never written down there.
That is the easy half. The hard half was that a daemon had no address. It reached the relay once per live session it hosted, so a machine was only reachable *through* something it was already holding open, which is fine for a laptop and circular for a server: it could be signed in, listed among the account's devices, and impossible to ask for its first session, because asking required a session.
So a daemon holds one connection of its own now (docs/PROTOCOL.md, the device channel), and the sessions drawer offers a machine whether or not anything is running on it. Two things fall out. Online is finally honest, asked of the machine rather than inferred from its sessions, which used to call a busy server gone and a closed laptop present. And a machine that has not published a key yet is still reachable the old way, so nothing has to be upgraded in step.
Commanding a machine still costs the vault passphrase. The daemon holds a key of its own and publishes it wrapped to the account, exactly as a session's content key is: the relay stores a blob it cannot read, and a stolen device token buys what it bought before.
Added: the account's other sessions are yours to manage
Rename, kill and wake now reach the machine a session actually runs on. Before, every session was read-only from every screen but the one hosting it: the drawer's right-click menu did not even open on another machine's row, because it looked the session up in the local daemon's list and gave up when it was not there.
The verbs are the same ones a local row offers and they are carried, not imitated: renaming a mirror here would have lasted until the next poll, and killing one would have ended the mirror while the shells went on running over there, which is not what the word means.
Clicking a dormant remote row wakes it now instead of mirroring nothing. A dormant session has no connection to the relay, which is exactly why it needs waking, so the request travels through a live session on the same machine and the woken session comes back with a fresh key.
A sealed row still offers no menu. Naming something is the one thing that cannot be done without being able to read its name.
Added: start a session on another one of your machines
The + in the sessions drawer asks where, when there is more than one answer: this machine, or any other one the account can reach. Pick one and the session is created over there, mirrored here, and walked into, the same as clicking a row of that machine's would be.
The phone could already do this and the desktop could not, which was backwards. Everything for it was on the wire already (DaemonQuery, and a reply that carries the new session's share link sealed to the asking session's key). What was missing was the desktop's way to reach it: the GUI speaks no protocol but TSP to its own daemon, and the relay is the daemon's door, so the daemon now does the asking on the window's behalf (MuxRequest::RemoteQuery).
A machine is only offered when it has something live. A daemon has no address of its own: it reaches the relay once per session it hosts, so speaking to one means speaking through something it already holds open, and one holding nothing cannot be asked to start anything. Nor is a session offered as that conduit unless this device holds its key, since the question is sealed to it.
Fixed: macOS stops asking for the keychain on every launch
Two causes, one symptom.
The app read the passphrase out of the keyring at boot and then, having unlocked, wrote the same bytes straight back, because "remember on this device" was true. Every touch of a macOS keychain item is a prompt, so auto-unlock cost two of them to accomplish nothing. It writes only when the value would change now.
The rest was the signature. The bundle was ad-hoc signed and deploy.sh re-signed it again on arrival, and an ad-hoc signature has no identity beyond its own hash. macOS binds a keychain item's permission to the signing identity that asked for it, so every build was a stranger and "Always Allow" never meant always. The build signs with a stable self-signed certificate held outside the repo now, and the install stops re-signing. The designated requirement is the bundle id and the certificate, both of which survive a rebuild.
A stable identity was necessary and not sufficient. macOS gives an app free access to keychain items it created itself and guards everything else behind the item's access list, and the passphrase already on disk had been created by an earlier ad-hoc build. So the app went on being a stranger to its own item. Worse, "remember on this device" updated that item in place, inheriting its access list and re-arming the problem: the write replaces the item now, so what ends up on disk is always something this build owns.
An item stranded by an older build has to go once, by hand, in Keychain Access. After that nothing asks.
Every keychain touch is logged at info level, with its outcome, because each one is a dialog in the user's face. The outcome is the point: a refusal returns as fast as a grant, so a fast call is not evidence that nobody was asked, and reading it that way misdiagnosed this once.
Changed: on macOS the window is a macOS window
Termuna drew its own minimize/maximize/close top-right on every platform. On a Mac that is where no window button has ever been: the app was announcing itself as a port.
So macOS keeps its real titlebar now, made transparent with its title hidden and the content running underneath (fullsize_content_view). The system's own traffic lights float over our chrome bar, top-left, with their real colours, their real hover glyphs, and a green button that zooms the way a Mac user expects. We draw no window buttons there and leave 78px of room for the system's.
The wordmark is gone from the bar on macOS with them. The top-left there belongs to the system's buttons, and the app's name is already in the menu bar where a Mac user looks for it; a wordmark beside the traffic lights says a second time what the screen already says once. The column it occupied stays, empty, because it is exactly as wide as the drawer beneath it and that is what keeps the panel toggles beginning where the grid does.
Keeping the decorations hands back more than the buttons: the rounding, the shadow and the resize edges come with them, so window_radius and our hand-rolled resize strips both stand down on macOS. They exist for the platforms that will not do it for us.
Drawing lookalike lights ourselves would have meant redrawing another platform's own controls, which never survives contact with the real thing beside it.
Fixed: the unlock modal takes the caret with it
Clicking "Unlock" opened a modal that asks for exactly one thing and then made you click the field before you could type it. It takes focus on open now, from either way in (the drawer's locked row, the account screen). Enter already submitted and still does.
The passphrase field carries two ids, one per place the form can appear: the account screen keeps its own copy and the modal can open over it, so a single id would name two widgets at once and focus would have to pick between them.
Fixed: the continuity panel reads differently on each machine
It read the same on all three, which is the one thing it exists not to do. A machine mirroring somebody else's session is itself a viewer at the relay, so the roster it fetches has itself in it, and the panel drew it verbatim under a fixed "this device" row: the computer you were sitting at appeared twice, once as "this device" and once under its own name. Identity is keyed on the device label, not the token id, for the reason the sessions drawer already keys on it: a token is minted fresh on every sign-in, so the id cannot say "this computer".
The machine actually running the session is named now too. A mirror's panel had listed every screen holding the session except the one it runs on, and said "Not shared" about a session it was watching arrive over the relay: it has no share link of its own, only the bridging host mints one, but the cloud list carries it, rebuilt from the content key the vault unwrapped. So the link and its QR are there on a mirror too.
Fixed: a mirrored conversation is one you can take part in
Opening another machine's agent session gave an empty chat, and anything typed into it vanished: the composer sat on "working…" while the far machine never heard a word.
The mirror carried a terminal and only a terminal. Layout and Output came down, Input, Resize and Command went up, and the whole agent lane, AgentFrame down and AgentCommand up, was dropped on the floor with a comment saying a terminal mirror had no use for it. For a managed agent session those frames are not a decoration on the session, they *are* the session: it has no PTY, and the mirror has no agent process of its own.
Both directions now cross. Turns and asks arrive and are sequenced into the mirror's log like output, so a window that reattaches gets the conversation back. Token deltas and the attention nudge are passed through unsequenced, exactly as the far host sent them: the far host already decided those describe a moment and should not be replayed. Upward, saying something, answering a permission ask, interrupting and changing the permission mode all go up the link instead of looking for a local agent that does not exist, and an attached image goes with them, because writing it here would put the file on the wrong machine. The host validates what it is asked, which is where that belongs: bypassPermissions stays refused.
Fixed: a conversation on another machine opens as one
Clicking a remote agent session still landed on a terminal, and for a managed agent that is a blank grid: such a session has no PTY at all. The window had already worked out it was a conversation, which is why the panel beside the empty grid read claude · managed.
Two things asked the question too early. The drawer's cache cannot answer for a session this machine does not host, and the fallback (ask the layout when it lands) was answered by the wrong layout: the daemon stands a nameless placeholder tree up the moment it starts mirroring, so the attach has something to show, and replaces it when the far host's real tree crosses the relay a moment later. The placeholder said "not an agent", and nobody asked again.
So the placeholder no longer answers, and the question rarely reaches it now anyway: the cloud list already knows, which is what the row's glyph was drawn from.
Fixed: a conversation on another machine looks like one
A remote agent session wore the shell glyph in the sessions drawer, so the list called a Claude conversation a terminal until you opened it.
The drawer had no way to know better. A local session carries its whole tree, agent and all; a remote one arrived as a name, a device and a liveness flag, because the relay stores the tree sealed and cannot read it to tell anyone what is inside. It hands the sealed tree over for live sessions now (termuna-website, 2026-08-10) and the desktop opens it with the content key it already unwraps for the title, so the row says what the local ones have always said. Dormant sessions still carry no tree: that would be most of a megabyte of ciphertext to decide a few glyphs.
Reading it takes unwrapping a frame first, not just unsealing a blob: what the relay retained is the host's whole encoded Layout frame, since its job is to replay those bytes to viewers verbatim.
Added: a machine with no connection says so
A device heading in the drawer whose sessions are all idle now carries a quiet offline. Nothing on that machine opens and nothing new starts there, and a heading that looks like every other one promises otherwise.
Only honest because the relay stopped believing a closed laptop (termuna-website, 2026-08-10). A suspended machine does not hang up, it goes quiet, so a MacBook with its lid shut counted as a live host for hours.
Added: Termuna runs on macOS, built from Linux
There is now a macOS build, and it is produced the same way the Windows one is: cross-compiled on the Linux box, shipped to the Mac already assembled. scripts/build-macos-app.sh links an arm64 binary with clang and lld: no osxcross, no zig, nothing on the host that the Windows cross build did not already install, and wraps it in Termuna.app with an icon drawn from the same moon-cursor geometry as every other surface. scripts/macos-qa/deploy.sh installs it per-user into ~/Applications and launches it in the logged-in session.
The one thing that cannot be cross-compiled away is Apple's SDK, which may not be redistributed; docs/MACOS-QA.md says how to copy one off a Mac and covers the rest of the loop, including the two permissions macOS demands before an SSH session may screenshot or type.
First run on an M4: Metal selected on its own, the daemon spawned and a shell attached in 97 ms, first frame at 100 ms, inside the 300 ms cold-start budget.
Added: another machine's session opens here, not in a browser
Clicking a session on one of the account's other machines used to open a browser tab. That was expedient, not right: the desktop is a terminal.
It now opens in the window. termuna-mux::remote is the mirror image of the cloud bridge: there this daemon is a session's *host* and pushes it to the relay, here it is a *viewer* and pulls one down. Same wire, same content key, opposite direction. The mirror is registered as an ordinary SessionHost: same registry, same broadcast channel, same attach_viewer. So the window opens another machine's session by attaching to its own daemon, exactly as it does for a shell downstairs, and not one line of the UI knows the bytes crossed the internet, which is the layering rule, and it keeps the network and the keys in the process that already holds both.
Only two places know the difference: input goes up the link instead of into a shell, and a structural change (a split, a resize) is a *request* rather than an edit: the far host makes it and the layout comes back. Acting locally would fork the tree between two machines, only one of which runs the session. Mirrors are never listed and never persisted: a view of somebody else's session must not come back after a restart as a dormant session of ours.
A guest's resize does reach the host, and the host takes its own size back when the guest leaves: the relay side of that already existed (smallest size across live viewers, guest sizes expiring 15s after the guest stops asserting them). The gap was ours: the window only sends a resize when its own window changes, so a mirror would have been forgotten mid-watch and the session would have snapped back under us. The mirror now repeats its size every 5s.
Changed: a locked machine is one row, and that row is the way in
A machine whose sessions cannot be read yet used to print one "sealed" row per session and offer a faint line of text at the foot of the drawer to do something about it. Both halves were wrong. N identical anonymous placeholders are noise, and noise with no remedy attached is the worst kind; and the exit was quiet in exactly the situation where a person is most stuck, sitting far from the thing it fixes.
Each such machine is now a single bordered row under its own heading: a lock, how many sessions are there, and unlock. It says the true thing (that there are two of them and that they are shut) and it is itself the button. From docs/design/termuna-ui-v6.html, where the three states are walkable with ?s=solo|locked|open.
All or nothing, never half: a session's title is wrapped to the account key the moment its host hears the account has one, and refreshed on every reconnect, so either the account has no key and nothing is readable, or it has one and this device is locked, or it is unlocked and everything is. Checked against production before relying on it: zero sessions with a device and no wrapped key.
Added: the passphrase can be entered where the lock is felt
The vault passphrase was asked for on the ssh screen and nowhere else, so a signed-in person looking at sessions named "sealed" had no way to learn what the word meant or what to do about it: the key was asked for in the one place they had no reason to look. One form now, three doorways: the ssh screen, the drawer (an "unlock to read names" row, which appears only while something is sealed), and the account screen, where a locked passphrase is a row that says what it costs. Unlocking closes the card and re-reads the names immediately rather than leaving "sealed" up until the next minute-tick.
Added: the sessions drawer knows about your other machines
Sign in on a second machine and the drawer stops being a list of this computer and becomes a list of the account. Live and idle stay the top division, because that is the one that decides what you can do: a live session takes a keystroke now, an idle one has to be woken first, and the machines are the division inside each, this device always first. Never the other way round: group by machine at the top and "what is running" ends up scattered across every heading.
Only machines running a daemon appear. The relay stamps a session's device from the token that attached as its *host*, and only a daemon ever does that: a phone attaches as a viewer, so a phone cannot become a heading here. It stays in the account's device list, where it means something.
With one machine signed in nothing changes: nesting a single group under a single heading buys nothing and costs a line.
Which machine you are on is worked out, not looked up: `[cloud] device_id` only exists if the install signed in after that field was added, and trusting it made an older install list its own computer as a stranger, under its hostname, beside the sessions it had already printed. The daemon is the authority on what it hosts: every row the relay reports that the daemon also has is ours, and the device stamped on those rows is this machine's whatever the config believes.
The pieces for this had been in the protocol since M9 and unused: AccountInfo hands the host the account's vault public key, and the host answers with SessionMeta: the session's content key wrapped to the account, and the title sealed with that key. The relay stores both and can read neither. So the desktop lists the account's sessions with the device token alone, and opens their names with the vault passphrase: locked, a machine shows how many sessions it has and calls them "sealed"; unlocked, they have names. Nothing is faked in between.
Clicking one opens it in the browser through a share link rebuilt from the unwrapped key: a session this machine was never handed a link to. The desktop is a host, not a viewer, so that is the honest way in.
Fixed: signing out lets go of the account's sessions, and says so first
A session bridged under one account stayed bound to it forever. Sign in on that machine as somebody else and the daemon reconnects the same session id with the new token; the relay's upsert carried a WHERE sessions.user_id = ? guard, so the row silently refused to move while the socket stayed open and the host streamed happily into a session its own dashboard would never list. The app showed it live, synced, with a share link. The cloud had never heard of it. Nothing anywhere said why.
Sign-out now asks first, in a card that names what it costs: this machine stops syncing, N sessions leave the cloud and their share links stop working, and (the half worth saying) they keep running here. Confirming releases each of them from the account (`POST /v1/sessions/{id}/release, which unlike DELETE` does not refuse a running session, because that is exactly the case) before the credentials that prove they are ours are dropped. A relay that cannot be reached does not block it: leaving an account has to work offline.
On the relay, a host attaching to another account's session is now refused outright with a reason instead of accepted and orphaned, and the SessionMeta write is guarded by owner: it had none, so the new account's host was overwriting the old account's wrapped key with one wrapped to the wrong vault, leaving a row its owner could no longer open.
Fixed: signing out signs the daemon out too, on Windows as well
The window signed out and the daemon carried on. The call that tells it to drop its cloud settings was wrapped in #[cfg(unix)]: a leftover from before the daemon spoke over a named pipe (MuxConn::connect has had a Windows implementation since 2026-07-23, and set_cloud was never gated). So on Windows a sign-out cleared the window and left the daemon bridging every live session to the relay under the old device token, still minting share links, until somebody restarted it. It is no longer conditional on the platform.
Sign-out also took only half of the account with it. clear_cloud_auth removed token and email and set enabled = false, leaving name, avatar_url and device_id in config.toml and their copies in memory. The next account to sign in on that machine was greeted by the previous one's name and picture until a profile fetch happened to correct them, and the stale device_id named a device row the install no longer owned. Every key [cloud] holds about a person now goes; the relay and share-base URLs a self-hoster set stay, because those are not the account's to take away.
Added: point a phone at the share link
The continuity panel can now show the share link as a QR code, under "Scan with phone". A share URL is a capability: …/s/<session>#k=<key> which nobody is going to type into a phone, and copying it there needs a channel that then holds the key. A camera needs neither.
The code is generated on this machine (qrcodegen, no dependencies of its own, no network): running a capability URL through somebody's QR service would hand them the session, which is the same rejection the relay gets. It is hidden until asked for, and hides again with a second click, because the line the panel prints beside it deliberately shows the place and not the key, and a QR shows everything to anyone who can see the screen. It follows the link it stands for: a session that re-bridges is issued a new key, and the code is regenerated rather than left pointing a phone at something it can no longer decrypt.
It is drawn as a light card even on a near-black panel: a reader thresholds on luminance and the inverted-code case is still where cheap scanners give up. Verified by decoding it back out of a screenshot of the running app: the payload matches the URL the daemon minted, key fragment included, and still reads at half the screen's resolution.
Added: the window has corners
Termuna's window is rounded now: 9px, square again while maximized, because a rounded corner flush against the screen edge is not a corner but a notch showing the desktop through a screen that should be full.
The window has drawn its own chrome since v6, so the rounding is drawn too rather than asked for: the surface is transparent, and the two widgets that own the window's corners: the chrome bar at the top, the status bar at the bottom: carry the radius. Under them the view sits on an opaque ground rounded to the same radius, because a transparent surface means a widget that paints no background of its own no longer falls back on the application's colour but falls all the way through to the desktop; the split dividers, which were deliberately drawn at alpha 0.001, did exactly that, and now paint their own seam. The only see-through pixels in the window are the four corners. Without a compositor those pixels are undefined and the window simply looks the way it looked before.
Fixed: a narrow window keeps its titlebar
Below 720px the chrome bar used to disappear: v6 defines that width as the grid and the status bar and nothing else. That is right for what the bar draws and wrong for what it *is*: with OS decorations off it is the window's only title, drag handle and set of window controls, so a narrow window could not be moved, minimized, maximized or closed with the mouse, and lost its two upper corners with the bar. The bar is now in every width class, stripped at quake to the mark, the session name and the three controls: the panel pins go, because there is nothing to pin at that width and they were dead buttons, and the share link goes for room. The tab strip is still the first thing a narrow window gives back.
Fixed: one right-click, one menu; a tab looks clickable
Right-clicking a tab opened two menus stacked on each other: the tab's own, and the pane context menu behind it. Canvas events reach every pane whether or not the cursor is on that pane, and the terminal's right-press handler took the cursor's window-absolute position without first asking whether the click had landed inside its own bounds, so it answered right-clicks meant for the tab strip, and for the drawers and panels too. It now opens only for a click that is actually in the pane, which also means a right-click in a split can no longer be attributed to its neighbour.
Tabs also show the pointer cursor on hover now, instead of the plain arrow. The close × already did; the tab itself did not, and a click target that does not say so reads as decoration.
Changed: asking a tab a question no longer switches to it
Right-clicking a tab used to activate it first, which meant you could not so much as read the menu of another tab without leaving the session you were working in. It did that because every row the menu offers (move left/right, close to the right, close others) acted on whatever tab was active. Those rows now name the tab the menu belongs to, so the menu can be opened, read and used on any tab while the one you are working in stays on screen. The keyboard shortcuts beside them still belong to the active tab, so they are shown only when that is the same tab; a hint that would do something else is worse than no hint. Moving a tab from the menu also closes it, instead of leaving it hovering over the slot the tab has just left.
Fixed: dragging a tab feels like carrying it
Tab drag-reorder was rebuilt. Three things were wrong with it:
- It never let go. The release was only heard by the strip itself, so a drag that ended anywhere else (over the grid, over a panel) left the tab still attached; the next time the pointer crossed the strip, with no button held, the tab shuffled again. The drag is now followed at the window and ends wherever you let go.
- It landed in the wrong slot. The drop target was computed from a tab width the drag worked out for itself, and its arithmetic had forgotten the search and split glyphs at the end of the strip. Past a handful of tabs it disagreed with the strip you were looking at. There is now one definition of the slot pitch and both read it.
- It juddered. The target came from "which slot is the pointer in", but the strip reorders under the pointer, so the tab chased itself across every boundary. It is now measured from the press: half a slot of travel per swap, in either direction, and a drag out and back leaves the tab exactly where it started.
The chip you carry was redrawn to match the rest of the app: chrome tokens instead of the terminal colour scheme, the tab's own pitch and ordinal and mono face, the accent rule the active tab wears, and a neutral depth shadow: it floats, so it casts a shadow, and never a glow. It also sits where the tab would be rather than 50px left of the pointer, which is where the old ghost drew itself whenever the sessions drawer was pinned. The pointer reads the strip as a grab surface while a tab is in hand.
Fixed: a rebuilt shell starts on a clean screen
The "session rebuilt" line, the new prompt and (for an agent session) the program restarted into it were all being drawn on top of the screen the dead process left behind: interleaved with its corpse, with the cursor stranded wherever that process had parked it. A pane whose shell has just been replaced now clears its screen first. The scrollback above is untouched: that is history, and it is the reason to resurrect a session at all.
Fixed: a repainting pane's log is recognised even when it starts mid-repaint
The last hole was the one that could not be seen from inside the bytes. A pane's retained log is capped, so for a program that repaints continuously the window almost always begins *inside* the repaint, the sequence that entered the alternate screen was evicted hours ago, and nothing that remains says what it is. The filter had nothing to recognise, so it replayed the lot: fragments of frames scattered across the screen, which is what a resurrected agent session looked like.
The daemon watched those bytes go past, so it is the one that knows. It now records which panes were repainting when it last saved the session, and a log read back for those panes is discarded rather than replayed. Logs written before this carry no such record; the ones belonging to agent sessions were moved aside on this machine, since their contents could never have been shown.
Fixed: no program's modes outlive the program
The clicks that printed 64;51;11M had one more hiding place: the chunk in which a program takes the alternate screen usually turns mouse reporting on in the same breath, and that chunk was still being kept as history. Replay it at a pane now running a fresh shell and the mirror believes the mouse is wanted again.
An alt-screen span now leaves the log whole: the entering chunk with it, and the same filter runs over logs read back off disk, so sessions recorded by older builds are cleaned as they load. On top of that, every pane's mirror is handed a switched-on terminal (no alternate screen, no mouse reporting, no bracketed paste, cursor shown, attributes and scroll region cleared) before a single line of history is replayed onto it.
Fixed: a rebuilt session no longer types mouse codes at you
Scrolling in a session the daemon had rebuilt printed streams of 64;47;22M into the shell. The modes belonged to the program that died with the old daemon: a full-screen TUI turns mouse reporting on and every viewer's mirror still held them, because a mirror is not restarted when the shell under it is replaced. The wheel dutifully sent mouse reports; the fresh bash had never asked for any, so it printed them as text.
A rebuilt session now says outright that its terminal is new: leaving the alternate screen, mouse reporting, bracketed paste and application cursor, cursor visible, scroll region and attributes reset: sent to every viewer just before the "session rebuilt" line.
Fixed: repaint frames are no longer kept as history
Output a program writes while it holds the alternate screen is one screen being redrawn, not scrollback. It was being retained anyway: kept in the replay log, persisted to disk, and handed back on the next attach or resurrection, where it could only arrive as fragments of frames. It is now sent live and not kept, which also stops a single TUI session from filling the retained log within minutes, as the roadmap noted it did.
Fixed: reattaching to a full-screen program no longer shows torn glyphs
Opening Termuna onto a session running Claude Code (or vim, or htop) painted a mangled screen: fragments of several redraws stacked on each other, words fused together, nothing readable.
The cause is one the roadmap already described for phones, which the desktop had been spared only because it used to stay attached from the moment a session began: until launch started walking into an existing session. A joining viewer builds its screen by replaying the daemon's retained output, and for a program that repaints in place that window holds the tail of a repaint stream: half-drawn frames with no beginning. Replaying it can only produce garbage.
So the daemon no longer replays it. It now tracks which panes hold the alternate screen (watching the mode sequences go past: it still does not emulate), replays each such pane's history only up to the moment the program took the screen, puts the viewer's mirror on the alternate screen, and nudges the pty size so the program repaints itself in full. The viewer gets one clean frame, drawn by the program, at the right size, and the shell history from before is intact underneath, back on screen the moment the program exits.
Added: eleven things a daily-driver terminal is expected to have
Surveyed against Tabby (the terminal the maintainer was switching from) and picked what earns its weight:
- Paste guard. A multi-line paste shows what is about to run, line count, character count, the text itself: before a single newline reaches the shell, because a paste is keystrokes and the second command runs before the first can be read. Every paste is also normalised: CRLF and lone CR become LF (a Windows clipboard would otherwise submit twice) and trailing whitespace goes.
[terminal] warn_on_multiline_paste,trim_paste. - Search options. Match case, whole word and regular expressions, as three toggles beside the hit counter. A half-typed pattern finds nothing rather than erroring.
- Zoom that stays zoomed. Ctrl+Shift+plus/minus now writes the size to the active appearance profile, and Ctrl+Shift+0 returns to what that profile says.
- Copy current path (Ctrl+Shift+P): the focused pane's working directory to the clipboard.
- Word separators (
[terminal] word_separators): what a double-click treats as one word. The default keeps paths and URLs whole. - Reopen closed tab (Ctrl+Shift+R), five deep.
- Broadcast input: type into every pane of a tab (Ctrl+Shift+I) or every tab of the session (Ctrl+Alt+Shift+I): four servers, one keystroke. A warm border and a line above the grid say it is on; esc or a click in any pane ends it.
- Maximize pane (Ctrl+Shift+Z): the focused pane fills the tab and gives it back on the next press. The others keep running.
- Directional pane focus (Ctrl+Alt+arrows): the pane actually to the left, by geometry, not the next one in the tree.
- Notifications for background tabs: one when a quiet tab stirs, one when a command rings the bell. Both off by default:
[terminal] notify_on_activity,notify_when_done. - Progress in the status bar. Long commands announce themselves in the only language they all speak (
47%) and the status bar reads it back.[terminal] detect_progress.
Also: a minimum contrast floor ([terminal] min_contrast, WCAG ratio) that lifts unreadable foregrounds: the fix for a scheme's own dark blue on a dark background. Off by default at 1.0: a colour scheme is its author's work until the user says otherwise.
Changed: the right button belongs to the terminal, always
Right-clicking inside a full-screen TUI (Claude Code, vim) forwarded the click to the application and left no way to reach copy, split, search or close-pane: exactly when a user wants them. Mouse reporting now gets the left button, the drags and the wheel; the context menu is ours in every mode, no modifier needed. This matches the terminals people switch from (Tabby intercepts right-click before its emulator sees it) and costs almost nothing: TUIs binding button 3 are vanishingly rare, and one still exists: shift is not needed for the menu any more, so the hint below simply teaches selection.
Added: the status bar says who owns the mouse
Drag inside a full-screen TUI that tracks the mouse: Claude Code, vim, htop, and nothing gets selected, because the application asked for those events and gets them. Termuna has always had the escape hatch (hold shift and the mouse is the terminal's again: select, copy, and shift+right-click for the pane menu), but nothing said so, so it read as a broken terminal.
Now the first drag an application swallows lights the answer in the status bar, in place of the session name: "the app owns the mouse · hold shift to select, shift+right-click for the menu". It leaves when the button does. No popup, no toast: the line that was already there simply answers the question being asked.
Fixed: a deleted (or revoked) shared vault now leaves the machine
When an owner deleted a shared vault, or revoked this account's membership: the other machines kept showing it forever, and worse: their next sync quietly *recreated* the vault in the cloud from local copies, owned by whoever synced first. Two changes close it:
- Sync never creates a shared vault from a name's mere presence. The only creations a sync performs come from a durable record of this device's own explicit "New vault" actions. Local copies of a vault someone deleted have no power to bring it back.
- The cloud's shared-vault list is reconciled before the push. Vaults it does not mention disappear locally, connections included. The list is never substituted with an empty default, so a failed fetch cannot masquerade as "everything was deleted".
Changed: the wordmark is back, on the chrome bar
The brand came off with the old picker and never landed anywhere. It now rides the chrome bar's top-left: beside a pinned drawer the wordmark claims the drawer's column and the panel toggles begin where the grid does; narrower layouts carry the compact mark.
Changed: the organization's owner manages its vaults here too
The cloud now grants an org's owner management over vaults shared inside that organization; the desktop follows the server's manage verdict instead of the raw membership role. For an org owner, a teammate's shared vault is no longer read-only: hosts edit, members revoke, the vault deletes, while plain members keep the read-and- clone model.
Changed: the share popup says who owns the vault
The members list now shows everyone including yourself ("(you)"), owner first, each with their role chip, plus one line of the rule: members read and use, only the owner edits, shares and deletes. Revoke buttons appear only when you are that vault's owner: they were shown to everyone before, promising a verb the server refuses.
Changed: shared vaults are read-only for members
A vault shared with you is now yours to use, not to manage: matching the server, which refuses member writes since the same-day cloud change. The desktop knows its role in every shared vault from the sync: read-only vaults say so in the connections tree ("shared · read-only", no members/delete buttons), their hosts' menus keep connect but disable edit/remove/share ("owner only") and offer clone to My connections instead: an editable copy in your own vault. Read-only vaults are dropped from move targets and the host form's vault picker, and sync no longer pushes into them (a round trip per profile for a stack of 403s).
Changed: the command palette grew into a real launcher
Ctrl+K is now organized like the launchers it competes with: a search row with its glyph, small-caps section headers, an icon on every row, and a footer that spells the keys (esc close · ↑↓ navigate · ↵ open). Three sections: actions (tabs, splits, search, screens, settings), sessions (every known session with its freshness: "live", "3h ago", and provider), and ssh connections (every saved host as user@host:port, one Enter from a shell on that machine). Before a keystroke the palette shows a curated front page: all actions, the five freshest sessions, the first five hosts; typing searches everything, label, meta and section alike, so "ssh prod" finds the prod box.
Fixed: opening links on Windows opens the browser, not Explorer
"open" on the continuity panel's share link (and every other link the app opens) went through explorer <url>, which drops the URL's #fragment (where a share link carries its decryption key) and then falls back to a File Explorer window. Links now go through the shell's own URL handler (ShellExecuteW), fragment intact. Verified on the Windows QA machine: the share link lands in the default browser and the web viewer decrypts the live session.
Fixed: the Windows build compiles again
The cloud bridge's dead-link detection (TCP_USER_TIMEOUT) is a Linux-only socket option and broke the MSVC target when it landed; it is now cfg-gated to Linux/Android. Windows keeps the keepalive schedule and relies on the relay-side heartbeat for blackholed links. Verified live on the Windows QA machine.
Fixed: the tab strip's verbs can no longer be pushed off screen
With enough tabs open, the search and split buttons (and eventually the +) slid out of the window: the strip trusted a width estimate, and the buttons' default padding made them wider than the estimate said. The right end of the strip is now a fixed section the tabs physically cannot displace: the flexible part is clipped instead, and the controls carry explicit padding so the tab math and the layout agree. Docs: CONFIG.md now also documents the [quake] global-hotkey section.
Removed: the full-window picker; the app is the session window now
The old home screen (sidebar + full-page sessions/agents/mcp/ssh screens) is gone. The v6 session window already carried everything it offered: the drawer lists and switches sessions, the management screens open over the grid, so the picker had become a second, inconsistent way to show the same things. There is no longer any destination outside a session:
- Launch always lands in a session. The one the window was closed on when the daemon still has it (resurrected if it had gone idle); otherwise the newest live session; otherwise a fresh shell. The first launch opens at 1440x860: wide enough that the sessions drawer and the continuity panel both hold their columns, so the whole product is on screen at once. After that the window reopens at whatever size it was closed (the minimum stays 400x260; every narrower density still works).
- Ending a session never strands the window.
exitin the last shell (or killing the current session) walks into the newest other live session, or starts a fresh shell when it was the last one. A dormant session is never resurrected uninvited. - Failures live on the connecting screen. A daemon that cannot be reached or an attach that fails shows the error in-window with retry and new-session actions: there is no picker to fall back to.
- Quake can summon screens too. A palette command opens agents/mcp/ssh/settings over the grid at any width now; quake no longer detours anywhere (the old behavior left for the picker).
- The "back to sessions" actions (palette, chat header) now open the sessions screen over the grid: the full filterable list the old picker had, same rows, same badges.
- Internals for future work:
Phaseis justConnecting/Terminal,app/picker.rsbecameapp/screens.rs(the management screens + modals, one routerview_nav_content),PickerNavisNavScreen, and the picker-only sidebar/rail/cross-fade/slide machinery is deleted. The[ui] sidebar_collapsedkey is parsed but ignored.
Changed: one window, three densities (UI v6)
The session window now scales its chrome with its width (docs/design/termuna-ui-v6.html): power grows with the pixels you give it, and growing the window never adds chrome by itself:
- Narrow (under 720px, quake): the terminal grid and the status bar. Nothing else: the drop-down window costs zero chrome.
- Default: the tab strip and the grid, as before. The sessions drawer and the continuity panel open on demand as overlays (toggles in the tab strip / chat header) and give every pixel back on esc or a click outside.
- Wide (1400px and up): a pin turns each panel into a real column, sessions drawer on the left (live and dormant sessions, attention badges, agents/mcp/ssh/settings nav, account), continuity panel on the right (which screens hold the session now, the share link, the session's facts, the encryption state). Pins persist across restarts.
From inside a session, the nav no longer leaves for the picker: agents, mcp servers, ssh, settings and account open in the centre column over the grid, behind a bar with the way back: the shells keep running underneath, and esc returns to them. Switching sessions happens in place from the drawer, including into an agent's chat.
New: a status bar on every width: the mark, the session and where it is, this process's measured memory, and whether it syncs (e2e) or stays local.
New: the window comes back the way you left it. Closing (the × or the WM's close) persists the unmaximized size, whether the window was maximized, and the session on screen; the next launch opens at that size, re-maximizes if needed, and walks straight back into that session: resurrecting it if it had gone idle. Leaving for the picker first means the next launch lands on the picker, and a session that no longer exists falls back there too.
New: the window draws its own chrome bar (OS decorations are off), panel pins on the left, the window's name centred, the session's web link and minimize/maximize/close on the right. The bar drags the window, a double click maximizes, and invisible edge strips take over border resizing. Quake width shows no chrome at all, as before.
Luna Dark now paints the grid the way the design draws it: ink-black background, quiet grey prose, and the chrome's accent family as the ANSI hues (green #4fd6a8, blue #6e9fd4, red #d96a5f, yellow #d6a24f): a default prompt looks exactly like the mockup. The ramp stays honest (green is green); the cursor keeps the luna brand teal. The active tab now merges with the grid and carries a 2px accent on its top edge; tab titles are set in the mono face. The default grid metrics are the design's too: 12.5px JetBrains Mono at 1.65 line height (existing configs keep whatever they say). Grid text is pixel-snapped, and bold text renders the real Bold face: the bundled bold TTFs carried a wrong weight class (558), which made the renderer lay synthetic bold over the already-bold outlines: every prompt came out smeared and overweight.
Fixed: a conversation you come back to comes back with itself
Three faults, one symptom: opening a chat you had left gave you an empty screen, or a screen where the agent looked like it was re-running your last message.
- The conversation was never written to disk. The session log is persisted while frames arrive, at most every three seconds, and the frames that matter are the last ones. A turn ends, nothing else is sent, and everything since the previous write is still only in memory, so a dormant chat came back empty. It is now written at the moments the flow stops: the end of a turn, an ask the agent is blocked on, and when the agent process goes.
- "Continue" from the agents list did not continue anything. The session id being resumed was dropped on the way into session creation, so it started a *fresh* conversation in the same directory: a stranger who had never met the work. It is passed through now.
- A resumed conversation now arrives with its history. Such a session is new and has no log, and the CLI loads its history into the model's context without saying a word of it on the stream. The host reads the provider's own transcript once, at resume, and seeds the replay log with it (last 200 turns), so every client: desktop, web, phone: gets the conversation it picked. This is not the transcript *tailing* ADR 0005 removed: nothing follows the file, and every frame after the seed comes from the SDK stream as before.
Seeded frames carry termuna_replay (docs/PROTOCOL.md) and no client starts a turn on one. Without that, an old message read as a message being picked up and the composer sat "working" on a turn that had ended days ago, which is what "it re-sent my message and the agent worked on it" actually was.
Changed: an agent conversation is headed by the agent glyph
The chat header led with the brand mark, which says "Termuna", and the window already says that. It now carries the same agent glyph that marks "agents" in the sidebar and every agent session in the picker, so one picture means one thing wherever it appears.
Fixed: the effort dial showed the wrong model's levels
The running model is reported by its *resolved* id, claude-opus-5[1m], which is a different string from the one set_model takes, opus[1m]. Matching the running model against the catalog by that second string found nothing, so the dial fell through to the default entry and offered its levels instead of the running model's: five where Sonnet takes two, say. The catalog carries resolvedModel; it is now read and matched on, here and in both other clients.
Fixed: a deleted connection stays deleted
Removing an SSH host took it out of the local vault and left the copy in the cloud alone, so the next sync: the one that runs at startup, pulled it straight back down. Delete, restart, and there it was again.
The vault now records the deletion as well as performing it. A sync carries those records up before it pulls anything down: each one becomes a DELETE of that item in the vault it belonged to, and only then is it forgotten. A deletion made offline, or against a shared vault this account may not write to, stays recorded and is filtered out of the merge, so the connection cannot come back while the deletion is still owed. Deleting now also syncs immediately when the cloud is configured, so the other devices hear about it without waiting for a restart.
Verified end-to-end against a local cloud service: delete online (the cloud item goes with it), and delete with the service down, then restart with it back up: the connection stays gone and the deferred delete lands. Records are capped at 256 so one that can never be flushed cannot grow the file forever.
The other half of that is a second device that still holds the connection and has not heard: on its next sync it would push the profile straight back. The relay now remembers deletions too (termuna-website, schema v12), refuses a push of a deleted id, and lists the deletions beside the items, so a sync also carries them *down*. A connection deleted on the phone disappears from the desktop, and a device that was offline through the whole thing cannot undo it when it returns. A deletion heard from the cloud is applied without being recorded again: we heard it there, so telling it back would be an echo. Against a server too old to send the list, nothing is dropped and the behaviour is exactly what it was before. Verified with a real GUI as one device and the API as the other, in both directions.
Fixed: half the model menu did nothing
Picking Opus or Fable had no effect at all: the daemon dropped the request before it ever reached the agent. Its model ids are validated by a checker written for shell words, and the CLI names two of its own models opus[1m] and claude-fable-5[1m]: brackets and all. Both were refused as junk.
The refusal was silent, and the label went on truthfully showing the model that was still running, which is what made it look like the pick had been ignored rather than rejected. It had been rejected.
Model ids now have their own check, wider by exactly two characters and no more: still not a shell sentence. The launch path that does build a shell line quotes the model, so a bracketed id cannot be read as a glob. This applied to starting a session as well as switching mid-conversation: launching *on* Opus was refused the same way.
Fixed: the model and effort you pick actually stick
Picking a model changed nothing on screen, and the composer sometimes went to "queue another message…" and stayed there. Two faults behind one symptom, both about a frame the CLI sends that we were reading wrong.
The model label waited for a system/init frame naming the new model. That frame comes once, when a session starts, and never again, so the label sat on whatever it said before, or on nothing. The daemon now asks get_settings right after any set_model or effort change and lets the answer be what the clients show: a request we sent is not evidence it was honoured.
What the CLI *does* send back is a user-role frame reading <local-command-stdout>Set model to sonnet (claude-sonnet-5)</local-command-stdout> shaped exactly like our own echoed message, down to isReplay: true. Only the wrapper tells them apart. We were reading it as a message being picked up, marking the agent busy, and then waiting forever for a result that was never coming, because no turn had started. The chat log already filtered these; the busy flag did not, and now does.
Fixed: the chat says which effort it is on
The effort dial drew five identical dots with none of them filled, and the model row read default, until the first message had been sent. Neither was a display bug: nothing had ever asked. The CLI does not volunteer its model until the system/init frame, which does not arrive until a session starts, and it never volunteers the effort level at all.
The daemon now sends a get_settings control request right behind the initialize it already sent. The reply says what the agent is actually running on, so the menu shows the real model and the real effort level before a word is typed, and the effort is named next to the dots, not only drawn, because five dots and a hover tooltip do not tell you which one you are on.
Changed: the agent's own catalog, instead of ours
The model picker listed four names Termuna had hard-coded: default, sonnet, opus, haiku, and the slash menu ten commands from the same kind of table. Both were guesses that could only go stale.
The daemon has always sent the CLI an initialize control request at spawn, and the reply carries the CLI's own catalog. Termuna now reads it:
- Models come with the names and descriptions the CLI gives them, "Opus (1M context) · Opus 5 with 1M context · Best for everyday, complex tasks", and the list is whatever that install actually offers.
- Effort levels are per model.
maxis a real level and the hard-coded four had hidden it; a model that takes no effort now shows no dial rather than a dial that would be refused. - Slash commands are the CLI's own: sixty of them here, including every skill and plugin the user has installed, which no table of ours could have listed. Whether picking one runs it or drops it in the composer follows the CLI's own argument hint:
<required>waits for you,[optional]and<optional …>go straight out.
The static tables stay as the fallback for providers that answer no such request, and for the moment before the first one arrives. Names and descriptions are clipped to their columns, because iced draws unwrapped text at full length rather than shrinking it, and two unclipped columns in a fixed-width menu write over each other.
Added: the rest of the agent's control surface
The CLI's SDK protocol carries more than "say this" and "allow that"; Termuna now speaks the parts a person actually reaches for. Four additive AgentCommand variants (docs/PROTOCOL.md), each validated host-side and each mapped to the CLI's own control request:
- Switch model mid-conversation (
set_model). The menu lists the daemon's catalog for the provider plus whatever the CLI reports it is running, and the tick follows the CLI'sinitframe rather than our last request: a refused switch must not leave the label lying. - Thinking on or off (
set_max_thinking_tokens). A switch, not a budget: a person decides *whether* the model should think, and the daemon turns that into the SDK's number. - Effort: whichever levels the running model takes (
apply_flag_settings), as a named row of dots in the modes menu. - Rewind the files to before any of your messages (
rewind_files). A dry run goes first, so the card names the files it would restore before you commit to it; when the agent has file checkpointing turned off it says so in the agent's own words, and which setting turns it on. This restores *code*, not the conversation: the CLI has no conversation rewind on this channel, and pretending otherwise would be the wrong promise.
Everything the CLI already reported and Termuna was discarding is now read: system/init names the model, system/status corrects the permission mode (so a /model or a plan-mode exit shows up even though it happened without us), result carries the model's context window, compact_boundary empties it, and TodoWrite calls are the agent's plan.
- A context meter in the header:
42% context, click to compact, instead of a token count with no denominator. It appears after the first answer, because that is when the CLI says how big the window is. - The agent's plan, as a count in the header and a floating panel that does not reflow what you were reading.
- Queued messages are shown when an interrupt reports them still waiting.
- The rewind affordance appears only on turns the CLI gave an id for; a message drawn optimistically now inherits that id from the echo instead of dropping it.
Added: the agent chat gets a composer that can do something
- Permission modes. A control beside the send button puts the agent in manual (asks before every edit and command), **edit automatically, or plan** (explores and proposes, changes nothing). Shift+Tab walks the three. The daemon and the wire have carried
SetPermissionModesince M11; until now nothing on the desktop sent it.bypassPermissionsis *not* offered, because the daemon refuses it from any client and a button that silently does nothing is worse than no button. - A
+menu: upload a picture from this machine, or start an@mention to point the agent at a file. - A command menu on the
/button: everything the composer can do, in one filterable list: attach, mention, the provider's own slash commands, the modes, and the way back to sessions. Commands that need an argument (/model,/resume) land in the composer for you to finish; the rest are sent as they stand, because there is nothing left to type. Enter runs the first row the filter leaves standing. - Esc stops the turn, and the working row says so: it now carries a live clock (
working · 12s) instead of a bare "working…".
Changed: the chat, on the v5 tokens
- The composer is one card: the message line, and under it the controls that decide what the message can do. The send square lights when there is something to send and becomes a stop square while the agent works; the placeholder says *queue another message…* rather than pretending Enter does the same thing it did a second ago.
- An empty conversation shows the mark, the agent's name, and where to start, instead of a grey sentence apologising for being empty.
- What you said sits in a quiet bordered block; what the agent said is simply the page. The filled accent bubble made every question shout louder than every answer. Prose is Inter whoever wrote it, code stays monospace and highlighted, and streaming text now arrives in the same furniture it settles into rather than flashing a box on the way in.
- The header is icons and a hairline: the project, the token count, the terminal view where there is one, and the way back.
Changed: the SFTP drawer, redrawn
- The files pane followed the redesign in `termuna_files_pane_redesign .html`: a header with sort, refresh and close; the host and path above the filter; folders and files as named groups; and a footer that says what is here (
127 items · 1.2 MiB) beside New and Upload. - Sizes live in a column of their own, right-aligned, so you can read down them. They are written short:
68.8 M,2.6 K,0 B, because454.5 KiBwrapped to a second line and dragged the row with it. - Dotfiles are folded away behind
hidden · N entriesand shown dimmed when you ask. They are most of a home directory and almost never what you came for. - Row actions appear on hover, in place of the size, so nothing shifts when they arrive and no row carries a pair of buttons it does not need. File icons are one page shape with a different mark inside, archive, code, patch, plain: tinted by kind: archives amber, configuration and code blue, everything else quiet.
- The drawer is a card: 330px with a hairline, an 8px radius and a neutral shadow, floating over the terminal. Nothing is painted behind it but its own margin: a black strip beside the card would hide work the card itself is not covering. Folders carry the accent at rest and brighten under the pointer, the breadcrumb names the folder you are in rather than the path you climbed, the filter has its magnifier inside it, and each group folds its own dotfiles away behind its own count.
Changed: one way out, one settle, and no work for a click that changes nothing
- The ⌂ button is gone from the tab strip. The rail's own sessions is the way back, and two of them was one too many.
- Screens cross-fade when you pick them: out to the background and back, 190ms end to end, with the swap under the curtain so neither screen is ever seen half-drawn. (iced has no opacity for arbitrary widgets; the fade is a page-coloured curtain drawn over the content, which costs no layout: the earlier version moved the content and juddered for exactly that reason.)
- Entering a session collapses the sidebar instead of cutting to the rail, and leaving one opens it again. It is the same motion as collapsing by hand, labels and all, because it is the same slide.
- Clicking the screen you are already on does nothing. It used to re-run that screen's loaders: a status line, a round trip to the daemon and a flicker, in exchange for no change.
Added: a live session keeps the rail
- Opening a session used to take the whole window and the nav with it: every other screen was behind a detach. A session now keeps the collapsed rail on its left: the mark, the five destinations as icons, the account disc, so sessions, agents, mcp, ssh and settings are one click away.
- The rail in a session is always collapsed and has no expand handle: the width belongs to the terminal. Hovering an icon still names it.
- Clicking one leaves the session for that screen. The session keeps running; the daemon owns it, which is the whole point of the daemon.
- The terminal grid, the tab strip and the divider drags all measure from the window less the rail, so the PTY is sized to what you can actually see.
- The "connecting…" moment now wears the chrome of wherever it is going: the rail on the way into a session, the full sidebar on the way back, so the sidebar no longer blinks out and back between two screens that both have one.
Added: the sidebar collapses to a rail
- A round handle rides the seam between the sidebar and the work, at the wordmark's height, and takes the sidebar down to a 46px rail: the moon mark, then one icon per destination, then the account disc. Hovering an icon names it, and says its count too: a rail you cannot read is a rail you cannot use.
- The sidebar slides between the two widths in 180ms and the handle rides the seam the whole way; its chevron turns through half a circle as it goes, so the button says it reverses itself rather than just swapping for its mirror image.
- Labels appear and disappear on the width, not on a fraction of the animation: they are drawn while there is room for them, which is what keeps one from painting over the work beside it mid-slide.
- The rail is a 16px glyph centred with a gutter's worth of air, no wider. Expanded, the glyph column lines up with the wordmark above it, as the mockup's
padding: 7px var(--gut)intends. - The choice is remembered (
[ui] sidebar_collapsed). A layout you chose that every launch undoes is not a choice.
Changed: settings, laid out the way the design draws it
- All four sections are the mockup's
.srrows now: the name of the setting, one line saying what it does to you, and its control on the right, under hairline rules that group them (profile / type / cursor / preview, scrollback / clipboard / quake mode). Bare−and+buttons became one stepper, on/off buttons became switches, and the cursor shape became the same chips the rest of the app uses. - Keybindings gained a search box. Sixty actions is a list you search, not one you scroll.
- About is the mockup's card: the wordmark, the build line (
0.1.0 · linux-x86_64 · iced 0.14 / wgpu), what the app is, and the three promises ticked off: offline-first, no telemetry unless you opt in, the relay never sees plaintext.
Added: the account screen the design asks for
docs/design/termuna-ui-v5.htmlhas drawn this screen all along and the app showed a grey card with two buttons instead. It is now the mockup's: who you are with the account disc, then the three settings that belong to an account: session sync on a real switch, recovery kit, billing, and then every device holding a token for the account.- Devices come from
GET /v1/devices: host, platform, when it was last seen, and a revoke for each one but the machine you are sitting at. Revoking that one would sign you out sideways with the config still claiming you were in; there is a Sign out button for it. - New device tokens are labelled
host · os-archinstead ofhost (termuna desktop), so the platform column has something true in it. Tokens minted before this keep their old label and are parsed as best they can be, never invented. - Session sync is a setting you can turn off from the app now. It writes
[cloud] enabledand tells the running daemon at once, rather than waiting for a restart to become true.
Changed: the sidebar's foot is the mockup's
- Two stacked rows: a daemon line and a boxed "account" button with the whole mailbox under it: are now the one row
docs/design/termuna-ui-v5.htmldraws: a 26px account disc bearing the initial, the name beside it, and the daemon's own line beneath (daemon · 4h 55mbehind a live dot). It is one button because it is one destination. - The name and the picture come from the account. The app now asks
/v1/auth/meat sign-in and at launch, remembers what it says, and draws the real name and the real face. An account with neither (one created with an email and a password, rather than through Google) falls back to a name derived from the mailbox,soldo.devices@gmail.comis shown assoldo, because everything after the first separator is routing, and to the initial on the tinted disc, exactly as before. - The picture is masked to a disc before it reaches the renderer, which has no notion of a rounded image, and cached beside the session store keyed by its URL: a launch costs no network, and a changed face can never be served from a stale cache. Binary cost of the image pipeline and the JPEG decoder: 31.91MB → 32.52MB (+0.6MB); the installer budget is unaffected in practice.
- Signed out, the disc is an empty ring and the name reads "sign in", the slot is there, nobody is in it. The daemon reports either way, and a stale daemon still gets its extra line, because only that case needs an instruction.
Changed: the host card is quieter, its menu is the app's own
- Clicking ⋯ on an SSH host (or right-clicking the card) now opens the same menu the terminal opens on right-click: icons, hairline separators, the destructive item in warn red. It floats at the pointer instead of unfolding under the card and pushing the grid down. Connect, edit, duplicate, share, move to <vault> and remove are all in it: the separate "move" strip and the inline confirm strip are gone, and removing a connection asks in the same card as every other question.
- A host card is one line for the name and one for the address, always. Both were cut to a fixed character count that a narrow card could not hold, so a long
user@hostwrapped and that card grew taller than its neighbours. The budget now comes from the card's real width, and neither line may wrap. - Hovering a host no longer paints the card. A tinted slab under a dozen cards is noise; the glyph and the name take the luna accent instead, which is the same "this one" the rest of the app uses.
Added: a vault you made is a vault you can unmake
- Shared vaults had no way out: the desktop could create one, share it and revoke a member, but never delete it. Every vault header now carries delete, behind a confirmation that states the two things worth knowing before you press it: the connections in it move to Personal, and everyone you shared it with loses access. Deleting a vault is about who can reach a group of hosts; it never throws the hosts away.
- The delete reaches the cloud (
DELETE /v1/vaults/:id, owner only), because a vault removed only on this device comes back on the next sync and the teammates never lost anything. With the cloud vault locked the app says exactly that instead of pretending; if the server refuses (a member cannot delete a vault out from under its owner) the vault goes back in the list rather than waiting for the next sync to contradict it. The personal vault is refused outright: it is where the other vaults' connections land. - members now opens in the same card as the rest of the app.
Changed: one modal, one input, everywhere
- The forms that used to unfold inside a screen now open the way the start-agent dialog always did: a card over a dimmed page. **Add host / Edit host, New vault and Add MCP server** are all the same card, so a long list can never push the form out of sight and a half-filled form can never scroll away from you.
- Modals arrive and leave with a short, quiet transition: 150ms in, 110ms out, the card settling the last few pixels and the page darkening with it. A dismissed modal keeps its contents until it has finished leaving, so it never empties itself in front of you.
- One text input in the whole app: the window's own colour recessed into the surface, a hairline that brightens under the pointer, the live accent only while it holds the caret. Search boxes, the command palette, the chat composer, the keybinding editor, the scrollback search and every field in every form now share it: as do the dropdowns, which are the same well with a handle.
- Settings sections are the filter chips the agents and MCP screens use, not underlined tabs. One list of choices, drawn one way.
- The cloud-vault line under Search hosts ("synced 7 connection(s)…") had no space above it and read as the search box's error message. It now sits in its own row behind a live dot.
Changed: sessions no longer come back on their own (ADR 0006)
- A daemon restart takes every shell it owned with it. On its next start the daemon used to respawn them for any session live within the last 48 hours, restore the scrollback, re-bridge and report the session as live: up to 32 of them, without anyone asking. On the phone that was indistinguishable from a session that had never stopped, except that the build you left running in it was gone.
- Either the process ran the whole time or it did not. Sessions now load dormant after a restart, with their history readable, and come back only when a human asks for them: from the picker, the phone or the web.
spawn_boot_resurrect, the 48-hour window, the 32-session cap and the[cloud] keep_aliveoption are gone. (The config key is still parsed and ignored, so an existing[cloud]section does not become invalid and take sync down with it.) - What you asked for, you can still see you asked for: a rebuilt session writes
── session rebuilt · new shell in <dir> ──into each pane before the new prompt, so restored scrollback above a fresh shell cannot pass for continuity. - The real fix for surviving an upgrade is a daemon that does not die (restart-in-place with the PTYs inherited); it is on the roadmap, and this change stops papering over its absence.
Fixed: "needs you" stayed lit after the ask was answered
- The amber badge is the relay's
attention_at, and the only thing that ever cleared it was a viewer'sInputframe. A managed agent session has no PTY, so nobody types into it: it is answered withAgentCommand, and an ask answered on the host's own desktop never crosses the relay at all. The badge therefore stayed lit on the phone indefinitely, for an agent that had long since carried on. - The protocol gains
AttentionOver(additive, unit variant): the host says when a session stops wanting its user, whatever stood it down. The relay clears the flag on it, and also on a viewer'sAgentCommand, which is the same human being present. - A restart is the other half of it: a fresh daemon has no attention to retract, so it would have left the badge up forever. The bridge now states this the moment it attaches, which asks still stand, and whether the session wants its user at all: instead of leaving the relay holding whatever it was told last time.
Fixed: a permission ask answered on one device stayed open on the others
- Allow a tool call on the desktop and the phone kept showing Allow/Decline for it, indefinitely. Nothing in the agent CLI's stream says an ask is over: the answer is a
control_responseTermuna sends *to* it, so a client that did not send that answer never learned the agent had moved on. - The daemon now states it. When the pending set changes: an ask arrives, one is answered, a turn ends and abandons what it was waiting on: it broadcasts a sequenced frame of its own (
{"type":"termuna","subtype":"permission_state"}) carrying the whole waiting set, and naming the one that just changed along with what the human chose. Carrying the set rather than only the transition is what makes it work for a client replaying history: the asks are in the log, and this says which of them still stand. - Desktop, phone and web all render a settled card without buttons, "allowed", "declined", or "answered elsewhere" for one that was resolved while they were away.
Fixed: after a daemon restart, an agent conversation stopped arriving in the cloud
- The relay stores frames by sequence number. The daemon's counter is recovered from the persisted frame log, but that log was only ever written by the PTY output pump, and a managed agent session has no PTY. Its counter therefore restarted at 1 on every daemon restart, so the relay overwrote its own stored history row by row and every frame the daemon sent was dropped as one it had already seen, until the count climbed back past where it left off.
- Three changes, because one alone leaves a hole: the agent pump persists the session and its log on the same throttle the PTY pump uses; the sequence counter is also stored in the session snapshot (one integer, and it survives a log that was capped or never written); and on attaching to the relay the bridge winds its counter forward to the head the relay reports, so a daemon that recovered less than the relay kept never talks into a void.
- The bridge also announces the agent's pending asks as soon as it attaches, which is what retires a card left over from before the restart.
Fixed: an idle session could go dormant in the cloud and never come back
- A session nobody is typing in sends nothing for hours, and an idle TCP flow is what a home router or a carrier NAT quietly drops. The relay's end then closed: it logged the host detaching and set
live = 0, while the daemon's socket sat in ESTABLISHED believing it was still mirroring. It never reconnected, so every session read as dormant on the phone with the desktop running the whole time, and daemon queries (which route through a live session) timed out as "the desktop is taking a long time to answer". - Observed on 2026-07-30: both bridges died at 21:52 with nothing in the daemon log, and were still dead 8 hours later against a daemon with 18h uptime.
Payload::Heartbeathas been in the protocol for exactly this and nothing was sending it. The host now sends one every 20s per bridge, which keeps the flow warm: an idle flow being what gets dropped in the first place. It is unsequenced, so the relay broadcasts it and stores nothing.- The bridge socket also sets
TCP_USER_TIMEOUT(60s) and TCP keepalive. Keepalive alone would not have helped, and measuring showed why: the keepalive timer only runs on an *idle* socket, and the heartbeat means the socket is never idle: with the link blackholed the kernel sat in retransmit for over seven minutes without firing a probe.TCP_USER_TIMEOUTis the one that bounds unacknowledged data, which is the case that actually applies. - Measured against production by blackholing the relay's address (a DROP rule, so packets vanish with no RST: the same shape as a NAT forgetting the flow): the link died at 06:47:38 and the daemon reported it at 06:48:55, 77 seconds. Traffic restored at 06:55:07; all four bridges were reattached by 06:55:52, 45 seconds later. Before this change the same test ran seven minutes with the daemon noticing nothing.
Removed
AgentEventand the transcript-tailing machinery behind it. Agent conversations no longer come from parsing the provider's own log file; see Added. Providers without a structured mode (Codex, Gemini, Cursor, OpenCode) still launch into a shell and are terminals with a program running in them (AgentInfo.structuredsays which you get) but they no longer produce a chat lane until each grows an adapter.- Protocol v1.
PROTOCOL_VERSIONis 2 and v1 peers are refused. The frames are CBOR-tagged by name, so a v1 peer would just see unknown variants and carry on showing an empty agent session: the version check turns that silent confusion into a clear refusal. Relays and clients must be rebuilt; existing agent sessions do not migrate.
Fixed: the wordmark is the brand's drawing, not an approximation
termunais drawn from outlines converted once fromtermuna-website/assets/brand/logo-wordmark.svg, so the letterforms are the brand's own. No typeface ships for one word, and the logo cannot come out in the wrong face if a font fails to load.- The cursor keeps the SVG's exact geometry: 40x76 block, corner radius 6, bite r21 at the upper-right shoulder, all relative to the baseline, but not its x position. The drawing puts it four ems past the word because it was laid out for a wider Martian Mono than the one that ships; the mark means "the next character of a running session", so it follows the last letter by a hair.
- Inter is subset to the ranges the chrome can actually show (856 KB → 283 KB): Latin, Latin-1, Latin Extended-A for Croatian diacritics, punctuation, arrows and the geometric glyphs the UI draws with.
- CI reports the binary size instead of asserting it. The assertion was a stand-in for the installer budget; the installer is still ~5MB and still asserted in
windows-exe.yml, so the proxy is what had drifted, not the promise. A real size pass is recorded in docs/ROADMAP.md.
Changed: the desktop is being rebuilt on the v5 design
apps/termuna/src/ui.rsmakesdocs/design/termuna-ui-v5.htmlexecutable: the ink ladder, accents, metrics and type scale as tokens, one per CSS custom property. The app chrome used to take its colours from whatever terminal scheme was loaded, so it could not match a design at all, and looked nothing like the phone. The scheme now does the one job it should: painting the grid.- Inter ships beside JetBrains Mono. Prose in sans, anything a machine produced in mono: the same split as the phone.
- Shell and sessions screen rebuilt: a sidebar with counts and a left accent rule on the active item; a row that reads running → name → what it is → when it was touched, with a rule down the live ones' left edge instead of a box, and actions held back until the pointer arrives.
- The desktop chat's parser is written against the SDK stream instead of the transcript file it used to read. That file is gone, and its quirks were still in the code doing the wrong thing on fields the stream spells differently: compaction numbers were read as
compactMetadatawhere the stream sendscompact_metadata, so every one was silently dropped; subagent output was filtered byisSidechain, which the stream never sets, so it leaked into the lane. Both fixed, a failed turn and a rule-denied tool now say so instead of vanishing, and the compaction divider carries its numbers. Unit-tested against real frame shapes. - The agent chat can answer. A
can_use_toolask renders as a card with the tool, its full arguments and Allow/Decline; text streams into a live bubble the finished turn replaces; send becomes stop while the agent works. Verified live: the desktop approved a Write and the file appeared.
Fixed: desktop UI details from first use
- Sending a message looked like it had been swallowed: the chat waited for the CLI to echo it back (
--replay-user-messages), which only happens once the agent picks the message up: seconds later. It appears the moment you press Enter now, and the echo is dropped when it arrives so nothing shows twice. - The chat header no longer offers "terminal" for a managed agent. That session has no PTY, so the button opened an empty grid; a terminal-launched agent still has one and keeps the button.
- A session row's actions appeared only sometimes. The clickable body was a button nested inside the hover area, and it swallowed the enter/exit events, so whether a row lit up depended on where the pointer crossed its edge: the first row, entered from the heading above, missed most often. One widget owns both the pointer and the click now, and the cursor turns into a pointer over the row, so what is clickable says so rather than leaving a tint to imply it.
- Segmented controls (agents, mcp) had their buttons adjoining, so their borders doubled up and the set read as one crammed block. They are spaced, like the phone's.
- The SSH host filter sits on the actions row beside "New vault" and "Add host", the way the sessions filter does, instead of on a line of its own below them.
- SSH host cards had less vertical room than every other row in the app, and none between grid rows.
Changed: the start-agent dialog
- Rebuilt to the maintainer's redesign: the provider named in a chip beside the title, the working directory as a field you can type rather than only browse to, a Browse button beside it, and the directories this machine has already run agents in listed underneath with their conversation counts: busiest first, one tap to pick.
- Long paths are shortened from the left (the last components identify a project; the prefix is the same for all of them), so a row stays a row.
esccloses it, which the footer now says and previously did not do.
Added
- Sidebar icons, drawn rather than shipped as a font: the mockup gives their paths on a 24×24 grid, so they stay the same source of truth as the colours.
- A managed agent session is named after the directory it works in. It has no shell to set a title and no pane cwd to poll, so it used to stay "shell" forever and every agent session looked like every other; the chat header then paired that with the provider and read "claude claude".
- Opening any agent session goes straight to the chat. It has no PTY, so the chat-or-terminal question offered a choice one side of which opened an empty grid.
SessionInfocarriessdkfor that, which every client needs anyway. - Agents screen on the same anatomy: provider segments (a CLI that is not installed stays visible and plainly unavailable rather than vanishing), then resumable conversations named by the project they ran in, with the prompt beneath and the time on the right.
- MCP and SSH screens on the same anatomy: a segmented provider control and a configured list with the endpoint and its kind; hosts as cards under collapsible vault headings that say how many and whether the vault is private or shared, with the ~/.ssh/config import as a bordered note rather than a button pretending to be a row.
- Settings and account follow: underlined tabs instead of filled pills (a pill reads as a button you press, not a section you are in), group labels as small headings, the design's button language throughout, and an account identity block with the avatar initial and the org in the colour that already means "shared with others".
- The terminal's own chrome joins them: the tab strip, the search bar and the files drawer take the fixed tokens, and so do the picker's inline editors (add host, new vault, members). The colour scheme now paints the grid and nothing else, which is what makes one app out of a themed terminal and a designed shell.
Added
- Agent sessions are conversations now, not terminals with a CLI typed into them (ADR 0005, protocol v2). The daemon runs Claude Code as a managed process over its SDK protocol and mirrors the conversation as
AgentFrames: streaming deltas, typed tool calls and results, compaction boundaries, cost. Clients answer withAgentCommand, message, answer a permission ask, interrupt, change permission mode, so a phone or browser can approve the tool the agent is blocked on instead of typing blind into a TUI it cannot read.bypassPermissionsis refused from remote clients on purpose. A managed session has no PTY and resumes by the CLI's own session id. Verified end to end through the daemon (cargo test -p termuna-mux --test live_agent_session -- --ignored) and live from a phone through the production relay: a Write outside the working directory stopped the agent, the card appeared on the phone, Allow released it, and the file landed on the desktop. - Token deltas are sent live but never stored:
stream_eventframes go out unsequenced, so they stay out of the replay log and off the relay's disk. A reader who joins later gets the conversation rather than a recording of its keystrokes. termuna-agent: a crate that drives an agent CLI over its own SDK protocol instead of its terminal. Claude Code instream-jsonmode speaks NDJSON both ways, so the conversation arrives as typed frames, streaming deltas, tool calls with their inputs and results, compaction boundaries, a result with cost, and, crucially, acan_use_toolpermission ask that Termuna can route to whoever is watching and answer. This is the foundation for replacing the transcript-tailing chat lane. Verified against a real Claude Code process: ask → answer → tool runs → typed result (cargo test -p termuna-agent -- --ignored).
- Cloud keep-alive: with sync enabled, sessions that were live when the daemon last stopped come back automatically on the next daemon start (or reboot) and re-bridge to the cloud, so you can continue them from the web or phone without the desktop app having to reopen each one first. Previously the daemon loaded every saved session dormant and only respawned one when a viewer opened it, so with the GUI closed nothing stayed live on the relay. Bounded and safe: only sessions active within the last 48h are respawned, capped at 32, staggered, and run off the startup path so cold start is unaffected; older sessions stay dormant (still viewable read-only from the dashboard). Opt-in with cloud sync (ADR 0002): nothing respawns unless
[cloud] enabled. On by default when sync is on; set[cloud] keep_alive = falseto opt out and keep the old behavior (sessions load dormant until reopened).
Fixed
- Scrolling and selecting work in apps that turn mouse reporting on without taking the alternate screen: a CLI agent's TUI being the case that exposed it. Termuna forwarded every wheel notch and every drag to such an app, which ignores them, so the scrollback could not be scrolled and no text could be selected or copied at all. The wheel now scrolls the scrollback in the normal screen, and a drag becomes a selection there (the button is released for the application first, so a plain click still reaches it). Full-screen apps on the alternate screen (vim, htop, lazygit) keep the wheel and the drag, since they own the viewport and there is no scrollback behind them. Shift still bypasses reporting everywhere, as before.
- Right-click opens the context menu, and middle-click pastes the primary selection, in the normal screen too. Both were forwarded to any app with mouse reporting on, which is how a selection could exist with no reachable way to copy it.
Added
- TSP grows a daemon-query channel (
Query/QueryReply, additive): viewers attached to a live session can ask the daemon for the installed agent CLIs, recent resumable agent conversations, and to launch an agent in a fresh session: everything E2E-sealed with the asking session's key, correlated by envelope id, never persisted by the relay.SessionCreatedreplies carry the new session's share link inside the sealed payload so the asking viewer (the mobile app's agents screen) can open it immediately. The channel also covers plain session management: create, rename, kill, and resurrect-with-share-link, and opening vault connections:CreateSshSessioncarries the resolved SSH target (host, auth, jumps, forwards) inside the sealed query, so a phone can open a saved connection Termius-style without any secret touching the relay. Spec: docs/PROTOCOL.md.