Skip to content

fix(panes): mirror the pane app's input modes into the attach replay - #270

Merged
theogravity merged 7 commits into
mainfrom
feat/pane-mode-mirror
Sep 27, 2026
Merged

theogravity merged 7 commits into
mainfrom
feat/pane-mode-mirror

Conversation

@theogravity

Copy link
Copy Markdown
Contributor

Why

Some browser-attached panes scroll one line per wheel notch and show no scrollbar while others scroll natively fast. Root cause: the attach replay comes from tmux capture-pane -p -e, which re-emits no DECSET, and the live tail joins after the pane app announced its input modes. So xterm never learns the app owns the mouse and fires its passive-wheel fallback, one arrow key per notch, through the socket.

The 2026-09-27 live measurement (tmux 3.7c): the working pane was an inline app with no modes announced; the slow one was Claude Code under alt-screen + mouse, invisible to the client.

What

  • packages/pane-runtime: capturePane now leads its result with an authoritative mode statement: all five DECSETs (?1049, ?1000, ?1002, ?1003, ?1006) in h/l form per the pane's live tmux flags (alternate_on, mouse_standard_flag, mouse_button_flag, mouse_all_flag, mouse_sgr_flag), read by one bounded (2.5 s) display-message. A failed or malformed read stays silent: "" means UNKNOWN, never "all off", because a false l re-sent by the pane-poll fallback could clear live-learned state. Works on both attach paths (plane-local and agent nodes share this code) with no frame-shape change and no protocol bump; any server/agent version mix degrades to pre-fix behavior. tmux's own mouse option stays OFF (it hijacks copy/paste; operator ruling).
  • apps/server/web: parseAnsi (which also renders preview cards) had a [0-9;]-only CSI grammar, which would have printed the new private sequences as visible junk. Widened to the ECMA-48 parameter set; a private-prefixed sequence ending in m neither styles nor resets.
  • packages/plugins/claude-code: CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN joins suggestedEnv so it autocompletes in the preset env editor. Deliberately a suggestion, not a product default. For an alt-screen app a native scrollbar is impossible by construction (nothing ever scrolls off the top); the classic renderer is what restores real scrollback.
  • Retired the five extraction-parity fixture guards, per the exit condition stated in their own headers (operator decision, recorded in the commit).
  • Docs: ws-attach.md and apps/server/api/AGENTS.md carry the mode statement as the fourth load-bearing attach invariant, including "do NOT restore a capture to pure capture-pane bytes".

Tests

  • capturePane mode-statement block: mixed decode vector 1:0:1:0:1 (the only flag shape that pins flag-to-slot order), verbatim argv pin on the format string, all-off l statement, three silence vectors ("" token, 01, garbage), display-message failure leaves the capture byte-identical, and the 2.5 s deadline cap.
  • parseAnsi: the mode statement is consumed, SGR after it still applies, bold survives a ?1m (mutation: without the private gate parseInt("?1") || 0 resets it), cursor addressing consumed.
  • The two round-4 test claims that were false were made true and mutation-verified red in throwaway worktrees.

Review

Six review rounds (5 full + a convergence pass). Round 2 caught a self-inflicted Critical: the first mirror version painted the private CSIs as junk in every preview card, fixed by the parseAnsi widening in this branch. Final round: zero findings, all severities.

Changesets: minor bumps for @internal/server, @internal/node, @subshell-ai/plugin-claude-code (pane-runtime and server-web are changeset-ignored and ride these).

🤖 Generated with Claude Code

theogravity and others added 7 commits September 27, 2026 14:35
Measured on the live instance (2026-09-27): a pane whose app had ALREADY
enabled alt screen or mouse reporting before the attach replays a
capture-pane that carries no DECSET, and the live join starts after the
app announced them. The browser's xterm never learns the app owns the
mouse, so every wheel notch fires xterm's passive-wheel fallback: one
arrow key through the socket. That is the reported "slow scroll, no
scrollbar" on mouse-holding panes; a terminal on the same machine
scrolls at the app's speed because its PTY stream carried the modes.

- pane-runtime: TmuxRunner.paneModePreamble reads the pane app's live
  mode flags (alternate_on, mouse_standard/button/all/sgr_flag, mapped
  against tmux 3.7c) and capturePane leads its result with the matching
  DECSET bytes, read before the capture so the preamble describes the
  basis the captured grid was drawn on. Any failure degrades to no
  preamble; no frame-shape change, so every plane/agent version mix
  keeps working, old agents simply ship today's behavior.
- claude-code plugin: suggestedEnv offers
  CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN, the classic renderer (real
  scrollback, native scrollbar), as a per-preset choice. Deliberately
  not a product default: the classic renderer re-admits the flicker
  the no-flicker renderer replaced, and browser scrollback is bounded
  by what scrolled off while attached. tmux's own mouse option stays
  off; it hijacks native copy/paste (operator ruling).

Co-Authored-By: Claude Code <noreply@anthropic.com>
…t condition

