Files
Codeman/src/web/CLAUDE.md
Codeman maintainer 29f76e7ec3 docs: split CLAUDE.md into root, src/web/CLAUDE.md and docs/releasing.md
The root CLAUDE.md had grown to 202 KB, all of it loaded into every
session. This lands the split drafted on 2026-10-10 (it sat uncommitted
in the shared checkout through two releases), redone on top of today's
master so nothing that landed since is lost:

- src/web/CLAUDE.md: the frontend invariants (every src/web/public/ UI
  subsystem, the module notes, the XLSX preview limits, the z-index
  stack). Claude Code loads it when a file under src/web/ is read; the
  root file tells sessions to Read it before editing src/web/public/.
  Its link targets are relative to the file so they resolve on GitHub.
- docs/releasing.md: the COM procedure (now nine steps, with the MCP
  registry publish), behind a pointer in the root COM section. The
  Version line stays in the root file.
- Root CLAUDE.md (130 KB): everything else, plus the split's trims: the
  Core Files table, the reference-only rows of the Additional Commands
  table, Data Flow and the respawn presets, whose rules already live in
  the root's other paragraphs (the "Read before touching a subsystem"
  line keeps the table's doc pointers).

Every other paragraph of master's CLAUDE.md lands in exactly one of the
three files, checked line by line; the paragraphs master changed since
the draft (tile grid, OSC 52, prompt uploads, file-path links and the
HTML view, markdown preview, z-index, MCP server, agent CLI and others)
are in their current form. Both new files join CLAUDE.md in
.prettierignore. Four code comments and docs/architecture-invariants.md
now point at src/web/CLAUDE.md for the moved sections.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 22:43:45 +02:00

56 KiB
Raw Permalink Blame History

CLAUDE.md (src/web: frontend invariants)

Loaded on demand: Claude Code reads this when a file under src/web/ is opened with the Read tool, and the root CLAUDE.md tells sessions to Read it before editing src/web/public/. These rules were moved here from the root file verbatim. All paths below are relative to the repo root, like the root file (only the markdown link targets are relative to this file, so they resolve on GitHub). The root CLAUDE.md still holds every cross-cutting rule (session safety, testing, security, server-side patterns).

This file is in .prettierignore for the same reason as the root one: Prettier escapes underscores in glob-heavy paths.

Frontend key patterns

Run launch synchronization: the Run entrypoint holds an in-flight lock and disables #runBtn for the whole launch (≥500ms) so a double click cannot create duplicate w<n>-<case> sessions; _ensureCreatedSessionVisible() runs before selectSession() and _onSessionCreated() stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ Closing has the mirror-image race: closeSession() must read wasActive BEFORE its await and announce the delete via _closingSessions, and _onSessionDeleted skips the active-session handoff for ids in that set; never read activeSessionId after the fact. The fallback picks the first sessionOrder entry still in sessions. ⚠️ The close is OPTIMISTIC: the tab goes and the next one is selected BEFORE the DELETE is sent (upserts skip ids in _closingSessions), and only a delete the server refused (checked with a GET) puts the row back; never await the server before hiding the tab. Tests: test/session-close-fallback.test.ts. → architecture-invariants#run-launch-synchronization

