Skip to content

cowork-deck v0.4.0

Choose a tag to compare

@github-actions github-actions released this 24 Aug 11:49
· 141 commits to dev since this release
349a49d

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.