Repository navigation
fix(panes): mirror the pane app's input modes into the attach replay - #270
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:capturePanenow leads its result with an authoritative mode statement: all five DECSETs (?1049,?1000,?1002,?1003,?1006) inh/lform 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 falselre-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 ownmouseoption 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 inmneither styles nor resets.packages/plugins/claude-code:CLAUDE_CODE_DISABLE_ALTERNATE_SCREENjoinssuggestedEnvso 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.ws-attach.mdandapps/server/api/AGENTS.mdcarry the mode statement as the fourth load-bearing attach invariant, including "do NOT restore a capture to purecapture-panebytes".Tests
capturePanemode-statement block: mixed decode vector1:0:1:0:1(the only flag shape that pins flag-to-slot order), verbatim argv pin on the format string, all-offlstatement, 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 gateparseInt("?1") || 0resets it), cursor addressing consumed.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
parseAnsiwidening 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