Session lineage lines (tab → tab it spawned, sessionLineageLines, per-device, desktop default ON): a create request may name its spawner via a parentSessionId body field or the X-Codeman-Parent-Session header; resolveParentSessionId() (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides toState(), no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (_appendLineageConnectionLines at the tail of _updateConnectionLinesImmediate()) and draws EVERY family, emphasizing the selected tab's (what it spawned, and the family it was spawned into: lineage-family--focus, thicker, opaque, drawn last; lanes stay in strip order), so a selection change must redraw (_updateActiveTabImmediate, gated on _lineageTotalEdges), and again on the strip's size transitionend (the active tab widens for ~150ms after the redraw). Geometry is pure in computeLineageTree(): one rounded orthogonal tree per spawning tab, every route starting at the PARENT (siblings share the trunk exactly), routed ONLY through the gaps between tab rows plus a spine left of every row, so it never crosses a tab or the terminal. ⚠️ That room is reserved in CSS by .session-tabs.lineage-tree (_syncLineageGutter(), called before the wrap is measured), keyed on whether ANY family exists, never on the selection, or every tab switch would resize the header and the PTY. Colors are keyed on the SPAWNING tab, claimed in strip order for every family, and memoized (never by draw index or selection order). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep data-agent-id="lineage:<childId>" (the entrance animation queries it); skip endpoints scrolled out of the strip. → architecture-invariants#session-lineage-lines-tab--tab-it-spawned

Split-pane sessions (showSplitButton, header button, default OFF, desktop-only at 1180px (width alone, so a wide Android tablet clears it), per-device): a second live session ("Pane B") beside the active one, in a TerminalTile (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (_registerInputSocket), has clickable paths and image paste, and owns its geometry (no 40x10 floor, zc columns adopted, font changes call tile.fit()). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through _focusedPane() (the terminal focused last), never this.terminal. Still plainer than the primary pane (no local-echo overlay, CJK IME textarea or touch handlers), though it wires the same keyCode-229 soft-keyboard controller (Android autocorrect, #441/#541), and NOT persisted across reloads. The tile grid (below) reuses TerminalTile, and the two are never open together. → architecture-invariants#split-pane-sessions

Tile grid (showTileGridButton, header Tiles button + Ctrl+Shift+G, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, getDefaultSettings(); a touchscreen laptop keeps ON), desktop-only at 1180px, per-device; tile-grid.js, design docs/tile-grid-plan.md): 1 to 6 live sessions side by side (the cap is ONE constant, TILE_GRID_MAX in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a TerminalTile with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (CodemanTileGrid, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-browser persistence (codeman:tile-grid, localStorage only: cells with null holes, count, focus, user zoom, divider fractions, never content; written on every change, kept by EVERY close, restored INSIDE handleInit in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: activeSessionId is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through _tilesOwnTerminal() (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, {t:'r'}, history pull) goes through ONE TileLoadQueue (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or leaveTiles, a followed link) leaves the grid; an auto selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of closeTileGrid({ reselect: false }) must null activeSessionId before any _cleanupPreviousSession, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through tileShortcutFor and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows showTileGridButton (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, toggleTileGrid (owner decision): they open the grid AT ONCE on tileGridOpenSet (constants.js): the stored grid EXACTLY as the user left it (owner decision 11: restoreTileGridCells, never filled to the count nor trimmed to the window; a cell whose session is gone since is filled from the ranking, a hole the user made stays), else an open split's two, else the open sessions as rankTileSessions orders them (working first, keyed off lastSubmitAt ONLY, then needing input, then most recent; _mobileOverviewState's states), the active one focused, filled to the remembered count (codeman:tile-count, default 6, at most _tileGridLimit()) by tileGridSetForCount, never a menu. ⚠️ removeTile(..., { gone: true }) (a session deleted, popped out or refused) keeps the stored count so the next open refills that cell; a removal by hand lowers it. Right-click is the 2 / 4 / 6 count menu (openTileCountMenu, owner decision 10), which owns its Escape. The button has no native title: its hover card (_installTileGridHint, #tileGridHint) says it, is its aria-describedby (always present, hidden, kept current) and hides in the capture phase on any press, click or right-click, so the menu never opens beside it. ⚠️ The open, the count menu and the toggle's close animate opacity and transform only (the close leaves an inert cloned still copy until the single view's selection settles, at most 700 ms); nothing moves under prefers-reduced-motion. ⚠️ Opening builds one tile terminal per frame (_connectTilesPaced), so openTileGrid returns before the terminals exist: focus is handed over in _connectTile (focusOnConnect). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ The grid is CELLS (owner: an empty cell can be any cell): grid.cells (id or null) is the one source of truth, grid.ids a derived getter, never written; the shape still comes from the tile count, a shape change goes through fitTileCells, and the stored ids carry the cells with null holes. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile or an empty cell, Ctrl+Shift+Arrows) ONLY through _reorderTiles: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not draggedTabId; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ Sessions Run from this tab join the open grid (_joinTileGridFromRun, called from _ensureCreatedSessionVisible); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls tile.paneStarted() when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (paneExit) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through sendResize/syncTerminalGeometry) names the harness with PR #532's run-mode-dot <cliId> logo (the id is data, never a branch) and SessionState.displayModel (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with capabilities.modelDetect, else what its config pins via the named modelDetect.configResolver (dsh-TUI's route, src/deepseek-route-config.ts: read-only, bounded, nothing on doubt), else the launch model, else nothing), painted by ONE diffing _paintSessionHarness; the model is untrusted text (textContent, data-i18n-skip). ⚠️ zh-CN: every string the grid shows has its own ZH_CN entry or translateDynamic pattern in i18n.js (test/tile-grid-i18n.test.ts harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. ⚠️ Detach Tiles (tileDetachEnabled, per-device, default OFF): a header let go outside the window pops the tile out (detachSession with a placement), dropped on ANOTHER Codeman window's tiles it moves there, and a pop-out's title drags back onto any grid. A foreign tile docks ONLY through _dockForeignTile (the target's own drop, then tile-adopted on the window channel), and only the window it was dragged FROM lets go of its copy (never a last-tile fallback onto that session: two windows sizing one PTY garble it). The gesture overlay moves tiles only through tile-grid.js's helpers (tileDropTargetAt/dropOnTileTarget/detachTileAtPoint). → architecture-invariants#tile-grid

Terminal touch gestures: link taps and text selection: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (_terminalLinkAtPoint), synchronously inside touchend (keeps the user gesture window.open needs) and BEFORE any mouse report; the caret's logical line (_tapIsOnCaretLine) and TUI-owned rows (_isActionableMobileTerminalTap) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap 'input'). ⚠️ Long-press selects via xterm's public select(); keep the three guards: suppress the compat mouse pair after touchend, the bounded focus guard + contextmenu suppression for the platform long-press, and no closing terminal.focus() on phones. Tests: test/terminal-touch-tap.test.ts. → architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection

Auto Copy (copy-on-select) (autoCopySelection, per-device, default OFF): a finished terminal selection lands on the clipboard with no keystroke. ⚠️ Copy at the END of a gesture, never in onSelectionChange (per-cell); it only arms _autoCopyPending and a document-level mouseup flushes. ⚠️ The flush must be SYNCHRONOUS in the handler (both clipboard paths need user activation); never defer it to a timer. ⚠️ Touch needs its own calls from _endTouchSelectionGesture()/_selectTouchSelectionLine() (no mouseup arrives). ⚠️ Unlike copyTerminalSelection(), never clear the selection or focus the terminal; restore prior focus. Guards are pure in decideAutoCopy() (constants.js, 1M-char cap, refused not truncated). Tests: test/terminal-auto-copy.test.ts. → architecture-invariants#auto-copy-copy-on-select

OSC 52 clipboard (_installOsc52Clipboard, terminal-ui.js; main terminal + every TerminalTile): a program in the PTY (Claude Code's /copy, tmux yanks, vim) sets the browser clipboard with ESC ] 52. ⚠️ Writes only: a 52;? read query is always refused, never answered. ⚠️ A replay must never copy: chunkedTerminalWrite strips every OSC 52 (OSC52_STRIP_RE, constants.js) because xterm parses after _isLoadingBuffer is cleared, so the flag alone does not hold; a tile gates on its own _bufferLoading, which does. ⚠️ One copy arrives up to three times (Claude's own OSC 52, tmux load-buffer -w, set-clipboard forwarding), so identical text within OSC52_DEDUPE_MS is dropped. ⚠️ Every tab on the session receives it; only the focused one opens the manual-copy fallback. ⚠️ Restore prior focus after _copyText (its execCommand fallback focuses a temp textarea). Tests: test/terminal-osc52-clipboard.test.ts (replays drive the real chunkedTerminalWrite over @xterm/headless).

Ctrl+V paste trap (image-input.js): Ctrl+V routes through _handleImagePaste(), which focuses a hidden contenteditable trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, text goes through terminal.paste() so bracketed-paste markers survive. ⚠️ The trap must consume exactly ONE paste event (Firefox delivers two per keypress: the execCommand('paste') event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the execCommand('paste') call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: test/image-paste-trap.test.ts. → architecture-invariants#terminal-paste-ctrlv

File Viewer text view: rendered markdown + Lines/Wrap toggles (_renderFilePreviewText() in panels-ui.js): a .md/.markdown opens RENDERED by default with an MD pill back to source; the plain-text view has Lines (CSS-counter gutter) and Wrap toggles. ⚠️ ONE markdown pipeline: the viewer calls _renderMarkdown(text, { breaks: false }) (marked + the DOMPurify allowlist, the Response Viewer's; chat keeps the default breaks: true, a file must not turn every hard wrap into a <br>) and binds the Response Viewer's click delegate (_bindResponseViewerInteractions) on the preview body for code-copy buttons and path links; never a second parser or handler. ⚠️ The document is built inside a <template> (a detached div with innerHTML set starts fetching every <img src> before the rewrite), then _rebaseFilePreviewMarkdownRefs() points relative images at the workspace-confined file-raw under the document's directory and root-relative ones under the workspace root (never a widened route; a failed load degrades to alt text), after decodeURIComponenting the ref and dropping ?query/#fragment (marked percent-encodes destinations, and the route encodes again), and turns workspace links into a.rv-path carrying data-session-id for the delegate (the linkifier's absolute paths get it too), stripping the target marked gave them. ⚠️ A preview opened by attachment id under a bare file name (attachment cards, history drawer: the registry keeps no relative path) has no directory, so its workspace refs degrade (images to alt text, links to their text), never resolve against the workspace root. ⚠️ The container carries data-i18n-skip or the translator rewrites the document's prose. ⚠️ Toggles are per-device localStorage keys (codeman:filePreview*), never SettingsUpdateSchema; Lines/Wrap are class flips on the ONE <pre>, with rules scoped .file-preview-body > pre.file-preview-text so they never leak into the document's code blocks. Markdown fetches lines=10000 (the route ceiling), other text keeps 500. ⚠️ md/markdown are IN FILE_PREVIEW_EXTENSIONS (owner decision 2026-10-13): a markdown path clicked or tapped in the TERMINAL (main pane, grid tiles and split Pane B share registerFilePathLinkProvider) opens this rendered view like every other surface, never the streaming log viewer; other in-workspace text keeps the tail viewer (live follow). The terminal pattern links .markdown and .htm too. Tests: test/file-preview-markdown.test.ts. → architecture-invariants#file-viewer-text-view-rendered-markdown-and-text-toggles

Spreadsheet preview (XLSX) (spreadsheet-preview.js + spreadsheet-preview-worker.js; moved from File-path links in the root file): ⚠️ XLSX previews parse in the BROWSER, never on the server: spreadsheet-preview.js fetches the raw route with ?preview=true (413 above MAX_XLSX_BROWSER_PREVIEW_BYTES, 10 MB) and hands the bytes to spreadsheet-preview-worker.js, the only place the pinned exceljs/fflate vendor bundles load (never on page load). admitXlsx() caps the ZIP before ExcelJS runs, counting what ExcelJS will EXPAND as well as what it reads (a merge costs its area, a <col> past 16384 is refused, validations are never parsed via ignoreNodes, and defined names are never expanded: the worker stubs _definedNames.model). ⚠️ The INDEX a row or sheet claims is bounded too, since ExcelJS allocates and walks up to it: a <row r> outside 1-1048576 and an xl/workbook.xml <sheet sheetId> above LIMITS.maxSheetId (65535) are refused, and sendTile reuses the merges read at load (mergesById), never sheet.model, which rebuilds the whole sheet. ⚠️ The COLUMN index costs the same way (a row's cells live at _cells[col - 1], so one XFD cell makes ExcelJS's eachRow/eachCell/hasValues visit 16,384 slots per row): worksheetMetadata builds each sheet's row and cell index from the keys that exist (Object.keys of _rows and _cells, skipping falsy and Null-type cells as eachCell({ includeEmpty: false }) does), reads row heights in that pass and merges from sheet._merges, and sendTile reads cells from that index (populatedRowsById); never call ExcelJS's dense eachRow/eachCell there. parseThemePalette returns the default palette for a theme above 64 KB (64 * 1024 characters), since its patterns are quadratic on unclosed tags. ⚠️ Merges are capped per sheet (LIMITS.maxMergesPerSheet, 2,000) AND workbook-wide (LIMITS.maxMerges, 10,000), refused as merge-limit before ExcelJS loads, since ExcelJS's _mergeCellsInternal checks each merge against every earlier one on its sheet (quadratic). Every <numFmt> in xl/styles.xml is read with readTagAttributes() and refused (number-format) when its decoded formatCode is over 255 characters or has a [ after its last ]: ExcelJS's isDateFmt rescans to the end of the code per unclosed [ once per numeric cell, and the code is echoed into the notice bar. ⚠️ formatCellValue caps every cell's display text at LIMITS.maxCellTextChars (1,000, ellipsis, never splitting a surrogate pair) for every value shape (rich text, hyperlink text, formula source, errors), since structured clone copies each tile cell's whole string to the page and the load timeout no longer covers tiles. richTextPrefix also visits at most maxCellTextChars + 1 runs, not only keeps that much text: an empty <r/> adds nothing, so a shared string of a million empty runs was walked whole per cell, per tile (56 s a tile). ⚠️ Every part ExcelJS parses (all but the pure-bytes xl/media/<name>.<ext>) is also charged per START TAG against LIMITS.maxElements (2,000,000, refused as element-limit): ExcelJS builds an object per element in sharedStrings, styles, comments, drawings, VML and tables too, and empty stored blocks pad a deflate stream past the ratio cap, so 8.3M empty <r/> runs in a 375 KB file cost 570 MB of heap. ⚠️ Admission keys every entry on the name ExcelJS will SEE (excelJsEntryName(): JSZip's ./../empty-segment resolution, then one leading / stripped), refuses two entries that land on one name, and treats anything matching ExcelJS's UNANCHORED xl/worksheets/sheet<N>.xml as a worksheet, so /xl/... or xl/./... cannot skip a counter, and ExcelJS parses only a STORE-only archive rebuilt from the entries admission inflated (buildAdmittedArchive()), never the fetched bytes; cell text goes through textContent, formulas are never evaluated. Bumping either package or editing the worker/core changes SPREADSHEET_ASSET_VERSION, which npm run check:public-assets pins. xls/ods stay download-only.

Frontend modules and UI subsystems

Frontend JS modules have @fileoverview with @dependency/@loadorder tags. Load order is the <script> order in index.html (each module's @loadorder tag mirrors it). i18n.js translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; input-cjk.js handles CJK IME composition via an always-visible textarea below the terminal (window.cjkActive blocks xterm's onData). terminal-keycode229-recovery.js forwards a committed input event that xterm's _inputEvent guard drops (Chrome-on-Android soft keyboards send composed: true after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ That decision is settled at the NEXT keydown as well as on its own zero-delay timer (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the \r. On the timer alone that character is not merely late, it is LOST: xterm emits the \r first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, hell\r where the user typed hello). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by test/terminal-keycode229-recovery.browser.test.ts, which the CI gate does NOT run. The same module replaces xterm's _handleAnyTextareaChanges (an append-only newValue.replace(oldValue, '') diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). ⚠️ Every xterm that takes keyboard input wires its OWN controller from this module: the primary pane (terminal-ui.js initTerminal()) and each TerminalTile (terminal-tile.js _createKeyCode229Recovery(), so every grid tile and the split's Pane B, which a wide Android tablet reaches), each on its own textarea, composition helper and session; in both, handleKeyEvent must run ABOVE the custom key handler's keyCode-229 early return, and notifyCanonicalData sits in the onData lambda, never in the send path the recovered bytes also take. mobile-ime-preview.js (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then _transferMobileImeCommitToLocalEcho, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on terminal.element, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.

Entrance animations (entrance-animations.js, all OFF by default): opt-in animations for tabs, terminal, windows, connection lines and tile-grid tiles, chosen via data-tab-anim / data-term-anim / data-win-anim / data-line-anim / data-tile-anim on <html>; the default legacy theme short-circuits every hook (tiles keep the grid's own settle). ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative animation-delay (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); blur is the ONE sanctioned filter exception, do not generalise it. ⚠️ Line glow lives in --line-glow so blur keyframes interpolate. Every animation setting lives in App Settings → Animations (#settings-animations); put new ones there. ⚠️ Tiles are OFF by default (Animations → Tile Animations, default settle = the grid's own fade, no screen beat); a theme only presets it. A tile's FRAME animates transform/opacity only (six at once, no filter) with tile-enter*/tile-leave* keyframe names (what the mount and the still copy listen for); its SCREEN replays the pane style on .tile-body.term-enter; a reload's restore always settles. Persisted per-device in codeman:*Anim localStorage keys, never in SettingsUpdateSchema; lab at ?animlab=1. Test: test/entrance-animations.test.ts. → architecture-invariants#entrance-animations

Mobile tab strip scrolling (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. _updateActiveTabImmediate() reveals it via computeTabScrollLeft() (constants.js, rect math on the strip's own scrollLeft, never scrollIntoView(), which scrolls the document under the fixed header); _fullRenderSessionTabs() must restore scrollLeft across rebuilds and re-reveal only when the active tab changed (_lastRenderedActiveTabId) or, grouped by state, the ACTIVE tab moved to another state band (_noteActiveTabBand(), both render paths, scrolling row only; another tab's move never scrolls). ⚠️ The phone-block min-width on .session-tab.active .tab-name keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Tests: test/mobile-tab-tap-zones.test.ts, test/tab-triage.test.ts. → architecture-invariants#mobile-tab-strip-scrolling

Session list layout: header strip or left sidebar (sessionListLayout, default header; per-device via displayKeys, also in SettingsUpdateSchema): the list can move into a collapsible <aside> (Alt+B, toggleSessionSidebar) or, via tabOrientation, a resizable vertical #tabRail (desktop/tablet only). ⚠️ There is ONE #sessionTabs, MOVED between hosts, never a second list: applySessionListLayout() runs first, then applyTabOrientation(), both BEFORE applyTabWrapSettings(), and both arm/disarm _startSidebarRichClock(). ⚠️ Axis decisions use _isVerticalTabList(), never isSessionSidebarActive() alone. ⚠️ Rich rows share one gate, isRichTabRows(); rich CSS pairs sidebar+rail with comma-grouped selectors, never :is(), and card rules stay rail-scoped. ⚠️ Rail sort (tabRailSort, default activity) is the flex order property only, never a DOM reorder; the arrow-key walk alone follows computed order. ⚠️ Leaving sidebar mode clears _sidebarFilter; the handheld overlay drawer is inert when closed, the docked rail never. ⚠️ The rail's Search sessions box (setTabRailSearch, in memory only) and the sidebar filter share ONE row filter, _applyTabListFilter() over the pure CodemanTabSearch (constants.js): classes only, never a reorder or layout write; the rail matches the name, the sidebar name + folder. ⚠️ A session row with a tab alert (tabAlerts, red or yellow) is never hidden by either box: it goes in as keep: true, counts toward its group (which stays on screen) but not toward matchCount. ⚠️ Escape in the rail box is claimed by the global capture-phase Escape branch (the box's own onkeydown runs after it, too late to stop closeAllPanels()), and a filter change that shows or hides anything redraws the connector lines, since a keystroke re-renders nothing; a floating window finds its parent tab only through _paintedSessionTab() (a hidden row's rect is all zero but truthy, which drew connectors from the viewport's corner). While it runs _projectTabGroups() projects every group open (a collapsed group's rows are not in the DOM) and toggleTabGroupCollapsed() refuses, leaving the stored collapse untouched; drag stays on during a search (owner decision), a drop landing between the visible neighbours; applyTabOrientation() clears it off the rail. Tests: test/tab-rail-search.test.ts, test/tab-rail-search.browser.test.ts. → architecture-invariants#session-list-layout-header-strip-vs-left-sidebar

Tab layouts (tabArrangement, per-device, default classic; state/case/ledger are opt-in; Discussion #426): state (option C) splits the list into needs you / waiting / working / idle (idle is quiet: its heading stays as the row's anchor but draws no label; tabStateOrder: 'urgent-last' flips the order, needs you at the bottom): rows in the desktop header strip with left-aligned labels in a measured column (the brand sits over the strip's corner, so the rows after the first start under "Codeman"), sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. case (option A) wraps each case's tabs in one .tab-cluster box and hides the -<case> of a generated name (.tab-name-case). ledger (option B) is a CSS-only grid on the desktop header strip. classic is the old strip. Pure cores: CodemanTabTriage and CodemanTabClusters (constants.js). ⚠️ state is the flex order property plus aria-hidden heading/break elements reconciled in place by _syncTabTriageChrome() after BOTH render paths, never a DOM reorder (a state change is an incremental pass). ⚠️ case boxes ARE a DOM grouping, so the incremental path rebuilds whenever _tabClusterLayout().key changes; membership follows _mobileOverviewCaseFor() like the home screens. ⚠️ Drag only reorders within a group or box (_isTabDropAcrossGroups()). ⚠️ Named groups in the vertical rail win over both groupings. ⚠️ classic must leave no trace (no headings, no inline order, no boxes, no name split), and the ledger must not change the markup. Tests: test/tab-triage.test.ts, test/tab-clusters.test.ts. → architecture-invariants#tab-layouts-by-state-by-case-ledger

Header stats styles (headerStatsStyle, per-device, desktop only, default compact; Discussion #426 option G): tiles / compact (two pills, a ring beside every value) / classic (as before), keyed on data-header-stats (pre-paint + applyHeaderStatsStyle()), forced to classic below 768px and in a solo window. ⚠️ The clustered styles MOVE #connectionIndicator into #headerSystemStats and #planUsageChip right after it; classic moves them back to comment anchors. ⚠️ WS joins the pill only while System Stats is shown. ⚠️ The extra parts (stat rings, tile words, plan rings, meters) are always rendered and hidden by default in CSS, which is what keeps classic unchanged; tile words are derived beside the connection descriptor, never added to it (test/connection-indicator.test.ts pins its shape). ⚠️ A tile is a three-row grid (label, value, bar) with pixel line-heights and the bundled JetBrains Mono; never lay the bar over a fixed-height tile (a taller system mono put the value on top of it). The WS tile's grid lives on the inner .connection-tile, because JS writes the indicator's display inline. Tests: test/header-stats-style.test.ts. → architecture-invariants#header-stats-styles

Phone overview home screen (mobile-overview.js, per-device mobileOverviewEnabled, default ON): under 600px the "C" logo shows NEEDS YOU / CURRENT / PAST SESSIONS instead of the welcome overlay, branched in showWelcome()/hideWelcome() via width-driven shouldUseMobileOverview(). ⚠️ The container ships hidden and only this module removes it: never give .mobile-overview a bare display rule (desktop does not load mobile.css). ⚠️ The split Run button must carry the toolbar's own classes (btn-toolbar btn-run mode-<backend> / btn-run-gear) and mobile.css must set no background/color on it; row status must mirror the session-tab alert language. PAST rows resume through the shared resumeHistorySession(). Status pills carry data-i18n-skip. → architecture-invariants#phone-overview-home-screen

Desktop home tab rail (home-sessions.js, desktop only): the welcome overlay's left gutter carries the open tabs as a rail docked flush left, full height, in overview order, each row showing created … · <state> <duration> from _mobileOverviewSince(); state classification is reused from mobile-overview.js (so it loads after it). ⚠️ The number badge is the Alt+1..9 tab-strip index, never renumber it to row position. ⚠️ The width gate lives in two places that must stay equal: HOME_SESSIONS_MIN_WIDTH (1180) and a max-width: 1179px media query. ⚠️ .home-sessions[hidden] must re-assert display: none. ⚠️ Size all children in em off the one clamp() knob, never rem/px. Age stamps tick in place (_tickHomeSessionsTimes()), never by re-render. Test: test/home-sessions.test.ts. → architecture-invariants#desktop-home-tab-rail

Home-screen session order (CodemanSessionOrder in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank needs → error → waiting → working → idle → done. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off lastSubmitAt, never lastActivityAt (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is orderIndex, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: test/session-overview-order.test.ts. → architecture-invariants#home-screen-session-order

Welcome "Resume Conversation" list (terminal-ui.js): loadHistorySessions() fetches once and caches the corpus on _historyAll/_historyCases; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through _renderHistoryList(), so never append rows to #historyList directly or re-fetch to re-sort. ⚠️ The box height is class-driven: expanding the list without .history-list.expanded leaves the collapsed max-height in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off _historyRowLabel(), the SAME string the row renders (name || firstPrompt || path), most rows are transcript-backed and have no session name, so sorting on name alone silently does nothing. ⚠️ A filter implies expansion, and _renderSearch() hides #historyHeader (title + controls) as one unit while a search is active. Tests: test/history-list-controls.test.ts.

Command palette + shortcut registry: Ctrl/Cmd/Alt+K opens the session palette; shortcuts live in a rebindable registry (DEFAULT_SHORTCUTS/getShortcutRegistry()/matchesShortcutEvent() in app.js, overrides in settings.shortcutOverrides). ⚠️ Palette-chord keys must ALSO be swallowed in attachCustomKeyEventHandler (terminal-ui.js) or xterm writes the control byte into the PTY. ⚠️ The global Escape handler (app.js) calls EVERY close method on every Escape, in the capture phase, so a close method that does more than hide (the palette and Session Manager restore focus) must return early when its overlay is not open. ⚠️ saveAppSettings() rebuilds settings from the DOM, so keys edited elsewhere (shortcutOverrides, showTokenCount, showCost) need explicit _prev carry-over. ⚠️ Smart copy (Ctrl+C): with no selection it must return true without preventDefault() or the interrupt is lost; keep copyTerminalSelection out of SHORTCUT_ACTIONS. The gate tests the CLEANED selection (CodemanCopySelection.clean: trailing padding, plus a LEADING margin only up to the width the CLI declares in capabilities.transcriptGutter, never one derived from the pane); the strip is not idempotent, so clean once and pass the RAW selection on, and leave Alt+drag column selections untouched. → architecture-invariants#command-palette-and-shortcut-registry

Per-device vs synced settings: the displayKeys set in settings-ui.js is a client-side merge policy, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; showPlanUsageLimits is additionally deleted from the incoming payload outright. Separately, SettingsUpdateSchema is .strict() and simply does not declare skin, showFileViewerButton, showCronButton, webglRendererEnabled, localEchoEnabled, cjkInputEnabled, or extendedKeyboardBar, so sending one of those is a validation error. The rest (showResponseViewer, showPlanUsageLimits, language, and most show* keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding both questions: membership in displayKeys, and presence in the schema.

Settings surface (#appSettingsModal + #sessionOptionsModal + #createCaseModal): one set-* language shared through a single :is(...) id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (switchSettingsTab scrolls); Session Options and Add Case really switch (switchOptionsTab / switchCaseModalTab), and their larger per-modal size blocks are the design, not drift. ⚠️ The load/save contract is getElementById by id: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off context (label-only rename). ⚠️ Add Case keeps its legacy .form-row markup via an adapter; every <details> there needs .set-adv-chev plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden <select>s, which stay the source of truth. ⚠️ .modal-tabs* classes are retired; admin-ui.js needs .set-rail-items + .set-doc to survive any restructure. Guard: test/app-settings-structure.test.ts. → architecture-invariants#settings-surface-app-settings-session-options-add-case

Header button visibility: most header controls are opt-in and hidden by a marker class (btn-multimonitor--hidden, btn-response-viewer-header--hidden, btn-file-viewer--hidden, btn-cron--hidden, and the desktop-gated btn-split--hidden / btn-tile-grid--hidden, which also need a @media (max-width: 1179px) backstop) that applyHeaderVisibilitySettings() (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by renderIndexHtml. ⚠️ Hiding must go through the marker class: the base rules are display:inline-flex !important, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + Tiles (1180px and wider) + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; test/mobile-header-buttons-policy.test.ts is the static guard. → architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron

Terminal font weight (terminalFontWeight / terminalFontWeightBold, per-device, default = xterm's own normal/bold): Claude Code's markdown bold is a bare ESC[1m, so the weight step is its only cue. CodemanTerminalFont.resolveWeights() (constants.js, pure) resolves each slot against its own xterm default. ⚠️ The @font-face for fonts/jetbrains-mono-variable.woff2 must stay declared 100 800 (the browser synthesizes from the descriptor, not the file); narrowing it silently makes the setting a no-op. ⚠️ A live save must reach both echo overlays (refreshFont()) and open Agent Teams panes. ⚠️ Leave _awaitTerminalFont() untouched. Test: test/terminal-font-weight.test.ts. → architecture-invariants#terminal-font-weight

Theme skins / branding / i18n: skin selects a palette via data-skin on <html>, applied by an inline pre-paint script in index.html reading localStorage['codeman:skin'] to avoid a flash of wrong theme. ⚠️ A skin is four things that must stay in sync, and missing any one degrades silently: the html[data-skin="…"] token block in styles.css, the xterm ANSI palette in terminal-ui.js, the pre-paint allowlist, and the Settings picker (both in index.html). test/skin-themes.test.ts is the static guard. Light skins additionally need color-scheme: light and xterm minimumContrastRatio: 4.5, and applyTerminalSkin() must call the local-echo overlay's refreshFont() because it caches the terminal fg/bg. displayName changes user-facing browser branding only and must NEVER rename npm package, CLI, API, storage, CSS, or protocol identifiers. language (en/zh-CN) keeps English as the canonical source so live switching stays reversible. User display names flow through textContent/attribute APIs and the server title's HTML escaper, never innerHTML. → architecture-invariants#theme-skins

Foldable settings identity: responsive layout is width-driven via MobileDetection.getDeviceType(), but the localStorage namespace uses MobileDetection.isHandheldDevice() so an unfolded Android foldable keeps codeman-app-settings-mobile. ⚠️ Do not switch per-device settings namespaces from instantaneous viewport width: a posture-triggered WebView reload would lose opt-in UI. Regression profile: OPPO Find N5 (unfolded) in test/mobile/devices.ts. → architecture-invariants#foldable-settings-identity

Folding devices: a fold is not a keyboard, and dialogs avoid the hinge: ⚠️ in handleViewportResize(), a visual-viewport resize that changes the WIDTH is a shape change (rotation, fold) and must never be read as the keyboard; it re-baselines instead, or keyboardVisible latches with no keyboard. ⚠️ init() must seed lastViewportWidth. ⚠️ With the keyboard up, a shape change baselines to window.innerHeight, never the shrunk visual height. ⚠️ The hinge is reserved via --fold-inline-end/--fold-block-end (0px when unfolded): each overlay fold rule must re-state its own gutter, a base gutter overridden by a later @media block needs its own fold restatement there on a zero base, and dialogs use physical sides (left/top segment) in every language. Guard: test/foldable-layout.test.ts. → architecture-invariants#folding-devices

WebGL renderer toggle (webglRendererEnabled, per-device): the GPU-stall watchdog's sticky codeman-webgl-disabled marker survives page loads and is cleared only by an explicit OFF→ON save or ?webgl=force. ?nowebgl forces the DOM renderer per-load. → architecture-invariants#webgl-renderer-toggle

Shell keyboard accessory bar + one-shot Ctrl (keyboard-accessory.js): a shell-mode session swaps the mobile accessory bar for terminal controls; setMode() records extendedKeyboardBar as the base layout and refreshForActiveSession() resolves base-vs-shell. ⚠️ Ctrl is a one-shot modifier applied in terminal.onData (after shouldSuppressTerminalQueryResponse, before every send path), and must skip isTerminalFocusOrMouseReport() chunks. ⚠️ It must disarm on use, second tap, any other accessory key, session switch, keyboard dismissal and layout swap. ⚠️ _handleCjkInput() must apply it too (the CJK textarea bypasses onData). Mapping: ctrlByteFor(). ⚠️ mobile.css's light-skin repaint must keep excluding .accessory-btn:not(.armed) or the armed state is invisible. → architecture-invariants#shell-keyboard-accessory-bar-and-one-shot-ctrl

Mobile prompt composer (keyboard-accessory.js): the agent bars' Paste key is Compose, a native multiline dialog where only Send submits (the shell bar keeps plain Paste). Opening it adopts the whole terminal prompt (_takePendingLocalEcho), erasing the flushed prefix with backspaces counted in code points. ⚠️ Drafts are per-session and in memory only (_composerDrafts), never persisted (prompts carry secrets). ⚠️ Delivery is a hand-built bracketed-paste frame via _sendInputAsync WITHOUT useMux, then a separate delayed Enter WITH it; never terminal.paste(), and the frame must never take the mux fallback (it strips newlines). ⚠️ _composerMaxLength must stay derived from MAX_INPUT_LENGTH minus the markers, or an oversized frame wedges the durable queue. ⚠️ The composer overlay needs its own gutter restatement after the fold rules. Test: test/mobile-prompt-composer.test.ts. → architecture-invariants#mobile-prompt-composer

Terminal resilience: a replay clear is the queued in-stream \x1bc in _resetTerminalForReplay(), never reset()/clear() (queued bytes fuse into the snapshot); the renderer watchdog _kickRenderer() reads xterm privates, pinned by test/xterm-private-api.test.ts against the resolved lockfile version; every terminal capture fetch has a deadline that covers the BODY (_fetchTerminalCapture). → architecture-invariants#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines

WebSocket output-gap reconcile (_wsOutputGapSession, app.js): output frames carry no sequence number, so an unintentional WS close while SSE stays up marks the session and the next open reconciles. ⚠️ The marker is cleared only once a repaint actually happened (_markTerminalBufferReconciled(), never in a finally). → architecture-invariants#websocket-output-gap-reconcile

Dismissing the on-screen keyboard (terminal-ui.js): two gestures blur the terminal's hidden textarea. (1) _installMobileKeyboardDismiss(), a document touchend that must never fire inside #terminalContainer or on a control (MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR, via closest(); roving-tabindex items sit at tabindex=-1, so the grouped rail's are listed as [role="treeitem"]). (2) In _handleMobileTerminalTap, a second tap on inert content blurs; the prompt row keeps focus-then-position. ⚠️ A scroll also ends in touchend: both classifiers must share one threshold (TAP_THRESHOLD reads MOBILE_KEYBOARD_DISMISS_TAP_SLOP), and multi-touch is never a tap. ⚠️ CI cannot see the only test for (1): run npm run test:mobile -- test/mobile/keyboard.test.ts by hand and diff the FAIL list against master. → architecture-invariants#dismissing-the-on-screen-keyboard

Phone toolbar: Enter replaces Shell (post-1.8.0): inside @media (max-width: 599px) btn-shell is display:none and btn-enter takes its slot (order: 4); starting a shell moved into the Run dropdown (Terminal / Shell → setRunMode('shell') → run() → runShell(), button label "Run SH"). runMode is z.string().max(20) server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged.

⚠️ sendEnterKey() MUST go through terminal._core.coreService.triggerDataEvent('\r', true) — not sendInput(), and never a raw POST to /api/sessions/:id/input. localEchoEnabled defaults to MobileDetection.isTouchDevice(), so on every phone the characters you type are buffered in the LocalEchoOverlay and have never reached the PTY; the onData Enter branch in terminal-ui.js is what flushes pendingText first and only then sends \r (after an 80ms delay so text lands first). Sending a bare \r submits an empty line and strands the typed text on screen, so the button looks dead. Replaying the keypress reuses the overlay flush, the flushed-offset cleanup and the ordering instead of reimplementing them. KeyboardAccessory.sendKey() is for escape sequences (arrows/Esc) and is the WRONG template to copy for input.

⚠️ Skin overrides outrank plain class rules. styles.css nests its skin block inside html:not([data-skin="og"]) { … }, so a bare .btn-toolbar rule in there resolves to specificity (0,2,1) and beats a .btn-toolbar.btn-x rule (0,2,0) in mobile.css regardless of load order. Toolbar-button colors set from mobile.css therefore need !important — that is why mobile.css leans on it so heavily. Symptom: only your !important properties land and everything else silently renders in generic toolbar grey.

Connection-loss UI (computeConnectionLossUi() in constants.js, writer _updateConnectionLossUi() in app.js): the service worker serves the cached app shell, so an unreachable server (phone off the tailnet, VPN down, server stopped) used to render a normal-looking empty dashboard whose only tell was the 8px header dot, which reads as "no sessions", not "no connection". Two surfaces now: a full-screen overlay while no server state has loaded this page load (nothing behind it is worth preserving), and a non-blocking banner once it has (the terminal scrollback stays readable). ⚠️ A 2.5s grace is load-bearing: a COM deploy restarts the server and SSE is back in ~200ms, and a banner on every deploy trains the user to ignore it. navigator.onLine === false skips the grace, since that is never a blip. Retry re-arms SSE and the terminal WS (planWsReconnect can 'give-up', and the SSE backoff caps at 30s).

SSE staleness watchdog (computeSseStale() in constants.js, _checkSseStale() + a 5s interval in app.js): an EventSource can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named sse:heartbeat event (cleanupDeadClients(), sse-stream-manager.ts), never an SSE comment, which EventSource cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while connected and online (the loop breaker). ⚠️ The liveness stamp lives inside addListener. ⚠️ Clear the interval only at the top of connectSSE(), or intervals stack. → architecture-invariants#sse-staleness-watchdog

Z-index layers (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via setComposition), iOS IME composition preview span when local echo is off (6 inside .xterm-helpers, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu + Tiles count menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), folder grid (5090, above the response viewer it opens from, below the file preview its tiles open), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its [hidden] must re-assert display: none or dismiss() leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → architecture-invariants#z-index-layers

Keyboard shortcuts: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+C (copy selection, else interrupt) / Ctrl+Shift+C (copy, never interrupts), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active), Shift+drag (start a selection in a stripped-DECSET pane, where xterm's own Shift branch is unreachable and a Shift+drag used to select nothing; _installShiftDragSelection), right-click (copy the selection, the mintty/PuTTY convention, since xterm paints into a canvas and the native menu has no Copy for it; with nothing selected the native menu is left alone). Rebindable via the registry. ⚠️ Ctrl+W is deliberately NOT bound (owner decision): it is delete-word in every shell and agent CLI, and as Close Session it killed sessions with no confirm; close-session keeps bindings: [] and stays bindable (test/ctrl-w-never-closes.test.ts).