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.
#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
Out of scope
Related
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:
document.fonts.load()) are already disproved there.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
pwshpane — 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
Singlelayout.RefreshTerminalLayoutadds each session's wrapper toTerminalGrid.Children. Only theSinglebranch follows that up withBridge?.FitTerminal(). Every multi-pane branch (TwoColumn,TwoByTwo,SixColumn,SixByThree, …) attaches wrappers with no fit call at all.This matters most during restore, because
RefreshTerminalLayoutruns 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.LaunchSessionAsyncdoes callbridge.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.jsre-fits atsetTimeout50ms and 250ms to cover the case where the initialfit()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. TheResizeObserveris 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._lastSizestarts at(80, 24)and the PTY is started at that size. So if a latefit()computes the same wrongcolsthat xterm already had, noresizemessage 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
Key interfaces
TerminalBridge.FitTerminal()already does the right thing: it posts{"type":"fit"}atDispatcherPriority.Backgroundso 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 theSinglebranch.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.resizemessage contract between page and host. Becauseterm.onResizeis change-triggered, the page needs a way to assert its current size unconditionally — e.g. thefithandler posting the resultingcols/rowsregardless of whether xterm considered it a change. This is the single highest-value fix and is independent of the call-site gap.setTimeout(50)/setTimeout(250)fits interminal-init.js— candidates for removal once an explicit signal exists, per the repo's documented preference for measured signals over guessed delays (theClaudeLaunchStaggerMshistory 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
TwoByTwoor 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
Single.colsxterm already had still results in the host knowing the true size (no silent divergence when the value is unchanged).cols/rowsand xterm'scols/rowsagree 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.setTimeoutduration.Out of scope
Related
onResizetrapdocument.fonts.readyre-fit, kept but a no-op here