cowork-deck

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 .app otherwise inherits launchd’s minimal PATH.
  • 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, so git config user.email inside 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 idle four 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, search and unicode11 were loaded, so xterm ran its DOM renderer and every refresh of a dirty row rebuilt all of that row’s spans. @xterm/addon-webgl draws 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 cursorBlink follows 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 evaluateJavaScript on 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.emit embeds 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-session Channel sends 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.ts now 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 SIGWINCH and 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. spawn refuses 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.
  • #229CloseRequested was handled for any window while AppState is app-level, so one close() on the status pill would have killed every session in the app. Scoped to the main window.
  • #231start_session resolved claude’s location and the workspace’s gh token inline, on the thread that paints the window: up to ten seconds of freeze per launch, while the doctrine at the top of commands.rs claimed 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.
  • #232GIT_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=yes is 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 gh no-auth directory was writable, so the gh 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 sets GIT_TERMINAL_PROMPT=0 so 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 Value nobody reads says the opposite. IgnoredAny is the type for that.
  • A terminals.json written before terminals became per-workspace still loads: active was 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 pointermove and still sends a resize per pointer move. src/drag.ts and 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.
  • #269Escape inside a terminal unzooms the deck instead of reaching the program. Always true of claude tiles; the drawer makes it impossible to ignore, Escape being the most load-bearing key in vim, less, htop and fzf.
  • #270Shift+F6 moves 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.