An absent `agentSkillEnabled` now means on, on the server
(`getAgentSkillEnabled()` reads `!== false`, like `workspaceHooksEnabled`)
and on the page (the App Settings switch loads `!== false`, so a Save of any
other setting no longer writes the old default back). Only an explicit false
turns it off. A partial settings PUT that omits the key leaves it as stored,
since the route merges the request into the file.
The switch's help text says it is on by default, that the copy stays out of
git, and that turning it off removes no copy already written. The shared
route-test mock now defaults to on too, so route tests see what a user sees.
Tests: quick-start against a real server (absent key injects, a partial PUT
keeps it on, an explicit false stays off through a later partial PUT), the
real openAppSettings/saveAppSettings in JSDOM (absent shows on, explicit false
shows off, a Save on a never-touched install sends true and passes the
schema), and the plain create route at the mock's default.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
105 new em-dashes sat in comments, test names and one test fixture from the
foreign tmux adoption work. Replaced by hand with commas, colons or
parentheses, one reading at a time. The fixture now quotes the real refusal
text, which says "It may still be running, try again."
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The attach line now takes the rule #770 gave remote and docker launches: bashCArg() single-quotes it at both respawn-pane sites, so it reaches bash verbatim. quoteAdoptLaunch is retired, since its double quotes kept the same $ and backtick safety but lost a real newline. #510's no-cd condition stays.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Session 0 in two containers, or one name on this machine and on an ssh host
with the same uid, shared a (socket, name) pair, so opening one returned the
other's tab and its row read "Go to tab". foreignTargetKey() is now the one
key, built from the location, the host identity, the socket and the name, and
both the one-wrapper check and adoptedBy read it.
Both also read live sessions instead of mux records, which only exist once the
first attach starts the pane. An in-flight mark closes the rest of that window:
the route awaits the registries and the layout lock before the session lands in
the map. Docker targets are keyed per engine for the same reason, or a second
daemon's container of one name was never scanned.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings the 1.33.2 based adoption feature up to master. Conflicts were
resolved by keeping master's intent and re-applying the feature on top:
both sides kept for imports, the script tag and the stylesheet blocks; the
trust-dialog guard keeps master's stopped check plus the adopted refusal;
the Ralph and image watchers keep master's remote branch plus the adopted
skip; the close confirmation keeps master's worktree option plus the
adopted wording.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getCustomModelForPersist() trusted homeEnvVar to name a path, but the registry
types it as any env name, so a clis.json override could write a session's own
credential to state.json. Only an absolute path is written now.
A re-apply resolved the CLI's real home from what the session's map held, which
under a selection is the injected directory, and recovery resolved none at all.
An entry the own home lacks (settings.yaml) was then linked from the server's
home on an endpoint switch and on recovery. The route now reads the new
Session.ownEnvValue(), and recovery reads the clamped record through the same
bounded guard, under the bulk cap since it walks every saved session. Also
corrects the comment that said only a removed directory gets its links again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Consolidates the four contributor changesets, and rewords the em-dashes this release
added in comments and docs. The five in test/settings-save-failure-ui.test.ts stay:
they assert existing toast text.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- linkFromRealHome/realHomeFallback on the configDir recipe; the apply consumer is generic and names no CLI
- lstat guard so boot-recovery re-applies survive a dangling link; symlink is best-effort
- resolve the real home from the session's own dirEnvVar override before the server's, threaded through both routes and boot recovery (Session.envOverrideValue)
- deepseek links profiles/sessions/settings.yaml; boot-recovery test covers the session lookup
Conflicts resolved by keeping both sides:
- CLAUDE.md (Agent CLI): #694's opening (an HTTP client, the experimental
agent-session-routes behind agentCoordinationEnabled) with #708's clause
on the directory spawn and its two refusals.
- docs/api-reference.md (codeman agent): #708's spawn paragraph and --json
shape, with #694's inspect/stage/submit in the verb list and the guarded
endpoints named before "adds no route for the other verbs".
- src/cli-agent.ts (@fileoverview): #708's quick-start / directory spawn
routes and #694's experimental routes in one endpoint list; the two
node:fs/promises imports (#708's realpath+stat, #694's open+stat) merged
into one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflict in CLAUDE.md (Adding Features, App setting) resolved by keeping
both sides: #714's sentence that a setting the server acts on must be
synced, after the `.strict()` clause, and #719's mutateSettingsFile() rule
at the end of the bullet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- src/web/routes/session-routes.ts:2102: the guarded-submit 409 now applies to tagged input (clientId + seq) only; untagged input is held like a WebSocket frame. The raw write (:2228) goes through holdForGuardedLease and answers 200 once held (a send-and-wait reads `delivered` from the held write; a full queue releases the waiter and answers 409); a mux write takes its own turn in writeViaMux.
- test/guarded-lease-held-input.test.ts: real-Session route tests for an untagged raw POST during the lease (written after the Enter) and a held send-and-wait (delivered: true); both fail without the fix.
- test/routes/session-routes.test.ts + test/mocks/mock-session.ts: the 409 test now covers tagged input only; new tests for untagged raw (held), untagged mux (own turn) and a full hold queue (409); MockSession gains holdForGuardedLease/releaseGuardedLease and a lease-aware writeViaMux.
- src/session.ts:4787 (guardedInputHeld JSDoc) + write() + docs/architecture-invariants.md: write() refuses during the lease only, so cron's paste mode can land ahead of input still draining.
- docs/api-reference.md: the input route's 409 is for tagged input; untagged input is held.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The network acknowledgement that install.sh writes for every passwordless LAN install also satisfied the tunnel guard, so one click on the welcome screen's tunnel link published full terminal control to a public URL with no login and no confirm. The tunnel now needs CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL or --allow-unauthenticated-tunnel (carried by web -d and service install), and with no login the browser asks before every enable, reading a new loginConfigured field from GET /api/tunnel/status.
The guard is now decided once per request and required again where the tunnel starts, so a save that found the tunnel running can no longer restart it unguarded after a concurrent stop.
Closes#706
Fetch is the only git command Codeman runs that writes, and the only one that runs a
repository's own fetch-post hook, so it stays behind a click and off by default. The Git
status window's header row gains Fetch; App Settings -> Bottom bar gains the switch and
the interval, synced because the scheduler acts on them, and Session Options -> Git
overrides both per case. The scheduler reads settings fresh each tick, times the interval
from the start of a run, never overlaps runs, and re-checks the Docker and remote rules
every tick before any git runs. Nothing but remote-tracking refs moves: HEAD, the index
and the working tree are proven unchanged against real git while origin/main advances.
Tests: src/git-fetch.ts and the scheduler, the per-case settings route, the synced/display
setting split, the two new i18n rows, and the Fetch block of the Git status browser suite.
- The history is deleted when the session's persisted record goes (forgetEditHistory):
kill via cleanupSession (a pinned record is only demoted and keeps it), the
persisted-only DELETE, and stale cleanup at startup. After remove() neither a sync in
flight nor a later request writes the file again; writes are serialized.
- load() re-bounds a damaged file: cursors, pending Bash calls (MAX_PENDING_BASH) and
subagent labels (MAX_AGENTS, oldest unused dropped first).
- Load older reads one block of every transcript, budget split between them, and only
the files it asked for; a block short of budget reads its end. A line longer than the
budget is skipped going back as well (floorMidLine).
- Concurrent first callers share one store; a forward request folded with an older one
still reads forward.
- Images outside the workspace get no thumbnail (one attachment id per render); a click
opens the shared preview, which registers once with notify: false.
- Docs: per-edit diff cap, deletion rule, load-time bounds, thumbnails, follow triggers.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Part 1 of #643 (local sessions only; remote follows as its own PR). An
opt-in, per-device header button opens a panel with the active session's
edits, newest first: every Edit/Write/MultiEdit/NotebookEdit of the main
agent and its subagents with its diff, plus images a Bash call rendered or
the agent sent. Remote, Docker and non-claude sessions answer `unsupported`.
- Reads only on request: `?sync=1` when the panel opens; while it is open on
that session it re-reads after an edit-capable `transcript:tool_end` or a
`transcript:complete` from the existing transcript watcher. No hook, timer,
second watcher or new SSE event; a wiring guard pins it.
- Main transcript: the path the transcript watcher follows (hook-reported),
else `<id>.jsonl` under the session's own CLAUDE_CONFIG_DIR override or
the server's config dir. A config dir set outside Codeman is found with
the next hook; until then the panel says so.
- Store: dataPath('edit-history')/<id>.json, 0600 via tmp+rename, capped at
500 entries and 4 MiB of diff text per session, deleted with the session,
blocked in isSensitivePath, never fed to /api/search. Every route goes
through findSessionOrFail with req.
- Images only through existing file surfaces: file-raw inside the
workspace, the attachment route with notify:false outside it.
- textContent rendering, bounded byte-offset reads, marker-class header
button, hidden on phones (locked in the mobile policy test).
Docs: Working-With-Files, Settings-Reference, architecture invariants, API
reference, CLAUDE.md key pattern.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Conflict in CLAUDE.md's State Files line resolved by keeping #661's new
config-backup entries and this PR's "case default groups" in state.json.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Merge-time fix for #660. CLAUDE.md and architecture-invariants still said a
raw npx vitest skips setup.ts, contradicting the updated Testing note: both
now say it resolves the root vitest.config.ts (and why that file must stay).
The architecture-invariants entry for safeRmHomeTree adds the sandbox-HOME
requirement (isSandboxedHome, TEST_HOME_PREFIX), the same rule for
isUnderTestHome, the route-harness assert and the 2026-10-10 incident.
CLAUDE.md's repo-root preamble lists vitest.config.ts with its reason, so a
tidy-up does not move it into config/ and reopen#640. The
assertSandboxedHome JSDoc no longer claims every route test is covered: only
the ones built on createRouteTestHarness are.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Case settings gains a "Default session group" selector (Ungrouped plus every
named tab group of the owner). A new session of the case lands in that group
in the same versioned layout write that registers it, so it is drawn in place
with no Ungrouped-to-group jump.
- Stored per owner and case in state.json (caseDefaultGroups), as the group's
stable id or null for Ungrouped; read and written only through
TabLayoutService under the owner lock.
- Precedence in sessionCreated: explicit tabGroupId, then the parent
placement, then the case default, else Ungrouped. A deleted or foreign id
never fails a spawn and is forgotten only after the creating write lands.
- POST /api/quick-start (local, linked, remote, Docker) and
/api/ralph-loop/start pass their case; POST /api/sessions infers it
lexically from workingDir. The create response carries tabLayout when a
case default placed the session.
- GET/PUT /api/cases/:name/default-group; deleting a case (or an admin
deleting a user's case folder) forgets the value.
- The selector mirrors desktop and phone, reverts on a refused save, and is
redrawn from every adopted tab layout; zh-CN strings added.
Only a human spends an idle alert, but the keep-alive turn resolved an unseen one three ways: its working transition, its stop hook, and its own idle prompt replacing the item with a pre-acknowledged one. While the turn runs, a pending idle item is now left exactly as found, the page's stop handler returns on the keep-alive reason before clearing hooks or raising Response Complete, and session:working/session:idle carry keepAlive so the stuck-detection timer neither restarts nor re-arms.
The window that marks the turn closes on its own idle prompt or any other submit instead of a fixed three minutes, which a slow turn's idle prompt (sent a minute after the turn ends) could outlast. The service keeps the default and cap in memory, read through a strict reader so a mid-write read is retried rather than kept, and the cap is clamped before the strict settings save.
Claude Code lets a subscription's one-hour prompt cache expire after an hour idle, and the first turn back re-writes the whole conversation at the cache-write rate; nearly every gap over an hour goes cold, while the saving is concentrated in gaps of one to four hours. Codeman now sends one short keep-alive turn five minutes before the expiry Claude Code reports on its statusline, so the next real prompt still reads the conversation from cache, and stops two hours (configurable) after the last real prompt.
Codeman owns the idle policy for these sessions, as agreed in Discussion #554: a launch with keep-warm on also carries idleCompaction: false in its one --settings JSON, so Claude Code's own idle compaction cannot replace the conversation with a summary a minute before the nudge. It is decided once per launch (keep-warm on, the statusLine exporter injected, Claude Code 2.1.290 or newer, an unknown version fails closed like --name), recorded on the mux record, and picked up by any relaunch; a launch with keep-warm off is byte-identical. A session switched on after launch is kept warm best-effort and the readout says so.
Every guard lives in one pure rulebook: the opt-in (an agent-spawned session does not inherit the app default), a launch that reports cache state, a warm 1h cache with enough tokens at stake and no compaction this gap, a live pane, no usage-limit pause, no respawn, Ralph, task or waiting agent, idle, no open dialog, an empty composer and no recent keystroke. The nudge is a submit and moves lastSubmitAt, which the response viewer's conversation correlation needs; the cap counts from its own persisted lastRealPromptAt instead. Read My Mind drops the exact nudge text, and the turn's stop hook and idle prompt open pre-acknowledged so nothing alerts. The last cache report and the clock survive a restart, so the timer re-arms.
safeRmHomeTree and isUnderTestHome answered "is this path under process.env.HOME?".
With test/setup.ts skipped (a bare vitest) HOME is the developer's real home, so every
real file passed, which is how the real linked-cases registry was deleted on 2026-10-10.
They now also require HOME to be the throwaway codeman-vitest-* directory setup.ts
creates, and the shared route harness throws when it is not, so every route test refuses
to start against a real ~/.codeman rather than just the one file that checked.
A selection mixing a file the server takes with one it refuses dropped the refused one without a word, and the Compose and paste-dialog pickers never toasted even when every file was refused. Every pick, drop and paste filter now goes through one helper that counts what the classifier refused: a partial refusal toasts how many were skipped while the rest upload, a full one keeps the existing Unsupported file type toast, and a clipboard paste that falls back to its text stays quiet.
Claude Code's statusline has carried a prompt_cache object since v2.1.251, and Codeman's statusline exporter already received it, but StatusTelemetrySchema stripped the whole object, so Codeman never knew whether a session's conversation was still cached. Keep it and show it.
StatusTelemetrySchema now lists every documented scalar field null-tolerant (several are documented as possibly null, and the object gains fields across CLI versions, so a strict shape would 400 the post and blank the footer), while parsePromptCache normalizes only the subset Codeman stores, so a hostile localhost payload cannot bloat state. Session.promptCache rides toState with a change-only session:promptCache event, and the route broadcasts the stored object.
The Session Options (Respawn tab) readout shows warm-until or cold with the re-read stake, the cache hit rate, the last miss's causes, and, for Claude Code's own idle compaction (recache_tokens_if_cold null while expected_rebuilds rose), a "compacted at HH:MM" note that is never raised as an alert. setPromptCache stamps that on a new object rather than mutating the parsed report, so the stamp reaches the broadcast. Sessions without a statusline of their own also get a cache:until HH:MM / cache:cold group in the terminal footer. The last report is restored on a Codeman restart (like displayModel) so the readout is not blank until the pane's next render; the readout is time-aware, so a value whose expiry has passed reads as cold, not stale-warm. A launch without the exporter records why (collection off, workspace statusline, remote) so the row says the cause instead of "not reported yet" forever.
This is the sensor a later opt-in keep-warm would read, and the TTL it reports is the source of truth rather than the plan tier.
Gesture control now moves tiles: with the tile grid open, pinch anywhere
on a tile and carry it onto another tile (they swap) or an empty cell (it
moves there); a session tab carried onto the grid joins it there. The
overlay asks tile-grid.js what is under the hand (tileAtPoint,
tileDropTargetAt) and drops through the mouse drag's own paths
(dropSessionOnTile / dropSessionOnSlot, so _reorderTiles stays the one
move path).
Detach Tiles (App Settings > Appearance, per device, OFF by default,
desktop only):
- a tile's header let go outside the browser window opens the session as
a pop-out (detachSession, which now takes an optional size and screen
position), the tile's size, near the drop point, after a 120 ms settle;
- dropped on another Codeman window's tiles it moves there: that window
docks it through the target's own drop and posts 'tile-adopted' on the
window channel, and only the window it was dragged from lets go of its
copy (a last tile goes to the welcome screen, never onto that session);
- a pop-out's title drags back onto any grid: the session is taken back,
the pop-out is asked to close and is released (stops answering
roll-calls; one a script cannot close says where the session went), and
its stale 'detached' answers are ignored for 2 s;
- a tile's menu offers "Open in a new window", and a hand that lets a tile
go outside the grid pops it out too.
With the setting off nothing changes. No server change.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Tiles button with no stored grid opened the first tabs, so the oldest
sessions got tiles while the busy ones were left out. It now ranks the
open sessions: working first (the most recently started turn first, keyed
off lastSubmitAt only, since a working pane's activity stamp is always
"now"), then the ones needing input (the red and yellow tab alerts), then
everything else by most recent activity, tab order breaking ties.
The ranking is pure (rankTileSessions in constants.js) and reuses the home
screens' classification (_mobileOverviewState) and stamps. It feeds the
toggle's fresh set, a count picked in the menu, Ctrl/Cmd+click and an open
split's fill. The active session is still always included and focused,
detached sessions are never tiled, and a page without the classifier falls
back to tab order. The vm harness now loads mobile-overview.js so the app
tests exercise the real classifier.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A grid tile or the split's Pane B (TerminalTile) left the mouse wheel to
xterm for a session running Claude's fullscreen renderer (claude 2.1.187+
with mouse tracking on, cliMouseTracking). That renderer scrolls its own
transcript on SGR wheel reports, which the primary pane sends it, while the
tile's xterm holds only Codeman's replayed repaint frames (tmux keeps no
history for such a pane). So in a grid of fullscreen Claude sessions the
wheel either scrolled nothing or dragged stale frames, Claude's pinned input
box with them, up the tile, and Claude's transcript never moved. This was
tile-grid-plan follow-up 4.
The tile now forwards the wheel the way the primary pane does
(TerminalTile._maybeForwardWheelToCli): the primary pane's own gate,
_shouldForwardWheelToApp(ev, target), asked for the tile's terminal and
session; the cell from _clientPointToCell(x, y, tile.terminal); a scrolled-up
viewport snapped to the live screen first; and the reports flushed through
the tile's own 40 ms coalescer to the tile's session (the primary queue
flushes to the active session). The encoding moved into pure helpers in
terminal-ui.js, CodemanTerminalInput.wheelDeltaWholeLines and
sgrWheelReports, which the primary pane's _wheelScrollLines and
_sendSyntheticSgrWheel now call too, so the two panes send identical bytes.
Shift+wheel, the explicit local-scrollback gesture, was dead in every tile
off macOS: Chrome on Windows delivers it as a horizontal wheel (deltaX), and
xterm's own scroller turns a Shift+vertical wheel into a horizontal one. The
tile now scrolls it itself (_maybeScrollLocalOnShift: scrollLines() on the
dominant axis, sub-line travel carried over, a shell tile's history pull
still asked on the way up), as the primary pane's capture-phase handler does.
Unchanged: inline Claude, opencode and older Claude still take the
PageUp/PageDown route (#555), shells and other modes keep xterm's own plain
wheel, and a tracking xterm or the alternate buffer stays xterm's.
Tests: test/terminal-tile-scroll.test.ts covers forwarding (geometry, tick
cap, coalescing, viewport snap, the tile's session rather than the active
one, Shift/tracking/alternate exclusions, byte equality with the primary
pane) and Shift+wheel (Windows deltaX shape, sub-line carry, shell history
pull). test/terminal-tile-scroll.browser.test.ts adds a real-Chromium case
with trusted page.mouse.wheel() events.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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>
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>
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>
- 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>
#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>
Owner feedback 1: "give me the hover info to right click over the tile
button to adjust it". The only hint was the native title, which the
browser shows late, small and unstyled.
The Tiles button now has its own hover card, under it in the count
menu's panel style:
- "Tiles · N" with the remembered count (live: a pick in the menu
changes it while the card is up), "Click: open the grid" ("close the
grid" while it is open), "Right-click: choose 2, 4 or 6 tiles", and
when the count does not fit the window what opens instead ("This
window fits 4 tiles: a click opens 4"; open, what fits). Shown from
the keyboard it adds "Shift+F10: the same menu from the keyboard".
- Shows 300 ms after a pointer that hovers rests on the button, or after
a keyboard focus (:focus-visible); never for a touch pointer or a
device that cannot hover (plus a CSS @media (hover: none) backstop),
never on a hidden button, never while the count menu is open. A short
fade on opacity and transform, none under reduced motion; it takes no
pointer.
- Hides on pointer leave, blur, Escape, a scroll and a resize, and on a
press, a click or a right-click on the button (capture phase, so it is
gone before the menu or the grid opens, and it stays gone while the
pointer rests there). openTileCountMenu hides it too, and the focus
the menu's Escape puts back on the button brings no card back.
- It replaces the button's native title (two tooltips never stack); the
aria-label stays and aria-describedby points at the card, which always
exists and is kept current, its keyboard line included while hidden, so
screen readers hear the same text without a hover. Text is diffed
against the last English, as the rest of the grid chrome, so the zh-CN
translator is not fought on every refresh.
- zh-CN for every line (平铺 · N, 单击, 右键单击, Shift+F10, the fits
note); no other header button changes (its styles are .tile-hint only).
Tests: tile-grid-hint.test.ts (install and aria wiring, the delay, each
show and hide path, content per state and count, the menu rule, the
translator, the CSS); the count menu and i18n tests read the accessible
name instead of the removed title, and the i18n harvest covers the card
(F10 joins the key names that stay Latin). The vm harness gains
removeAttribute.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner answer: "paced connect: in". Measured on the checkpoint build: of
the 140 ms between the click on Tiles and its first frame, about 100 ms
was the six xterms being built inside the click, so nothing moved on
screen for that long and the entrance could not start.
openTileGrid now mounts every tile and lays the grid out as before, then
_connectTilesPaced builds one terminal per animation frame, the focused
tile's first, then reading order. The click paints its empty tiles in
about 20 ms and the entrance plays while the terminals are built. The
time until every tile has painted does not change: the load queue serves
one capture at a time, so only the focused tile's connect is on its path,
one frame later. Each tile still connects once, into its final cell (one
fit, one PTY resize).
- openTileGrid returns before the terminals exist, so a selection that
focuses a tile whose terminal is not built yet hands the keyboard over
in _connectTile (focusOnConnect), never when focus: false was asked,
and to the newly focused tile when focus moved meanwhile.
- _connectTile connects a tile once (entry.connected; a remount after
Attach resets it), so a re-form or a remount before a tile's turn is
never connected twice.
- A grid closed or opened again meanwhile stops the old run (a run
token), and asks for no further frames.
Tests: tile-grid-paced-connect.test.ts (the order, the final cells, the
keyboard, close and reopen, removed and remounted tiles); the tests that
read connect or the terminal's focus right after openTileGrid now run
the queued frames first (flushFrames in the vm harness), every
assertion kept.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner request: "when clicking on the tile button first make this
animation nicer". Recorded before: the click froze the page, six empty
tiles cut in at once, each tile's history then scrolled in visibly as
its load landed, and closing showed an empty single view for about a
quarter of a second before its own replay scrolled in.
The grid's own motion, on by default (not an entrance-animations.js
theme, which are off by default):
- Opening: each tile fades and settles in (opacity, translateY 6px,
scale .97), 180 ms, 24 ms apart in reading order: the last of six is
done at 300 ms. Its terminal stays transparent until the load queue
reports its first capture done, then fades in whole (160 ms), so no
replay scrolls by; a 15 s backstop shows it should that never come. A
tile added later enters the same way.
- Closing with the toggle (button, Ctrl+Shift+G; owner answer: only
these): the close stays synchronous, and a still copy of the tiles
(clones: no xterm, socket or listener; inert, aria-hidden, no pointer)
dims at once over the stage, holds until the single view's
selectSession has replayed its session (at most 700 ms), then fades
out. No empty single view between the two. A reopen drops a copy
still showing; a web tab hides it.
- A re-form to another count fades the old grid's copy out at once while
the new tiles enter.
- The count menu fades in (140 ms).
Every one animates opacity and transform only, so FitAddon measures the
final cell and each tile still sends one PTY resize (#464); nothing
moves, and no copy is made, under prefers-reduced-motion.
Tests (tile-grid-motion.test.ts): the stagger, the reveal and its
backstop, the same fits and connects with and without motion, the held
copy (released on settle, by its cap, removed on the last tile-leave or
its fallback), only the toggle animates, a reopen purges, the zoomed
tile alone, the re-form, reduced motion, and a CSS guard that every new
keyframe touches only opacity and transform. The vm harness gains
style.setProperty, cloneNode, isConnected and lastElementChild.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner decision 10 ("give me then the option to choose only HOW many
tiles, 2,4,6 default is 6 so the menu is easier"; asked where it lives:
"Click opens 6"). The click still opens the grid at once; right-click,
Shift+F10 or the Menu key on the button opens a small menu of three
counts, each drawn as the grid's own layout (2x1, 2x2, 3x2), the
remembered one checked. It replaces the session picker, which is gone
(method, markup hook, CSS, zh-CN entries).
- A pick is remembered per device in codeman:tile-count (default 6;
codeman:tile-grid stays ids only) and opens that many tiles. The click
and Ctrl+Shift+G then open with it, at most what the window fits.
- Which sessions: the rule the click already had (the grid this tab
last had, else an open split's two, else tab order, the active one
included and focused), trimmed from the end (the focused one kept) or
filled from tab order to the count. A remembered grid comes back with
its tiles first, in their cells, holes filled first, then tab order
(owner answer, superseding decision 8's "exactly the stored set"). A
page-load restore still brings back exactly what was stored.
- With the grid open a pick re-forms it: a count change is a shape
change under the cell model's rule (reformTileCells), the focused tile
always kept, every joining tile mounted and laid out before any of
them connects, so each fits once and sends one PTY resize.
- Ctrl/Cmd+click on a tab with the grid closed opens the count in total,
that session among them and focused (owner answer: N, not N+1).
- A count the window cannot fit is greyed out with the reason; a
remembered one stays checked, the keyboard starts on the largest that
fits. Arrows, Home/End, Enter or Space; Escape closes the menu alone
(the global handler gives it the key first, like the tab-group menu)
and puts the keyboard back on the Tiles button; Tab and a click
elsewhere close it.
- zh-CN for every new string (N 个窗格, 窗格数量, the titles, the Help
modal row); the i18n test harvests the menu now, with a session named
"6 tiles" as the user-text trap.
Tests: tile-grid-picker.test.ts becomes tile-grid-count-menu.test.ts
(the button checks kept, every picker check carried over to the menu,
plus keyboard, remembered count, re-form, Ctrl/Cmd+click and the batch
connect); the open-set, cap, restore, shortcuts, split-coexistence and
i18n expectations follow the count; the Help modal test escapes its
label (the new one has parentheses). The vm harness tracks
document.activeElement and makes SVG elements.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
"Each session in the split view" (owner request) gets the tile header's
strip: the harness logo, the name and the model, painted by the same
_paintSessionHarness.
- Pane B's header is built from nodes now (it was innerHTML with the name
escaped) and follows renames and model changes on every tab render, like a
tile header; its close button is a tile button (26px target, 19px glyph).
- Pane A is the main terminal, which has no header of its own: while the
split is open it gets the same strip, minus the close, as the first child of
.terminal-wrap, and it names the active session. The strip takes 28px from
the main terminal, so the opening resize fits it with the strip already in
place, and closing removes the strip before giving the height back. Both go
through sendResize / syncTerminalGeometry (#464): the close no longer calls
a bare fitAddon.fit(), and a close that skips the server resize (Pane A's
session ended) still refits through syncTerminalGeometry.
- The partial-history banner, which overlays the top of .terminal-wrap,
starts below Pane A's strip while it is there.
test/split-pane-headers.test.ts drives the real split code on the grid's vm
harness: both headers, text-only names and models, refresh on a tab render,
no writes for an unchanged session, and the opening/closing fits.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The tile header is now `● [logo] name · model ..... ⋯ ⤢ ×` (owner request):
- The logo is PR #532's `run-mode-dot <cliId>` slot, so the logos, the skins
and the plain dot of an id without a logo stay single-sourced in styles.css.
The id is data (a class and a catalog lookup), never a branch; the frontend
id-branching guard now also scans constants.js, terminal-split.js and
tile-grid.js (the one existing shell branch in tile-grid.js, the attach
route, is allowlisted with its reason).
- The model is the session's displayModel, as text in a data-i18n-skip span
inside a box whose tooltip may translate. Unknown means the logo alone.
- The logo's tooltip and accessible name say "<harness> · <model>", plus where
a model the CLI did not report came from ("set at launch", "custom
endpoint"; zh-CN patterns for both, the names pass through). The model's box
is aria-hidden so a screen reader hears the model once.
- One painter, _paintSessionHarness (terminal-split.js, shared with the split
panes next), diffs against what it last wrote, never the DOM: an unchanged
session writes nothing on a tab render.
- On a narrow header the model gives way first, then the name: the name does
not shrink at all and is capped at its box, since any shrink factor takes a
subpixel from a name that fits and ellipsizes it.
The chrome and zoom tests found header parts by child position; they now look
them up by class, with every assertion kept (the rename tests had been passing
against the new logo node by position). The i18n harvester files the logo's
labels as harness and model names that must stay as they are.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A session header can only name the model a session runs if the server knows
it, so SessionState gains `displayModel: { model, source }`, resolved in a pure
module (src/session-display-model.ts), strongest first:
- custom-endpoint: a Custom Model Endpoint Profile's modelId answers the
session, whatever alias the CLI prints;
- statusline / screen: the newest report from the running CLI itself.
Claude's statusLine exporter already posts model.display_name on every
render; the status-telemetry route now records it (only for a CLI with
capabilities.statusLineTelemetry). A CLI whose registry entry declares the
new capabilities.modelDetect has its footer read off the pane capture the
idle/working probe already takes (no extra tmux call), so an in-session
/model switch is followed at the next transition;
- launch: the model the session was launched with (claude's --model or the
app-wide default, another CLI's <cli>Config.model), read where the registry
says the model param lives;
- nothing known: no field, never a placeholder.
modelDetect is registry data, measured on live panes: dsh-TUI's status line
on the row under its composer (qwen3.8-27b on the owner's route) and codex's
`<model> <effort> ·` footer on its last row. Both anchor on chrome only that
CLI draws, over the last rows of the screen only; a transcript line shaped like
the footer is never taken (fixture tests). The pattern goes through
compileVersionRegex() with exactly one capture group, checked at load time.
An unreadable or covered footer keeps the last model (unlike the watching
label: a model does not stop running when something covers its row). Model
text is untrusted: escape sequences and control characters are stripped and it
is capped at 64 characters. A change emits displayModelChanged, broadcast
(session:updated) and persisted; a restart restores a CLI-reported model until
the next report.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
tile-grid-load-queue carried its own FakeSocket, FakeFit and FakeTerminal,
near-copies of terminal-tile-input's. It now imports
test/mocks/terminal-tile-fakes.ts, which gains what only it used:
FakeSocket.drop(code), the terminal's scrollToLine / scrollToTop, and the
replay-pace extension (an opt-in `holdParse` that keeps write callbacks
from running, as on a disposed xterm, and empty writes left out of
`writes`, since the replay queues one only to hear it was parsed).
One definition serves both files with no per-file switch:
terminal-tile-input passes unchanged with the extension in place, the
shared fit resizes to the default 80x24 the tile already has, and
FakeSocket.OPEN is the real value. Every assertion is unchanged.
Mutation-checked through the shared fakes: dropping destroy()'s replay
settle fails the destroy-while-parsing case, a queue that runs two loads
at once fails eleven cases, and a tile that never registers its input
socket fails six in terminal-tile-input.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
terminal-tile-input defined FakeSocket, FakeFit and FakeTerminal inline;
they move unchanged to test/mocks/terminal-tile-fakes.ts so the grid's
load-queue test can drive a real TerminalTile on the same fakes instead of
its own near-copies. No assertion changed (a tile that never registers its
input socket still fails six cases).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Five tile-grid tests defined the same `tileEl(id)` lookup and three more
inlined it; the harness (test/mocks/tile-grid-vm.ts) now exports it and
they import it. tile-grid-open-set built a second vm context just to read
constants.js, although the harness it already imports has loaded the same
file: it reads windowStub.CodemanTileGrid instead.
No assertion changed. Mutation-checked: tiles without their
data-session-id fail 34 tests across the seven files, and a broken
tileGridOpenSet fails the open-set cases.
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>
Every Run path makes each session it created visible through
_ensureCreatedSessionVisible and then selects the first one, a human
selection that used to leave the grid for the single view. That helper now
hands the new session to _joinTileGridFromRun: with the grid open it joins the
next free slot, so Run's selection focuses its tile. No Attach overlay flashes
on it while Run starts its pane. Sessions created elsewhere (agents, other
devices, cron) arrive only by session:created and never join; a grid already
holding what the window fits does not take it, and a hint says the new
session opens on its own.
A tile's + adds "New session in this case": the normal Run (current run mode)
for the case the tile's session belongs to, with the toolbar's case put back
afterwards; the session it creates joins the grid like any Run from this tab.
Disabled for a session outside every case.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Three more ways into the grid:
- Drag a session tab from the strip onto a tile: a session not yet tiled
replaces that tile in place (the replaced session keeps running); one
already tiled swaps places with it. A layout that is not full (3 tiles in a
2x2, 5 in a 3x2) shows its empty cells as slots, and a tab dropped on one
joins the grid there. Tiles and slots handle the drag in the capture phase
and stop it, because its payload is the session id as text and xterm's
helper textarea would type it into the PTY; a drag that is not a tab (a
file) is left alone. The dropped session takes focus (a human selection).
A sorted or grouped rail does not offer tab dragging, so neither does this.
- Ctrl/Cmd+click on a tab puts that session in the grid and focuses it,
opening the grid on what the Tiles toggle would bring back if it was
closed; on a window too narrow for the grid it stays an ordinary click.
- "Open group as tiles" in the grouped rail's group menu makes the group's
live sessions (as many as the window fits) the grid.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The grid now places every tile explicitly (grid-column / grid-row, reading
order) with a 6px divider track between columns and between rows, instead of
relying on DOM order and a gap. Track sizes are fractions (grid-template fr
values) that reset to equal whenever the column or row count changes.
Dragging a divider trades size between the two tracks either side, each kept
at the minimum tile size (the pure dragTrackFractions in constants.js, always
computed from the fractions the drag started with, so it cannot drift). The
affected tiles reflow locally at most once per animation frame, with no PTY
resize; each hears exactly one fit (one PTY resize) at pointer-up, and tiles
in other tracks hear nothing. Pointer capture keeps the drag on the divider,
the body locks the resize cursor and text selection for its duration, and
closing the grid or removing a tile mid-drag tears it down, as the split's
divider does. A zoomed grid shows no dividers.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>