The fixtures' own headers declared the exit: "frozen as a REFERENCE...
Both this file and that test are deleted once the extraction has been
reviewed and shipped" (operator decision 2026-09-27). All five extracted
plugins have shipped (e.g. @subshell-ai/plugin-claude-code at 3.0.0),
and the guard fired exactly when an extraction guard must stop: the
plugin's first legitimate post-extraction change to its preset-editor
reference data (the CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN suggestion).
A frozen pre-extraction snapshot cannot stay green against an evolving
product, and "never absorb a difference by editing the expectation" is
the suite's own rule. Verified no imports outside the suite before
removal; __tests__/fixtures/plugins (the loader's fixtures) is
unaffected.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Review round one (075e07b..5c4da4d) findings, all addressed:

- The preamble is now a FULL statement: all five DECSETs in h or l form
  per the flag values, emitted whenever every flag answered 0/1. The l
  forms are what let the pane-poll fallback (no log to tail, whole
  captures as its only channel) un-announce a mode the app dropped
  mid-session. An unanswered flag stays SILENT rather than inventing an
  OFF: a false l delivered by a poll re-send would clear a mode the
  client learned from live bytes, which is worse than silence. The
  all-off statement is an idempotent no-op for a reset()-fresh client.
- The flag read carries its own 2.5 s per-call deadline: a wedged tmux
  must cost an attach the statement, not double the command timeout.
- Six stub tests pin what nothing pinned before: mapping, order, the
  all-off statement, silence-on-unknown, degrade-on-failure (the
  capture still ships byte-identical), the capture's throw contract,
  and the deadline wiring itself.
- paneModePreamble marked @internal (consumed by capturePane only).
- The read-order comment states which stale case is kinder instead of
  claiming a description it cannot honestly give.
- docs/ws-attach.md carries a fourth invariant bullet: captures lead
  with mirrored mode bytes, they are server-injected knowledge not pane
  output, they must not be "restored" away, and "" means unknown.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Round-two review found a Critical the branch itself introduced: the
SPA preview parser's CSI grammar is `[0-9;]`-only (its premise: the
server sends styling only), so a private-parameter CSI like `?1049h`
does not match and falls through as VISIBLE JUNK at the head of every
preview card, at every feed tick. The premise was true until this
branch; the mode statement broke it. Reproduced, then fixed: the
grammar is now the full ECMA-48 CSI form (private-range parameter
starts, `:` sub-separators, intermediates, final byte), and SGR applies
only for a plain `m` final with no private start and no intermediates.
Four new ansi.test.ts cases: the statement consumed, SGR still applied
after it, a private `?…m` not styled, cursor addressing consumed.

Review Minors, same pass:

- The mixed-flag stub vector is now adjacent-different (`1:0:1:0:1`):
  a swap of two format variables or two decsets entries can no longer
  pass the suite silently. A `01` token joins the silence vectors,
  pinning strict "0"/"1" equality over truthiness.
- ws-attach.md and apps/server/api/AGENTS.md said "three invariants"
  while four now stand; AGENTS.md is what every agent session
  auto-loads, so the enumeration names the mode statement there too.
  (The next "restore pure capture-pane bytes" refactor starts here.)
- Comment typo and three em dashes in this branch's added prose
  removed (operator ruling: none in authored prose anywhere; lint:prose
  only sees .md, so they needed the eye).

Co-Authored-By: Claude Code <noreply@anthropic.com>
Round-four review proved by mutation that two of this branch's tests
could not see what their comments claimed:

- modeStub answers without reading the format string, so the
  adjacent-different vector pinned only the DECODE side; a swapped pair
  of format variables stayed green. The stub now records argv when
  asked, and a new test pins the display-message format string
  verbatim (the pipePane argv tests' shape). Query side and decode
  side each have their own pin, and the comments say exactly that,
  with the vector's rationale corrected.
- the private-`m` gate test ran with no active style, so deleting the
  gate (parsing `?1` as SGR 0, resetting an already-empty state) kept
  it green. The case now asserts bold SURVIVES the `?1`, which the
  deletion breaks.

Both fixes mutation-verified before this commit: format-swap red,
gate-removal red, throwaway worktrees removed. The paneModePreamble
@internal line, which claimed a pin the suite did not yet have, tells
one truth now.

Co-Authored-By: Claude Code <noreply@anthropic.com>
AGENTS.md said "Three invariants" over the four-item enumeration this
branch grew (the count sat one line above the edit that added the mode
statement, and every agent session auto-loads this file). The argv-pin
comment cited in-file precedent for the tmux 3.4 format-variable
history that actually lives in tmux-runner.ts's SESSION_LIVENESS_FORMAT
narration.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The mode mirror made every capturePane pay two tmux invocations (the
flag read plus the capture); the stub counted every call, so it
reported the mode read as a cache miss. The dedupe under test is about
the capture, so the counter now guards on capture-pane and keeps its
file-counter honesty.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@theogravity
theogravity merged commit 3c0313a into main Sep 27, 2026
15 checks passed
@theogravity
theogravity deleted the feat/pane-mode-mirror branch September 27, 2026 22:32
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 27, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant