Skip to content

Claude panes render garbled during startup restore until a repaint forces a re-fit #134

Description

@AThraen

This was generated by AI during triage.

Symptom

While CSM is starting up, the content of Claude panes renders garbled — rows colliding, chunks duplicated, content drawn in the wrong place. It is not a subtle offset; it looks corrupted. It corrects itself once something forces the pane to repaint.

Reported by the maintainer against current main.

Not #116, and not #38

#116 warns explicitly that a similar-looking startup symptom has a separate cause, so recording the distinction up front:

Why Claude panes specifically, and why "garbled" rather than "offset"

Claude Code is an ink TUI: it redraws by absolute cursor positioning, not by clearing the screen (the same property #38 identifies as its likely mechanism). If the PTY believes the terminal is one width and xterm draws at another, ink writes each frame to addresses that do not match the visual grid. A plain shell mostly survives that with ugly wrapping; a TUI that positions its own cursor produces colliding and duplicated rows. So a width disagreement shows up as corruption in a Claude pane and as nothing much in a pwsh pane — which matches the report naming Claude windows.

So the question is why the PTY width and the rendered width disagree during startup.

Mechanism — a fit that never happens

Three things compound, all visible in the current code:

1. Panes are attached to the grid without being re-fit, except in Single layout.
RefreshTerminalLayout adds each session's wrapper to TerminalGrid.Children. Only the Single branch follows that up with Bridge?.FitTerminal(). Every multi-pane branch (TwoColumn, TwoByTwo, SixColumn, SixByThree, …) attaches wrappers with no fit call at all.

This matters most during restore, because RefreshTerminalLayout runs again after every session launch. A pane launched early is detached and re-attached on each subsequent pass, and gets no fit on any of them. LaunchSessionAsync does call bridge.FitTerminal() once — but only for the session it just launched, at a moment when later panes have not yet changed the layout.

2. The page-side fallbacks are acknowledged to be too early. terminal-init.js re-fits at setTimeout 50ms and 250ms to cover the case where the initial fit() ran while the container was Collapsed (0×0). The code comment already concedes these are "guesses at 'fonts are probably ready by now' and are easily too early during a heavy restore with many WebView2s initialising." Restore is fully sequential and measured at ~131s for 25 sessions (#82), so 250ms is not remotely long enough for a pane that gets laid out much later. The ResizeObserver is not a safety net either when a WebView is detached from the visual tree rather than merely sized to zero.

3. A wrong-but-stable width is never reported. The page only tells the host about a size via term.onResize, which xterm fires only when the dimensions change. TerminalBridge._lastSize starts at (80, 24) and the PTY is started at that size. So if a late fit() computes the same wrong cols that xterm already had, no resize message is posted, the PTY keeps its stale width, and nothing self-corrects. #116 flags this same trap independently — it is worth treating as the root pathology rather than a detail.

Together: the PTY is sized from a default, the pane is attached without a fit, the timeout fallbacks expire long before the pane is really laid out, and the one mechanism that would report the truth is suppressed because the value didn't change. The user's repaint is what finally forces a fit that does change cols, which posts a resize, which makes ink redraw at the correct width.

Desired behavior

  • A Claude pane renders correctly on first paint after restore, with no user-initiated repaint, resize, or layout change.
  • The PTY's width always reflects the width xterm is actually drawing at, including when a pane is attached, re-attached, or becomes visible long after its page loaded.
  • The correct size is established by an explicit signal at the moment the pane has real layout — not by a timeout guessing when that might be.

Key interfaces

  • TerminalBridge.FitTerminal() already does the right thing: it posts {"type":"fit"} at DispatcherPriority.Background so WPF has measured the WebView2 first. The bug is the call sites, not the method. Every path that attaches a wrapper to the grid should call it, not just the Single branch.
  • TerminalBridge.TerminalSize / _lastSize — the (80, 24) default is what the PTY starts at. Worth deciding whether a PTY should start before a real fit has been reported at all, or whether the first genuine fit should always be forced through to _pty.Resize.
  • The resize message contract between page and host. Because term.onResize is change-triggered, the page needs a way to assert its current size unconditionally — e.g. the fit handler posting the resulting cols/rows regardless of whether xterm considered it a change. This is the single highest-value fix and is independent of the call-site gap.
  • The setTimeout(50) / setTimeout(250) fits in terminal-init.js — candidates for removal once an explicit signal exists, per the repo's documented preference for measured signals over guessed delays (the ClaudeLaunchStaggerMs history is the cautionary tale in the other direction: a timer's continuation absorbs whatever stall the restore loop is having, so a delay-based fix is unreliable exactly when the system is loaded).

Reproduction notes

Likely needs a multi-pane layout and several restored sessions to show up — a single-session restore hits the one branch that does fit. Suggested repro: save a session set of 4+ Claude sessions in a TwoByTwo or larger layout, restart CSM, and watch the panes as the restore rail advances. Sessions launched early in the sequence should be the worst affected, since they are re-attached the most times without a fit.

Acceptance criteria

  • After a restore of 4+ Claude sessions in a multi-pane layout, every pane renders correctly with no user interaction.
  • Every code path that attaches a terminal wrapper to the grid results in a fit, verified for each layout mode — not only Single.
  • A fit that computes the same cols xterm already had still results in the host knowing the true size (no silent divergence when the value is unchanged).
  • The PTY's cols/rows and xterm's cols/rows agree for every pane immediately after restore completes. Initial render: input sits one character into the placeholder until the window is resized #116 proposes logging both sides; that instrumentation would confirm this directly and is worth landing first.
  • Sessions launched early in a long sequential restore are no worse off than those launched last.
  • Correctness does not depend on any setTimeout duration.

Out of scope

Related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions