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.app
otherwise 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 asGIT_AUTHOR_*, which outranks.git/config, so
git 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 readidlefour 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, socursorBlinkfollows 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 insidepointermove, 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, aSIGWINCHand a full-screen redraw by whatever is running —claude
answers 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'sgh
token 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 login
that "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 a
Valuenobody 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.