From the read-only final review of the release, adversarially verified, then reviewed again:
- tiles: a file dropped on the grid uploads to that tile's session instead of navigating away; app-driven tile changes no longer move the keyboard into another session; popping out the last tile no longer leaves a frozen view; "Open group as tiles" no longer merges an open split; the Tiles button defaults off on touch tablets (opt-in)
- voice: dictation with the grid open reaches the focused tile
- css: By case stays one scrolling strip on 600-767px tablets, the needs-you pulse animates opacity only, phone welcome chips are 40px
- i18n: zh-CN for the case picker rows, the git status settings, new toasts, tile and spreadsheet texts
- cli registry: codex launch defaults are registry data, not an id branch; the codex footer reads an ultra effort
- build and docs: a dependency preflight runs before the build deletes dist; docs no longer name 1.36.0
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- docs/tile-grid-plan.md: the As built bullet on tile loads said a refresh
clears the screen at its turn in the queue. Since the fetch-first refresh it
fetches at its turn, keeps the last frame through the wait and its own round
trip, and resets with the queued in-stream \x1bc only once the capture is in
hand; a failed, aborted or empty fetch writes nothing and resets nothing.
- docs/architecture-invariants.md: the tile grid's One load queue paragraph
gets the same correction, and its list of captures that go through the
TileLoadQueue now names the server {t:'c'} refresh and the dropped-output
recovery refresh.
- test/terminal-tile-input.test.ts: destroy() cancelling a pending recovery is
now pinned on the timer itself (armed before destroy(), null right after it,
read before any timer runs), since the recovery callback's own destroyed
guard made the fetch check pass either way; a second test pins that
destroy() starts the live-output count over, so a write callback xterm still
owed counts nothing. Both fail with the _resetLiveFlow() call removed from
destroy().
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The server applies no WebSocket backpressure (16 KB / 8 ms batches, no
bufferedAmount check), and a tile wrote every live frame straight into
xterm. A flood a tile could not parse as fast (a shell tile running cat on
a huge log) piled up in xterm's own write queue without bound, on a main
thread up to six tiles share, until xterm's WriteBuffer threw past 50M
code units; onmessage's empty catch then dropped every frame silently and
nothing recaptured the screen. The primary pane caps its queues and drops
then recaptures (_onSessionTerminal, _scheduleDroppedOutputRecovery).
Each tile now writes live output through _writeLive:
- unparsed code units are counted, each write's callback counting its own
back down; frames held behind a replay (_liveQueue) count too;
- the budget is TerminalTile.LIVE_BACKLOG_BUDGET, 4 MiB, deliberately not
the primary pane's 128 KB: that caps its own rAF-paced queues, while
xterm itself paces a tile, and a tight cap would trip on ordinary bursts
and blank-and-reload the tile over and over;
- past it a frame is dropped, the tile stops writing onto the hole, and one
refresh is scheduled, debounced and bounded by the primary pane's own
rule (CodemanDroppedOutput: 2 s, DROP_RECOVERY_MAX_ATTEMPTS, never retried
after a deadline abort). It is an ordinary refresh, so single-flight,
bounded by lines=/tail= and paced by the grid's TileLoadQueue. The flag
clears once a capture taken after the last dropped frame has replayed;
- a write that throws is the same drop, never a "malformed frame";
- past the bound the flag is released, so a tile is never left frozen;
- a reconnect starts the accounting over (an epoch makes callbacks from
before it count nothing) and drops a pending recovery, since its own
refresh replaces the screen; destroy() cancels it.
The live-queue flush after a pull or a refresh goes through the same path,
so a throwing write there cannot skip the load's marker and trailing
refresh either.
Tests (input harness, real constants and fake timers): the default budget
lets a 1 MiB unparsed burst through, parsed bytes stop counting, a trip
stops writing and ONE debounced refresh recaptures, a write throw takes the
same recovery, a hole in the held queue is recovered by another refresh,
bounded retries then release, no retry after a deadline, a reconnect resets
the count, and destroy cancels. The fake xterm can now hold and release
parses and throw on a write. Live writes now carry a callback, so the unit
tests match them on the data argument (a `.not.toHaveBeenCalledWith(data)`
would otherwise pass for nothing).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Redraw (Ctrl+Shift+R and the header button, restoreTerminalSize) on a
focused tile or the split's Pane B called tile.fit({ force: true }) and
always toasted "Terminal restored to CxR". In TerminalTile._sendResize,
force only skipped the client-side dedupe: the frame carried no `f`, so
Session.resize skipped a size equal to the one it last applied and the
server did nothing. And _sendResize returned silently with the socket down
or the session popped out to its own window, while the toast still
claimed success.
Both halves are fixed, the first as parity with the primary pane:
- a forced fit now sends `f: true`, the flag the primary's sendResize sets,
which the server honours (ws-routes reads msg.f, Session.resize then runs
tmux resize-window and the PTY resize at the same size);
- fit() and _sendResize() return whether a frame went out, and
restoreTerminalSize toasts success only then. Otherwise it says why, as
the primary branch does: "sized by its own window" for a detached
session, "not connected" while the tile's socket is down (it announces
its size again on reopen), and the primary's "Could not determine
terminal size" for a pane that measured nothing.
What this does not claim: a forced resize to the size the PTY already has
changes no geometry, so it is not a cure for a garbled tile whose PTY
already matches; the primary pane's forced resize has the same limit. It
matters when the server's recorded size has drifted from the tmux window.
Tests: the forced frame carries f:true (and plain ones do not), fit()'s
return value on send, dedupe, detached and closed-socket paths, and Redraw
end to end on a real tile (sent, socket down, popped out), plus the three
no-success toasts in focused-pane-shortcuts.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A tile's refresh (a {t:'r'} or {t:'c'} frame, every reconnect) wiped the
pane with a synchronous xterm clear() at the load's turn, BEFORE its fetch,
and wrote live frames straight through the fetch and the replay. That is
the replay clear CLAUDE.md "Terminal resilience" forbids: bytes still
queued in xterm are parsed after a synchronous clear and fuse into the
snapshot, and clear() keeps the cursor's row, column, SGR and margins, so
the capture (raw rows, no home) started wherever the cursor sat. A failed
or empty fetch left the tile blank.
The refresh now runs in the primary pane's order (_onSessionNeedsRefresh,
_resetTerminalForReplay):
- fetch first, so the tile keeps its last frame through the round trip and
through a grid tile's wait in the load queue;
- from the response on, live frames are held in _liveQueue with their
arrival time, as _pullHistory already did, and the body read of a bounded
window (grid tile, shell) gets the pull's 10 s budget, while Pane B's
unbounded full=1 keeps the request's own budget;
- then the queued in-stream \x1bc immediately before the replay;
- then the held frames that arrived after the response (_flushLiveQueue,
now shared with _pullHistory), then the owed marker.
A failed, aborted or empty fetch writes nothing and resets nothing.
The _stampMarkerIfOwed guard for a pending trailing refresh stays (that
refresh settles the marker itself either way); only its rationale changed.
The fake xterm now treats an in-stream RIS like clear() in its row
emulation.
Tests: the ones that counted clear() calls on the refresh path now count
the in-stream reset instead, assert it sits right before the replay and
that clear() is never called (unit single-flight block, the marker
ordering tests, the reconnect test, the grid {t:'r'} and marker tests, and
the scroll test's server-clear overflow case, which now goes through a
refresh). New: the screen is untouched on a failed or empty fetch and on a
failed body read (held frames written in order), frames before the
response are written through and later ones held behind the replay, the
cutoff drops frames the capture covers, the body budgets, and a grid tile
keeps its last frame through its own capture's round trip.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The server sends {t:'c'} from one place only: a fresh Claude pane's first
prompt (Session.startInteractive), meaning "refresh after startup". The
primary pane answers it with a refetch and replay (_onSessionClearTerminal),
and stands aside while the grid is open, so the tile's own handling was the
only one that ran. That handling was a bare xterm clear(), which keeps only
the cursor's row and drops the banner, a resumed transcript and all
scrollback. An idle Claude never repaints static rows, so a Claude session
Run into the grid, or Attached in a tile, came up as a near-empty tile.
_onLiveClear() now calls _refreshBuffer(), the {t:'r'} path: single-flight,
coalesced into one trailing refresh behind a load already running (a shell
pull's held frames included), and paced by the grid's TileLoadQueue. The
queued {clear:true} entry and its branch in _pullHistory's flush are gone,
along with the _clearTerminal helper they used.
Tests: two unit tests pinned the bare clear (a clear frame queued in order
during a pull, and one applied at once before the capture); they are
replaced by tests that the frame coalesces behind the pull and refetches,
plus a socket-level {t:'c'} test, a coalescing test, and a grid test that
the frame waits its turn in the load queue.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A file dragged onto the tile grid navigated the browser away: the single
view's drop handler sits on #terminalContainer, hidden while tiles are
open. The #tileGrid section now cancels every file dragover and drop
(bubble phase, so tab and tile drags stay with _acceptTabDrops), and a
drop on a tile uploads its images to THAT tile's session through
_uploadAndInsertImages, with the same "Only image files are supported"
toast as image-input.js (now in the zh-CN table).
- App-driven refocus no longer moves DOM focus into another session's
xterm: a remote delete of the focused tile, _reconcileTileGrid and a
socket closed with 4003/4004/4010 (_onTileExit) pass focus: false.
removeTile gains a focus option; user-initiated removes keep focusing.
- Popping out the last tile left the parked terminal's stale content under
the popped-out tab (and snapshotted it on the next switch).
_selectAfterTileGrid treats a detached session as unusable for both the
focused id and the fallback.
- "Open group as tiles" and Ctrl/Cmd+click with the grid closed pass
mergeSplit: false, so an open split no longer adds its two sessions on
top of a set already sized to the group, the count and the window.
- Touch-primary devices (primary pointer coarse: iPad, Android tablets)
default the Tiles button OFF in getDefaultSettings(); touchscreen
laptops (fine primary pointer) keep the desktop default ON. The button,
the App Settings chip and the Ctrl+Shift+G gate all resolve an absent key
through these defaults, so they agree. CLAUDE.md and the invariants doc
say so.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The tile's hand-encoded click report went through _handleDesktopTerminalClick
and _sendSyntheticSgrTap to _sendInputAsync, so it took a seq, was persisted
and would be redelivered after a reload. The documented TerminalTile rule
(CLAUDE.md, Split-pane sessions) is that only typed input enters that queue
and focus/mouse reports go out ephemeral, and the tile's own _onTerminalData
says the same. Before #555 an opencode tile's click went through xterm's
encoder and that ephemeral path. A click still unacknowledged when the page
reloads, or sent during a server restart, could be replayed onto a later
screen, where a press+release can pick a dialog option.
_sendSyntheticSgrTap now takes an opt-in `ephemeral` field on its target and
sends through _sendInputEphemeral when it is set; _handleDesktopTerminalClick
passes the target through unchanged, and TerminalTile._installClickListener
sets it. Without the flag nothing changes, so the primary pane's own click
and touch tap reports stay on _sendInputAsync exactly as before (whether the
primary pane should also go ephemeral is a separate question, out of scope
here).
Tests: the tile case now requires a frame with no seq and nothing pending in
the reliable queue, and the targeted-click case in terminal-touch-tap spies on
both send paths: a target with the flag goes ephemeral, an untargeted click
and an untargeted tap stay durable. Dropping `ephemeral: true` from the tile,
or the branch in _sendSyntheticSgrTap, turns the matching test red.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A tile counts as hollow when every row above its screen is its own overflow
(baseY minus _overflowRows is 0), so unlike the primary pane, whose hollow
buffer has baseY 0, its viewport can sit above the bottom while it is hollow:
Shift+PageUp, a scrollbar drag or a wheel during the first replay leave it up
there. _maybePageCliTranscript never looked at the viewport, so every wheel,
wheel-down included, was turned into PageUp/PageDown and swallowed. xterm never
scrolled back, the stale rows stayed on screen while the CLI paged out of
view, and clicks were dropped too, because the click report refuses an
off-bottom viewport.
The tile now pages only while _terminalViewportAtBottom holds for its own
terminal, checked before the pending travel is touched. Off the bottom the
wheel stays with xterm, so a wheel-down brings the viewport home and paging
resumes from there. The primary pane is unchanged: its hollow test already
implies a viewport at the bottom, which the twin comment now says.
Tests: a unit case for a tile hollow by the discount with its viewport above
the bottom (no page key, no preventDefault, and no travel carried over once
back home), and the real-browser case now scrolls a hollow tile up and proves
a real wheel-down scrolls xterm home with no page key sent, then pages again.
Both go red with the gate removed, and the unit case also with the gate moved
below the pending-travel update.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#541 fixed Android autocorrect duplicating the typed line in the primary
pane: xterm's keyCode-229 textarea diff is append-only, so an autocorrect on
space (delete a word, insert the corrected one) sent the whole line again.
The fix, an edit-based diff that sends one DEL per deleted code point and
then the inserted text, lives in terminal-keycode229-recovery.js together
with #441's next-keydown drain (a character committed in the same task as
Enter goes out ahead of the \r) and the original orphaned-insertText
recovery. Only the primary pane created that controller, so a grid tile or
the split's Pane B still ran xterm's stock behaviour. Both are gated on
width alone (1180 CSS px), which a wide Android tablet clears.
TerminalTile now creates its own controller in connect(), after the xterm
opens and before the first await, handed this tile's textarea, this tile's
CompositionHelper and _onTerminalData as the send path, so recovered bytes
go to the tile's own session through the exactly-once queue. As in the
primary pane, handleKeyEvent runs first in the custom key handler, above the
keyCode-229 early return, and notifyCanonicalData sits in the onData lambda,
gated on the same two CodemanTerminalInput predicates, never in
_onTerminalData, which the recovered bytes also take. destroy() tears the
controller down before disposing the xterm, which restores xterm's own diff
and removes the capture listeners. No mode or device gate, matching the
primary. The module itself is unchanged apart from its header; terminal-ui.js
gains only a comment naming the twin.
Tests: test/terminal-tile-input.test.ts now loads the real module into its
vm harness (with window timers, without which create() would silently throw
and every test would run against no controller) and drives a fake
CompositionHelper carrying xterm's own append-only diff. It covers install
and restore on the tile's own helper and textarea, autocorrect sent as an
edit (with a control reproducing the device-log duplicate), the last
character and an autocorrect each followed by Enter in one task, a
self-rescued 229 key delivered once, the onData gate ignoring query replies
and focus reports, two refused inserts after one keydown both recovered,
robustness when the controller throws, per-tile controllers, and a source pin
keeping the call above the early return. Removing the create, the
handleKeyEvent call, the notify, its gate, or the destroy each turns at least
one of them red, as does moving the notify into _onTerminalData. The browser
suite gains a TerminalTile block in
test/terminal-keycode229-recovery.browser.test.ts (real xterm, trusted
execCommand input, chunks asserted to address the tile's session, with a
destroyed-controller control).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#555 made the primary pane page opencode's transcript with PageUp/PageDown
from the wheel, because opencode draws in place on the alternate screen and
leaves the browser's buffer with no scrollback. A TerminalTile (a grid tile,
the split's Pane B) left every wheel to xterm, so in an opencode tile the
wheel scrolled nothing, or only stale rows.
The tile now runs the primary pane's own gates aimed at itself (its terminal,
its session, never the active one): xterm's tracking mode, the Claude
forwarding gate, then the hollow-buffer test. A wheel that passes them is
consumed in the capture phase and turned into PageUp/PageDown through the
shared pageKeysForTravel math, coalesced per tile (40 ms, 512 bytes, the twin
of the primary pane's queue) and sent ephemeral on the tile's own socket.
Every other wheel stays with xterm as before, the shell history pull
included. The file names no CLI: the mode rules stay in terminal-ui.js, and
terminal-tile.js joins the frontend no-id-branching guard.
A plain port of the primary's baseY === 0 test would almost never fire in a
grid. A tile's first capture is taken at the PTY's previous size (usually the
taller primary pane's) and written into a shorter xterm, and its own
row-shrinking fits (zoom-out, divider drags, tile count changes) push more
rows above the screen. The tile counts those rows as its own overflow: all of
them after a load whose capture held a single screen (the server's
captureRows), plus whatever a local fit or a PTY geometry report pushes up,
reset by a clear and clamped to baseY. The paging gate gets baseY minus that
count. Output that scrolls real lines still counts as history, so the tile
stops paging there.
#555's other half, stripping opencode's mouse DECSETs so a drag selects text,
is server-side and already reached tile sockets. It also left the tile's
xterm unable to encode opencode's clicks, so the tile now installs the
primary pane's desktop click report (bubble phase, gated on the session's
cliMouseTracking, the tile's own link hover and selection). Both listeners,
the flush timer and the page-key state are torn down in destroy().
Still out of scope, as the fileoverview now says: touch paging (tiles have
no touch path) and SGR wheel forwarding to Claude's fullscreen renderer
(tile-grid-plan follow-up 4), so a fullscreen Claude tile keeps leaving the
wheel to xterm.
Tests: test/terminal-tile-scroll.test.ts drives a real tile in the vm
harness (session targeting, every no-page case, accumulation, the cap,
coalescing, byte parity with the primary pane, the overflow discount through
a load, a fit, a geometry report and a clear, the click report and destroy);
the discount cases fail with it removed. The fake xterm gains opt-in row
emulation. test/terminal-tile-scroll.browser.test.ts checks the same model
against a real xterm with trusted wheel events (browser suite, not the gate).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment and doc corrections that #555 made stale, no behaviour change.
- stock.ts: the grok and omp altScreen comments compared their strip to
opencode's, which is now strip-mux-and-mouse rather than the narrow
strip. Grok now says it shares antigravity's strip until measured, and
omp drops opencode from its comparison.
- terminal-ui.js: the touch-tap comment named Claude/Codex/Gemini as the
stripped modes, but the gate is now the cliMouseTracking flag alone and
covers opencode too, so it names the two stripping flavours instead.
- src/types/session.ts: the cliMouseTracking JSDoc (the flag the browser
now gates on exclusively) listed only claude/codex/gemini; it now names
the strip-full and strip-mux-and-mouse modes, including opencode under
tmux.
- src/session.ts: the usesMux getter doc now names isMuxMouseStripMode,
since the replay strip passes usesMux to it as well.
- docs/architecture-invariants.md: the narrow-strip list gains
grok/deepseek/omp (matching the PR's own CLAUDE.md line), the
"must REMEMBER" heading covers both DECSET-stripping flavours, and the
cliMouseTracking writer is described as the full-or-mouse branch it
really is.
- docs/wiki/The-Dashboard.md: the user manual said every non-Claude CLI
scrolls locally; opencode's wheel and swipes now page its conversation.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A gemini session stayed "working" for good after its first turn. Its braille
spinner trips the generic SPINNER_PATTERN and marks the pane working, but only
a composer glyph arms the idle confirmation and gemini declared none, so it
fell back to Claude's `❯`, which gemini never draws.
Measured on live Gemini CLI 0.63.0 panes (capture-pane every 300 ms through
real turns with a shell call at 40, 120 and 200 columns, YOLO and default
approval mode, plus the raw PTY stream). The turns ran against a local
stand-in for the Gemini API (GOOGLE_GEMINI_BASE_URL, which Codeman's custom
endpoint support already sets), since the CLI's TUI does not depend on the
backend and no account is needed for it:
- The TUI repaints its whole bottom region every frame, composer included,
and the composer sits between a `▄` bar and a `▀` bar; the submitted prompt
is echoed between the same bars. The `▀` bar arms the idle check: every
repaint carries it, tmux's reattach repaint too. The composer's prompt
character is no good: it follows the approval mode (`*` in YOLO), and its
`>` also starts the echoed prompt, which would make the submit verifier
read a submitted prompt as stranded and press Enter again.
- While a turn runs a line `⠦ Thinking... (esc to cancel, 6s)` animates about
every 80 ms (largest gap mid-turn: 214 ms). The label can be any loading
phrase, so the working line is the `(esc to cancel, <n>` suffix, or a
spinner frame opening a line for when a long phrase wraps that suffix.
Nothing at rest matches either.
- A tool confirmation (default mode) replaces the composer, stops the
spinner and the pane goes silent (3.9 s gap), so it reads as idle.
Verified on an isolated instance from this branch: a YOLO turn emitted one
session:working (+170 ms) and one session:idle (2.5 s after the last output);
a default-mode turn went working -> idle while the confirmation waited ->
working once allowed -> idle at the end; after a server restart four restored
gemini panes went busy -> idle in about 4 s; a fresh launch settled in 3 s.
The launch-settle and uncharacterised-CLI tests that used gemini as their
example of a CLI without work detection now use grok and deepseek.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An omp session stayed "working" for good once a turn started, the same
latch pi had: omp's braille spinner trips the SPINNER_PATTERN fast path,
and only a composer glyph arms the idle confirmation. omp declared none,
so it fell back to Claude's `❯`, which omp never draws once its setup
wizard is done.
Measured on live omp 18.8.6 and 18.0.11 panes, holding a turn open
against a local endpoint that never answers: the input row is `╰─ <text>`
and is redrawn at submit, at the end of a turn, at launch and on
reattach. While a turn runs, the status bar's leading `π` becomes a
braille spinner plus the elapsed time (` ⠼ 14s > ⬢ model > ...`; 18.0.11
pads it with two spaces, past a minute it reads `1m`), with a
`⎋ Working…` row above it. The registry entry now names the input row as
the glyph and either working signal as the working line.
The glyph also switches the submit verifier on for omp, which reads the
input row the way it reads Claude's composer. A prompt sent mid-turn goes
to omp's Steering queue and clears the row, so the verifier stands down.
Text left in the row after an Enter is the one case it re-presses.
Verified end to end on a sandboxed instance (own HOME and PATH, omp
18.8.6): session:idle at launch, session:working during a turn,
session:idle about 3 s after it ended, and a restored pane settled idle
about 3 s after a server restart.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An opencode session that ran a tool stayed "working" for good. The running
tool row draws a braille spinner (`⠋ Sleep for 12 seconds...`), which trips
the generic SPINNER_PATTERN and marks the pane working, but only a composer
glyph arms the idle confirmation and opencode declared none, so it fell back
to Claude's `❯`, which opencode never draws. Measured on an isolated
instance: a 16 s turn latched busy/isWorking for the rest of the session.
A text-only turn had the opposite problem and never showed as working.
Measured on live opencode 1.3.0 panes (capture-pane every 250-300 ms through
real turns at 40, 60, 120 and 200 columns, plus the raw PTY stream):
- Every composer row starts with a `┃` bar, and the submitted prompt lands in
the transcript with the same bar, so a turn's first repaint arms the idle
check, and tmux's reattach repaint does the same for a restored pane.
- While a turn runs the footer row starts with an 8-cell knight-rider
spinner, `⬝■■■■■■⬝ esc interrupt`, redrawn about every 40 ms (largest
gap mid-turn: 121 ms). At rest the TUI is silent and nothing on screen
draws a `⬝`/`■` run, the 200-column sidebar included.
- The working line is the spinner run, `[⬝■]{8}`, not the label: tmux ships
`esc` and `interrupt` as separate words joined by cursor moves, so the
label never reaches the stream detector, and at 40 columns the footer
wraps it to `esc` / `interr` / `upt`. All 344 spinner chunks of a turn
match the run after Codeman's ANSI strip.
- A pending permission prompt replaces the composer and stops the spinner,
so it reads as idle (waiting on the user).
- The last `┃` row on screen is the composer's agent/model row, or the
permission box's closing bar, never the prompt text, so the submit
verifier stands down and can never press Enter into a dialog.
Verified on an isolated instance from this branch: a 15 s tool turn emitted
exactly one session:working (+271 ms) and one session:idle (3 s after the
spinner stopped); a permission prompt read idle and the allowed turn went
working -> idle; after a server restart both restored opencode panes (one
at rest, one on a permission prompt) went busy -> idle in about 4 s; a
fresh launch reached an open page as idle in 3 s.
The launch-settle tests that used opencode as their example of a CLI
without work detection now use gemini and antigravity.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A claude tab drew no harness mark at all and every other agent CLI a
two-letter text pill (DS, CX, ...), so a claude tab read as "no harness"
next to its neighbours. Session tabs (header strip, side rail, sidebar,
phone chips) and the desktop home rail now draw the agent through PR
#532's run-mode-dot <id> slot, the id as data, the same mark the Run
menus and the tile and split headers use. The shell is not an agent and
keeps its SH pill; a CLI added through clis.json gets the slot's plain
dot instead of nothing.
The per-CLI tab pill colours and their light-skin ink overrides are gone
(the monochrome marks follow the tab's own text colour), and the logo
steps aside with the other adornments while a compact rail row renames.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The owner's picks after testing the 1.36.0 beta:
- Header Stats Style defaults to Compact (two pills with rings) instead of
Tiles. The resolver, the pre-paint stamp and the App Settings option all
agree; an unknown value now reads as compact.
- The Tiles header button is ON by default everywhere but handhelds (their
defaults object keeps it off, and the button still needs a 1180px window).
The Ctrl+Shift+G gate in tileShortcutFor() now resolves an absent key
through the device defaults too, so the chord and the button cannot
disagree; before, it required a stored true.
- Tab Layout defaults to Classic (the single strip, as before). By state, By
case and Ledger stay available as opt-ins. The tile-grid beta the owner used
last had only this layout, and it is the one they wanted back.
zh-CN option labels follow the new "(default)" markers; docs and wiki updated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#538's by-state header strip lines its labels up in a column measured into
--tab-triage-gutter by _sizeTabTriageGutter(). Under 600px mobile.css hides
the headings (the phone row keeps its chips, no labels), but the sizer
measured them anyway: every part was 0 wide, yet `width + 5 * (parts - 1)`
made each labelled heading 5, so the "nothing to size" guard never tripped,
the column became 15px and the group key was cached as if measured. A phone
turned to landscape (>= 768px) or a foldable opened (Find N5: folded under
600, unfolded 1124 wide) then crossed into the wrapping strip with no tab
render behind it (the resize handler only calls updateTabOverflowMode()),
the key had not changed, and "WAITING 2" sat in a 15px column on top of the
first tab of its row.
Only the wrapping strip reads the column, so the sizer now measures nothing
and forgets its key while the strip does not wrap, counts only parts that
are laid out (a hidden heading sizes and keys nothing), and
updateTabOverflowMode() calls it right after deciding the wrap. Phones and
tablets therefore never measure (no layout read per render pass), and every
flip into the wrapping strip, the 768px breakpoint included, measures
afresh. The 600px breakpoint only matters below 768px, where nothing is
measured.
Tests in test/tab-triage.test.ts pin that hidden headings size and key
nothing, that the real updateTabOverflowMode() measures on the unfold with
no render, and that wrapping again re-measures with the counts unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On phones and 600-767px tablets the header strip stays one horizontally
scrolling row. #538 (Tab Layout, default "by state") orders that row in one
flex `order` band per state, so when the ACTIVE session changes state (a
prompt sent: idle to working; a permission prompt: needs you) its chip
moves to another band while scrollLeft stays put, and the tab in use left
the screen (measured at 390px: x 165 to -870). #257's reveal rules only
covered a CHANGED active tab: _updateActiveTabImmediate reveals on a
switch, and _fullRenderSessionTabs restores scrollLeft and re-reveals only
when _lastRenderedActiveTabId changed, while a state change is an
incremental pass that never reveals at all.
_noteActiveTabBand() records the active tab's band (read off the element,
so it is what is on screen) and reports when it moved while the tab stayed
active. Both render paths reveal on that, in the single scrolling row only
(_isScrollingTabRow: not wrapping, not a vertical list). It is keyed on the
band, not the raw order value, because another tab entering or leaving the
active tab's band shifts that value by one and another tab's move must not
yank a strip the user is browsing. _updateActiveTabImmediate records too,
so the pass right after a switch still counts (viewing a waiting tab spends
its alert and drops it into the idle row). The incremental path reveals
after updateTabOverflowMode(), and only from the branch that reached
_syncTabTriageChrome(), so a pass that falls through to a full rebuild
mid-loop leaves the record for the rebuild to compare.
Tests in test/tab-triage.test.ts pin the reveal on both paths and after a
switch, and that another tab's band change, a wrapping strip, a vertical
list and an active web tab never scroll.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
opencode's TUI enables mouse tracking. tmux runs with `mouse off`, so it passes
the PANE's DECSETs straight through to the tmux client, and the browser's xterm
obeyed them: `mouseTrackingMode` flipped to 'any' (measured 62 none / 18 any over
16s) and xterm then reported DRAGS to the TUI instead of selecting locally.
In that state marking text produced no selection at all, so copy-on-select
silently did nothing (5/5 dead drags while `any`), and the obvious fallback —
Ctrl+C — is opencode's `app_exit`, which ended the session. Both were hit here.
opencode needs the middle strip: alt-screen toggles AND mouse DECSETs, but NOT
`3J` (a TUI is not a `clear` consumer). That is `altScreen: 'strip-mux-and-mouse'`
+ `isMuxMouseStripMode`, applied to the live stream (session.ts) and the replay
of a stored buffer, now the exported `stripReplayBuffer()` (session-routes.ts).
The browser's mouse-report gate keeps no mode list any more:
`_shouldReportMouseToCli()` reads only `cliMouseTracking`. The server sets that
flag solely in the mouse-strip branch (`_recordStrippedMouseMode`, one caller),
so it can only be true for a mode whose DECSETs are stripped, and whichever modes
the registry strips, the browser follows. Clicks still reach opencode through the
hand-encoded SGR tap it gates.
The `altScreen` JSDoc gets the decision table its three independent choices need
(alt-screen / `3J` / mouse DECSETs), written from the predicates, including that
`preserve` and `strip-mux-only` take the same runtime row. The table is pinned for
every stock CLI, with and without tmux, on both the live strip and the replay
strip, plus the published flag (test/claude-scrollback-strip.test.ts), so the two
halves cannot drift and a mis-ordered replay branch fails.
Docs and comments that still said opencode keeps its mouse reporting or gets the
narrow strip are updated (CLAUDE.md, architecture-invariants, scrollback and
copy-shortcut plans, session.ts, terminal-ui.js).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
opencode's TUI runs on the ALTERNATE SCREEN (measured on 1.18.31: tmux
`alternate_on=1`, `history_size=0`), so tmux keeps no history for the pane and
the browser's normal buffer never grows past one screen (`baseY === 0`). The
plain wheel therefore scrolled a buffer with nothing in it — dead in every
opencode tab, on desktop and touch alike.
opencode is not a forwarding candidate: it IGNORES SGR wheel reports (six
`\x1b[<64;…M` reports against an idle pane left the capture byte-identical),
but it does page its own transcript on PageUp/PageDown (`messages_page_up/down`,
verified on the same pane). The hollow-buffer rescue already sends exactly those
keys — it was just gated to `claude`. Widen the gate to opencode so the wheel
and touch gestures reach the CLI's own transcript instead of a no-op.
Every other mode stays out: shell/pi own real terminal scrollback, and
codex/gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified
(docs/scrollback-fix-plan.md).
Test: test/terminal-scroll-routing.test.ts — new opencode case (Red before the
fix, Green after); the "real local scrollback is untouched" case now also pins
antigravity as not-paged.
Found live: closing the grid with a click starts the single view's
selection, which focuses its terminal when its replay lands, a few
hundred milliseconds later. A right-click on Tiles in between opened the
count menu with the keyboard in it, and the late focus then moved the
keyboard into the terminal while the menu stayed open (3 of 3 tries), so
the arrows, Enter or Escape meant for the menu went to the session's
PTY instead (an Escape arrived there as an ESC byte).
The menu now closes when the keyboard leaves it for another element, as
any menu does. A focus going nowhere (a click on a button in Safari,
which does not focus it) does not count, so a click on a count still
picks it. Live afterwards: the menu either closes as the terminal takes
the keyboard, or keeps it when the replay landed first; never open with
the keyboard elsewhere.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- docs/tile-grid-plan.md: owner decision 10 (right-click Tiles is a
2 / 4 / 6 count menu, default 6, remembered per device; the session
picker is gone; decision 8's "picker on right-click" and "exactly the
stored set" superseded) with the owner's answers on the details; three
as-built bullets (the count menu, the animation, painting first); the
Tiles-button bullet rewritten for the count; the entry points, the
capacity note, the multi-user row and the Escape invariant follow.
- docs/architecture-invariants.md: the Opening paragraph rewritten (the
count, the stored grid in its cells, the menu owning its Escape, the
paced connect and focusOnConnect, the motion rules); the z-index list
names the count menu and the closing grid's still copy.
- CLAUDE.md: the tile grid paragraph and the z-index line.
- Wiki: Tile Grid (the click, the count menu, the animation, reduced
motion) and Keyboard Shortcuts (right-click Tiles).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner feedback 1: "so the empty tab doesnt always have to be the last
one! so I can move freely around and the empty tab can also be tab nr
4 or 3". This replaces the slot refusal of aecada8c.
grid.cells (a session id or null per cell) is now the one source of
truth; grid.ids is a getter deriving the tiles in reading order, so
everything that only wants the tiled sessions (focus neighbour,
cycling, the load queue's order, the picker, closeSession) is
unchanged. The shape still comes from the tile count and the cap
counts tiles, never empty cells.
- A tile dragged onto an empty cell moves there and leaves its own
cell empty, nothing else moving (_moveTileToCell, through
_reorderTiles: no remount, reconnect or reload; only a tile whose
cell size changed fits). A tiled session's tab does the same; a tab
of a session not tiled yet joins in the cell it is dropped on. Each
slot knows its cell and reads "Drop a tab or a tile here".
- Move Tile goes to the adjacent cell: into it when empty, a swap when
a tile is there (tileCellInDirection).
- Removing a tile leaves its cell empty; adding one takes the first
empty cell. A shape change goes through fitTileCells: each tile keeps
its row and column when all fit (2x2 growing to 3x2), else the tiles
pack in reading order.
- Focus never lands on an empty cell: Alt+Shift+Arrows run over the
cells, Ctrl+Tab and Alt+[ ] over the tiles.
- codeman:tile-grid stays ids only: its ids are the cells with null for
an empty one. A reload brings the holes back when the shape is the
same (a session gone since leaves its cell empty), another shape
packs, the old packed format reads unchanged, and a followed
#session= link keeps the holes.
Docs: the spec's as-built bullet (rewritten in place), the wiki's Tile
Grid page and Keyboard Shortcuts, the invariants and CLAUDE.md.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The owner's answers on moving tiles:
- "dont move the tile": an empty slot no longer takes a tile. A slot is
always the last cell, so a move there shifted every tile after it.
Each drop target now says what it accepts (_acceptTabDrops'
`accepts`): a tile takes any session but its own, an empty slot only a
session not tiled yet. A refused drag is still held (dropEffect none,
no highlight), and dropSessionOnSlot refuses a tiled session too, its
tab included. A session not tiled yet still joins on a slot.
- A cancelled drag changes nothing, focus included (best practice): the
header focuses its tile on click, never on press, so a drag that ends
with Escape or outside leaves focus and the idle alert alone. The body
keeps press-to-focus, so focus still moves before a press reaches
xterm. The rename input stops its own clicks.
- A tiled tab dropped on the zoomed tile stays refused (confirmed).
- The Alt+Shift+Arrow focus chords skip a text field too (best
practice), as the move chords already did: shifted arrows select
there. Toggle and zoom are not text-editing keys and are unchanged.
Docs: the wiki, the spec's as-built bullet (with the owner's answers),
the invariants and CLAUDE.md say so.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The wiki's Tile Grid page gets a "Moving tiles" section and the move
chords in its keys table (with what they leave to a text field and
take from a terminal editor inside a tile); Keyboard Shortcuts lists
the chords and the header drag. The spec records moving as an owner
request in its as-built list, with the reasoning behind the default
keys. The invariants and CLAUDE.md say every move goes through
_reorderTiles (no remount, reconnect or reload; only a tile whose cell
size changed fits) and that the header drag carries its own type and
is not draggedTabId.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The digit rule from 21ae48a5 hid the official DeepSeek ids (`deepseek-chat`,
`deepseek-reasoner` carry no digit), so a session on the official route with
the model field on showed the logo alone (its bundle row pins a provider
alone, so the config had nothing either). It also still misread a folder name
with a digit when every field before it was off.
Now the captured field is rejected when it is what the field can be when it is
NOT the model, and read otherwise:
- capabilities.modelDetect.rejectWords (registry data, single tokens, compared
ignoring case; the schema bounds them and requires a screenLine). dsh lists
every effort id its adapters offer (pi-ai THINKING_LEVELS plus the DeepSeek
adapter's off/low/high/max) and the shipped mode ids, from dsh 0.1.1-rc.2 /
dsh-TUI 0.10.0-beta.1. A mode's drawn label (`plan mode`, `full access`,
CJK) can never be one captured field.
- In the shared screen reader, for every CLI: a field equal to the session's
own working-directory basename is the folder, never the model.
Fixtures: `deepseek-chat` and `deepseek-reasoner` with the model field on are
read; every effort id, `default`, `plan mode`, and the folder name first (with
and without a digit) are not; the live qwen footer still reads `qwen3.8-27b`.
Known gaps, all off by default, are named in stock.ts: a custom mode id drawn
raw, a git branch or a one-word session title first, and the non-compact
footer layout (nothing read there; the route config applies).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- api-reference: the `config` source in the displayModel table, read again at
every pane start, attach and relaunch rather than restored.
- cli-registry: `modelDetect.configResolver` (a named, read-only, bounded
reader), the stock `deepseek-route` reader and its rules, and why the dsh
footer pattern needs a digit.
- deepseek-integration §4: Codeman reads the route for display only, the way
dsh-TUI resolves it; the catalog check it cannot see.
- architecture-invariants (tile grid), tile-grid-plan "as built" (owner
feedback 1), the wiki's model row, CLAUDE.md's source order.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- api-reference: the new `displayModel` session field, its sources in order
(custom-endpoint, statusline, screen, launch) and that it is untrusted
display text, persisted and restored when the CLI reported it.
- cli-registry: `capabilities.modelDetect` (one capture group, the last rows
of the probe's capture, anchored on chrome only that CLI draws), the two
stock patterns (dsh-TUI, codex) and the fifth config regex.
- architecture-invariants (tile grid): the header painter, the id as data, the
untrusted model text, no writes for an unchanged session, the truncation
order, Pane A's strip and its fits through syncTerminalGeometry.
- tile-grid-plan "as built", the wiki's Tile Grid page (logo and model rows,
Split's strips), and CLAUDE.md's tile grid and CLI registry paragraphs.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The invariants for this performance pass: a tile's replay holds the load
queue only while xterm parses it, and destroy() settles a replay in
progress; the main terminal's resize timer refits the split's Pane B
only, leaving grid tiles to the grid's observer; the page's SSE filter
names TILE_GRID_SSE_FILTER while tiles own the terminal; grid tiles send
lines= on their full captures. Plus an "As built" note in the spec, whose
parking section still says the subscription stays [activeSessionId].
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The spec records decision 9 and an as-built entry replacing the "+ / New
session in this case" one, and marks the target picture, the header line
and the + bullet as built without it. CLAUDE.md's header list, the
invariants' z-index line (the + menu's layer) and the wiki's Tile Grid
page (the header string, its table and the cap sentence) drop the +.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner request: translate "Run SH" and the rest of the Run family, plus
the two leftovers from the last report.
Run: one pattern turns "Run <code>" (Run CC, Run SH, Run OC, Run CX ...,
and any registry CLI's shortBadge) into "运行 <code>"; the mode codes and
product names stay, and exact entries still win ("Run Shell" was already
运行 Shell, "Run OMP" 运行 OMP, "Run PI" takes the "Run Pi" entry). New
entries: "Terminal / Shell" (the run menu's shell item) and "Send Enter"
(the phone toolbar's Enter button title). The phone overview's Run button
already showed 运行 beside a mode word kept as typed.
Help modal and shortcut overlay: "Tabs", "Toggle Session Sidebar", both
"Copy Selection" rows, "Focus Tabs", and "Wheel" (滚轮, a mouse input like
Click). Key names stay English: the Help modal's Home key, and every key
the overlay renders, now carry data-i18n-skip, because "Home" is also a
dictionary word (the Home button) and showed as 主页 in the key column.
The invariants' paneExit section gains the badge's translation rule
(from the previous commit): its updates compare with the remembered
English, never the DOM.
Tests: i18n-exit-run-help covers every Run label _applyRunMode can show
(its hard-coded ones and Run <shortBadge> for every stock CLI), the
toolbar titles, the Help modal through the real translator in JSDOM (no
English outside the key column, the Home key kept while the word Home
elsewhere still translates, Wheel translated), every shortcut registry
group and label, and that the overlay's keys are skipped.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Drawing only the selected tab's family hid every other connection until you
clicked into one. All families are drawn again (still as routed trees); the
selected tab's families (what it spawned, and the family it was spawned into)
get .lineage-family--focus: 2.5px instead of 1.5px, full opacity instead of
0.75, a larger end dot, and drawn last so nothing covers them.
Lanes follow strip order (cycling through CodemanLineage.MAX_LANES, 3), never
the selection, so a family's lines never move when you click a tab.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner request: with App Settings language set to 简体中文, the grid reads
fully in Chinese. Every string the grid puts on screen gets its own
ZH_CN entry, so none reaches the generic leading-verb fallback: the Tiles
button (both states, with the right-click hint), the Tiles and Split
chips, the grid region, the Help modal's Tiles rows, the shortcut
registry's Tiles group and labels (overlay and App Settings list, and
"not bound"), "Open group as tiles", the picker, a tile's +, the header
buttons, the Attach overlay (not attached, attaching, exited, ended, the
hint), the empty slot, the dividers, the Split button while tiles are
open, and the toasts. Strings with a count, an exit code or a duration
are translateDynamic patterns: the cap texts (both wordings, with and
without ": the new session opens on its own"), "This window fits N
tile(s)", the auto-zoom hint, "The agent exited (N)" / "(signal N)", the
crash-restart confirm (the existing confirm wrapper runs it through t();
the session name passes through untranslated, in the single view too),
and the tile header tooltip ("idle 3m"), which requires the duration so
bare state words stay out of the table (they collide with other
surfaces, see mobile-overview.js).
Wording: 平铺 for the feature, 窗格 for one tile, 附加 for attach, 智能体,
案例, as the table already has them. Key names stay; Click, Right-click
(mouse actions) and Arrows in the Help modal's key column are translated.
English reads exactly as before (only additions to the table).
test/tile-grid-i18n.test.ts drives the real tile code through every
state that writes text, harvests each string and requires Chinese with
no Latin word left beyond key names and durations, and the same English
in en; plus the markup through the real translator in JSDOM, and session
and group names (also when they equal a UI word) staying untranslated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Lineage lines used to draw one glowing dashed bezier per parent/child pair,
hanging below the tab strip, for every family at once. With a parent on row 3
of a wrapped strip and ten children below it, the curves crossed every lower
row's tab names and ran through the terminal text.
- One rounded orthogonal tree per spawning tab: every route starts at the
parent, so siblings share a trunk. 1.5px, solid, 1px dark outline; only a
working child's branch is dashed.
- Routes run only through the gaps between tab rows, joined by a spine left of
every row (computeLineageTree/computeLineageRows in constants.js), so they
never cross a tab or reach the terminal. The vertical rail gets the same tree
on its existing left track.
- Only the selected tab's family is drawn (what it spawned, plus its parent and
siblings). Selection redraws, and the strip's size transitionend redraws once
more, since the active tab widens for ~150ms after the selection redraw.
- The routing room is reserved by .session-tabs.lineage-tree, keyed on whether
any lineage exists, never on the selection, so a tab switch never resizes the
header or the PTY.
- Colours stay per spawning tab, now claimed in strip order for every family so
selection order never decides who gets the skin blue.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner feedback: the tile header's ⋯ ⤢ + × read as tiny next to the
session name. The buttons inherited the header's 12px font. They are now
26px click targets (min-width, so a wider glyph still fits) with a 16px
glyph, the same as the app header's own icon buttons (.btn-icon-header);
the thin ellipsis and cross get 19px so all four read at one visual
size. The header grows from 24 to 28px to hold them, the inline rename
input to 22px. Checked live at DSF 1 on a dark (daylight-blue) and a
light (paper-gray) skin, focused and unfocused tiles, and a zoomed tile.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner decision 8 ("when I hit the tiles button, open the tiles already!").
A click with the grid closed now opens it straight away, and Ctrl+Shift+G
runs the same function (toggleTileGrid), so the two cannot drift. What
opens comes from one pure helper, tileGridOpenSet (constants.js):
a. the grid this tab last had, if any of its sessions survive, opened
exactly (an open split closes and its sessions do not join);
b. else an open split's two sessions, Pane A focused;
c. else the open sessions in tab order (the picker's list: no detached
ones), up to what the grid takes here (the cap of 6, fewer when the
window fits fewer), the active session always among them and focused.
A click with the grid open still closes it.
The picker moved to right-click (oncontextmenu, browser menu suppressed).
With the grid open it is preselected with the current tiles, and Open
replaces them. Ctrl/Cmd+click on a tab with the grid closed opens the
toggle's set plus that session. The button's title, the Help modal and
the wiki say right-click chooses which sessions.
Docs: decision 8 and an as-built entry in the spec (Entry points too),
CLAUDE.md, the invariants (#tile-grid, Opening), the wiki's Tile Grid and
Keyboard Shortcuts pages.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Six was tested smooth on the owner's desktop; nine missed the headless
frame bar and is untested on real hardware. TILE_GRID_MAX (constants.js)
is now 6 and stays the one cap every limit reads; the layout table gets
its own bound, TILE_LAYOUT_MAX = 9, so the 7 to 9 layouts keep working
(unreachable) and going back to nine is that one line.
Every way in stops at the cap: opening, addTile, a tile's +, a session
Run makes, Ctrl/Cmd+click, the picker, "Open group as tiles", and a stored
grid with more ids (it comes back as its first six, focus kept only if it
survives, a dropped zoom cleared, row fractions that no longer match the
3x2 reset). The limits now go through one helper, _tileGridLimit(), whose
texts say which limit binds: "Up to 6 tiles" / "The grid holds at most 6
tiles" when it is the cap, "This window fits N" when it is the window.
Docs: decision 7 and an as-built entry in the spec, CLAUDE.md, the
invariants, the wiki's Tile Grid and Dashboard pages.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The spec listed it as the default applied while the owner's answer was
pending. The owner has decided: with showTileGridButton off the chord is
inert and passes through like any unbound key; on, it toggles the grid.
Recorded as decision 6 in the spec (with a line under Gating), and as an
owner decision in CLAUDE.md and the invariants.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A session Run makes while the grid is open joins as a tile right away, so
the tile connects and sends its size before Run starts the pane. The
server only records a resize for a session with no PTY and spawns the pane
at 120x40, and Run's own resize step measures the parked main terminal
(display: none, so nothing). Measured live for Shell and Claude: a 97x17
tile over a 120x40 pane, for good (#464).
The chrome refresh now remembers each tile's last-seen pid and calls
TerminalTile.paneStarted() when it appears or changes. paneStarted()
forgets the sent size and sends it; a hidden tile (a zoomed neighbour)
sends nothing and keeps it forgotten, so its next fit() sends it, which a
plain fit({ force: true }) would lose. Keyed on the sessions map, so a
handleInit after an SSE drop counts too.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- CLAUDE.md: a Tile grid paragraph beside the split-pane one (parking, the
one load queue, the selection and close rules, chords, dividers, auto-join,
the exited-agent case), tile-grid.js (7.6) in the load order, the
desktop-gated header markers and the Tiles picker in the z-index stack.
- docs/architecture-invariants.md#tile-grid: the mechanisms and the reason
behind each rule; the split section now says where a waiting grid load
differs and that every capture carries a deadline.
- docs/wiki/Tile-Grid.md: the user manual page (turning it on, the ways in, a
tile's header, keys, leaving, persistence, Split), linked from the sidebar,
The Dashboard, Keyboard Shortcuts and Settings Reference.
- The Help modal lists the tile chords (pinned in help-modal-shortcuts.test).
- docs/tile-grid-plan.md: status updated, and an "as built" list of where PR 2
went another way than the spec.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Close Session was bound to Ctrl+W by default. Ctrl+W is delete-word in
every shell, readline prompt and agent CLI, so muscle memory killed the
session (its tmux pane and CLI, with no confirm) mid-sentence, and with
the split pane open it was not even the pane being typed in.
Close Session now has no default key: the capture-phase handler lets
Ctrl+W through and xterm sends ^W to whichever pane is focused. The
action stays in the registry and can be bound in App Settings ->
Shortcuts; the shortcut overlay shows it as not bound. The Help modal,
CLAUDE.md, the split and tile-grid specs and three wiki pages stop
advertising Ctrl+W as kill. Owner decision (tile-grid decision 5).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CLAUDE.md, architecture-invariants#split-pane-sessions and the split-pane
spec described Pane B as having no reconnect, seq-less input and xterm's
own Ctrl+V. Updated for TerminalTile (terminal-tile.js, load order 7.4):
reconnect and stop codes, the input-socket map and which input is kept
out of the persisted queue, image paste, the geometry rules, and
_focusedPane() with its Ctrl+W exception. The tile-grid spec now records
PR 1 as built (no key handler factory; scheduleLoad and scrollback move
to PR 2 with their first user).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Compact now draws every reading the same way, ring then label then
value: CPU and MEM get an accent ring (red past 80%, like their value)
in place of the sparklines, the plan windows keep their green, yellow
or red rings, the dot separators go, and both pills share one label and
one value style. The sparkline markup, history and CSS are removed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Idle is the default state of a tab, so naming it only adds noise. The
idle group is now quiet: its heading element stays (it anchors the row's
order band and, as the first row, holds its place beside the brand) but
draws no label or count. Needs you, Waiting and Working are unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
In the desktop header strip grouped by state, the brand leaves the flow
and sits over the strip's top-left corner, so every row after the first
starts at the left edge under "Codeman" instead of leaving that corner
empty. Labels are left-aligned in the measured column; the first row's
heading takes its natural width beside the brand (--tab-triage-brand,
kept by a ResizeObserver so no render pass reads layout for it).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>