Release ·
cowork-deck v0.4.0
Until now the app had no terminal in it. Working on a repository through the deck meant alt-tabbing to a separate window, standing in a directory the app could not know about and under whatever git identity the machine happened to have. This release puts a shell in the app — and, because a shell breaks every assumption the PTY layer was quietly built on, fixes the eight defects that finding out produced.
The terminal is also no longer the slow part of the window: it draws from a glyph atlas rather than by rebuilding DOM rows, its output crosses into the webview batched and as bytes, and neither a drag nor a resize asks a running agent to repaint itself a hundred and fifty times on the way.
What people get
An embedded terminal, per workspace (#225). A drawer under the deck holding
ordinary interactive shells. Cmd+J (Ctrl+Shift+J on Windows and Linux) opens
it, and opening it empty opens a shell — a strip with a + and nothing in it is a
worse answer to “give me a terminal” than a terminal. Tabs, +, double-click to
rename, drag the top edge to resize.
- A drawer, not another tile kind. A deck tile is one unit of agent work: a state chip driven by hooks, a restart that resumes a conversation, a broadcast checkbox, a name read out of a transcript. A shell has none of that and would carry four controls that mean nothing while competing for the space the sessions are the point of. What a terminal needs is tabs, and to be out of the way when it is not in use.
- It is your own
$SHELL, started in the active workspace’s folder, carrying that workspace’s GitHub account — a login shell on macOS, where an.appotherwise inherits launchd’s minimalPATH. - One line before the first prompt names the folder, the branch, the account and
the git identity it will commit as. That line is the only way to check the last of
those: the binding is injected as
GIT_AUTHOR_*, which outranks.git/config, sogit config user.emailinside the shell reports the value that loses. Output is held until the banner is written, because a prompt that got there first would push the explanation below the thing it explains. - A terminal belongs to its workspace the way a tile does. Switch projects and you get that project’s terminals, on the tab you left it on; a project you have never opened one in has no drawer at all, rather than an empty strip shortening its deck. Nothing is closed by switching — the shells keep running and their scrollback is where you left it, which is also what makes coming back instant. A terminal whose workspace was deleted stays visible from everywhere, exactly as an orphaned tile is: a terminal nobody can switch to is a terminal nobody can close.
- Tabs survive a restart as new shells in the same folders under the same names. That is the honest most a shell can offer — there is no conversation to resume.
- Eight at once, and closing one that is running a job asks first. A shell has
no hooks, so its state would read
idlefour minutes into a release build; the process table is read instead.
Nothing is torn down without asking (#230). Quitting the app now refuses the first gesture and names the sessions with something running in them — a build, a test run, a command in the drawer. A second gesture goes through regardless, so the app can never become unquittable. A session sitting at a prompt, and an agent’s own long-lived helpers, are not counted: a question that is always there is one people learn to click through.
And “killed” now means the process session (#226), not the one process the app
started. A shell puts each command it is given into a process group of its own, so a
npm run build was never reachable by signalling the shell — it outlived the
session that started it. Teardown is now SIGTERM to the foreground group, SIGHUP and
SIGTERM to the leader, a grace period, then SIGKILL to whatever is still in that
process session, guarded so it can never match the app’s own. The same teardown runs
however the app ends: closing the window, Cmd+Q, or the updater relaunching it.
A terminal that draws at the display’s rate (#261, in part; #186, in part).
- The renderer. Only
fit,searchandunicode11were loaded, so xterm ran its DOM renderer and every refresh of a dirty row rebuilt all of that row’s spans.@xterm/addon-webgldraws from a glyph atlas instead. A cap comes with it and is not optional — WebKit keeps a process-wide ceiling on live WebGL contexts and force-loses the oldest rather than refusing the next, so an uncapped tile scrolled out of sight would blank the terminal someone is reading. Eight contexts, handed to whichever panels are on screen, a freed slot going to whoever waited longest, and a lost context falling back to the DOM renderer rather than to nothing. - The cursor stops strobing. It was a DOM-renderer defect, not a font one:
blinking is a CSS animation on the cursor’s span, and the renderer rebuilds that
span on every repaint of its row, restarting the animation at 0%. Under a TUI that
repaints its input line continuously the cycle never completed. The WebGL renderer
blinks off a 600 ms timer no repaint touches, so
cursorBlinkfollows the renderer: on with it, off without. A cursor that does not blink is a small loss; a cursor that strobes is the complaint. - Typing no longer queues behind output. One PTY read was one
evaluateJavaScripton the webview’s main thread — the same thread that delivers keystrokes — and on Darwin the tty caps a single read at 1024 bytes, four times smaller than Linux’s 4096, so the same agent output cost four times the calls on a Mac. Reads landing within 4 ms of each other are now passed on as one, capped at 64 KB. 4 ms sits under a frame at 240 Hz, and it is also the worst case added to a keystroke echo, which is the one place the latency would be felt. - Output crosses as bytes.
app.emitembeds its payload as a JSON literal in a JS source string, so terminal output was base64’d (+33%), pasted into JavaScript, parsed by the JS parser,atob’d, and walked a byte at a time by a callback. A per-sessionChannelsends binary over the custom protocol instead. None of those four steps remain, and the frontend no longer walks the tile map once per chunk to work out whose bytes it is holding. - A drag reads layout once and writes once a frame (#262, in part). Both pointer
drags wrote a size and read layout straight back inside
pointermove, forcing a synchronous layout, so the engine never got to coalesce two events into one frame and every event paid the whole cost. Against a 2500-row diff with five pointer events in a single frame: 85.9 ms of blocking and 15 layout reads, down to 0.2 ms and none.src/drag.tsnow holds the rule so the next grip is born with it. - A resize reaches the PTY once, when it is over (#263, in part). A resize is an
ioctl, a
SIGWINCHand a full-screen redraw by whatever is running —claudeanswers one by drawing its entire interface again. Nothing throttled it. A window resized in 32 steps with five live terminals went from 10 calls, all of them mid-gesture, to 0 mid-gesture and then 5: one per terminal, at the final size. Trailing edge, because the size that reaches the child has to be the one the gesture ended on.
Defects the shell uncovered
All eight were found by asking what an ordinary shell needs that claude never did.
pty.rs now states its four assumptions at the top and enforces them.
- #227 — respawning under a live session id let a dead process paint into its
successor’s terminal.
spawnrefuses a live id unless the restart button passes an explicit flag, and each spawn carries a generation its reader and waiter check. - #228 — exit was one boolean, which made “your build failed”, “we hung you up at shutdown” and “the wait itself failed” the same value. It now carries an exit code, a signal name and an unknown flag; a signalled process reads as ended rather than errored, and the tile prints what happened.
- #229 —
CloseRequestedwas handled for any window whileAppStateis app-level, so oneclose()on the status pill would have killed every session in the app. Scoped to the main window. - #231 —
start_sessionresolvedclaude’s location and the workspace’sghtoken inline, on the thread that paints the window: up to ten seconds of freeze per launch, while the doctrine at the top ofcommands.rsclaimed the session commands could not block. Both are resolved off that thread now, before each launch. Keystrokes typed before the process exists are held and flushed in order rather than discarded. - #232 —
GIT_SSH_COMMAND’s key path was unquoted, and git hands that variable to/bin/sh, so a path with a space in it made ssh read the rest as a hostname.IdentitiesOnly=yesis dropped with it: it is not scoped per remote, so inside the app it broke every push to any host but the bound one. - #233 — the shared
ghno-auth directory was writable, so thegh auth loginthat “you are not logged in” invites could turn one account into app-wide state. It is read-only now, and a degraded session setsGIT_TERMINAL_PROMPT=0so git fails rather than soliciting a credential that would be cached globally. - #226 and #230 are above, under what people get — both change behaviour people will notice.
- A channel cannot outlive the process it was opened for, found while replacing
the transport. Tauri sends
{ end: true }when the Rust half is dropped and the JS half unregisters its callback id, but the object survives and still serialises to that id — so handing it to a second spawn invoked cleanly and every write was dropped with a console warning. Since the restart button reuses the panel, a restarted session would have printed nothing at all. One channel per spawn, with a test that fails on the reused one.
Housekeeping, no behaviour attached
Older(serde_json::Value)in the legacy-shape reader warned as dead code, correctly: the variant exists to swallow a shape and keep nothing of it, and aValuenobody reads says the opposite.IgnoredAnyis the type for that.- A
terminals.jsonwritten before terminals became per-workspace still loads:activewas a bare session id where a map now is, and a tolerant deserializer drops it rather than failing the whole file and taking every tab with it. - The diff drawer’s manual checklist gains the one check jsdom cannot do — that the layout snapshot a drag works from is never stale in a way a person can see.
Known, and tracked
Found while building this, left out on purpose:
- #262 and #263 are only half done, and deliberately so. Both were written
when the drawer lived on its own branch; that branch has since landed, so the
terminal drawer’s grip is now in the same tree and still reads layout inside
pointermoveand still sends a resize per pointer move.src/drag.tsand the PTY throttle are in place and waiting for it — one gesture of that grip is still 81 pointer events producing 150 asks to repaint. - #264 — the diff grid re-lays out in full whenever the pane’s width changes, which is the 34 ms that remain after the drag fix. #265 revisits the no-virtualisation decision now that the cost is known. Both under #261.
- #186 — the cursor half is fixed and the mechanism is understood; the cell-drift half is not, and how any of this feels on a Mac has not been checked by hand. #266, the spike on WebKitGTK’s own knobs, stays open beside it.
- #269 —
Escapeinside a terminal unzooms the deck instead of reaching the program. Always true ofclaudetiles; the drawer makes it impossible to ignore,Escapebeing the most load-bearing key invim,less,htopandfzf. - #270 —
Shift+F6moves to the previous region and the palette does not list it, so it is documented nowhere. - #250 — deleting a workspace warns about its scenarios but not about its running sessions.
Still open from v0.3.0: #235 (the history screen reads an unreadable journal as
an empty one), #236 (no test covers that screen’s boot wiring), #249 (an
unpinned scheduled scenario runs in whichever workspace happened to be active),
#255 (every modifier+key pair other than the three translated still collapses).
From v0.2.0: #199 (after /clear, restart resumes the pre-clear conversation),
#195 (a non-UTF-8 transcript reports zero tokens and no name, silently),
#194 (a renamed card tile loses the mark that says which card it is on).
Install
macOS — download the .dmg for your architecture (aarch64 for
Apple Silicon, x64 for Intel), drag the app to Applications, then
clear the quarantine flag once:
xattr -cr /Applications/cowork-deck.app
Linux — download the .AppImage, chmod +x it and run, or
install the .deb.
The app checks this page for updates on launch and installs them
in place; the xattr step is first-install only.
Downloads for v0.4.0
- macOS · Apple silicon
- macOS · Intel
- Linux · AppImage
- Linux · Debian and Ubuntu
- Linux · Fedora and RHEL
On macOS, read the Gatekeeper note first.