fullscreen sticky prompt header stopped rendering in 2.1.247 (regression: memoized isSticky() never re-evaluates)
The scroll-aware sticky prompt header — the dimmed one-line header pinned at the top of the viewport showing the user prompt that owns the currently visible output, clickable to jump back to that message — shipped and worked through 2.1.246, and stopped rendering in 2.1.247. It is still broken in 2.1.250.
(This is the feature requested in #75845, which is still open — it was in fact implemented and working.)
Environment
- macOS 26 (Darwin 25.6.0), Apple Silicon
- Native install, fullscreen renderer (
"tui": "fullscreen") - Ghostty-based terminal
What happens
Scroll up in fullscreen mode so a user prompt moves above the viewport:
- 2.1.246 and earlier: row 1 shows the dimmed prompt line.
- 2.1.247 / 2.1.250: row 1 is blank.
The "Jump to bottom" pill still appears at the same scroll position, so the scroll state itself is detected correctly — only the sticky header is missing.
Reproduction
- Set
"tui": "fullscreen". - Send a normal text prompt whose reply is longer than one screen, e.g.
print numbers 1 to 55, one per line, digits only, no commentary. - Press PageUp twice, so the prompt scrolls above the viewport.
- Expected: dimmed prompt pinned at row 1. Actual: row 1 is blank.
Note: prompts entered as ! bash commands are not sticky candidates — that part appears to be by design. Reproduce with normal text prompts.
Bisect
Resumed one identical session (same transcript, same token count, same scroll position reached with the same keystrokes) under each build downloaded from https://downloads.claude.ai/claude-code-releases/<version>/darwin-arm64/claude, with checksums verified against each manifest.json:
| version | sticky header |
|---|---|
| 2.1.241 | works |
| 2.1.243 | works |
| 2.1.246 | works |
| 2.1.247 | broken |
| 2.1.250 | broken |
In every run the visible transcript rows were identical and the "Jump to bottom" pill was present; only row 1 differed.
Root cause
In 2.1.246 the sticky-prompt component read the viewport on every render:
let g = a.current?.isSticky() ?? true;
// ...
if (R > 0 && !g) { /* scan backwards for the nearest user prompt */ }
In 2.1.247 the same block was rewritten with compiler-style memoization keyed on the viewport handle object:
let mM;
if (xo[2] !== Oe.handle) { mM = Oe.handle?.isSticky() ?? true; xo[2] = Oe.handle; xo[3] = mM }
else mM = xo[3];
let Xg = mM;
// ...
if (qo > 0 && !Xg) { /* scan backwards for the nearest user prompt */ }
Oe.handle keeps the same object identity for the lifetime of the list, so isSticky() is evaluated once at mount — while the view is still pinned to the bottom, returning true — and that cached true is reused for every subsequent render. !Xg is therefore permanently false and the prompt-scan loop is never entered.
getScrollTop() and getPendingDelta() are memoized on the same key, so the derived scroll offset is frozen at its mount-time value as well.
The scroll subscription still fires re-renders, but because the memo key never changes, each re-render reuses the stale cached values.
2.1.250 keeps the identical pattern (Io[2] !== ot.handle).
The "Jump to bottom" pill is unaffected because it reads handle.isSticky() directly inside a callback rather than through the memo — which is why scroll state looks correctly detected while the header is missing.
Suggested fix
Don't memoize the live viewport reads on the handle identity. Key them on the scroll position/tick that the subscription already provides, or read them unmemoized as 2.1.246 did.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Confirming this on Windows 11, VS Code integrated terminal, native install (
"tui": "fullscreen") — so it isn't macOS- or Ghostty-specific.Same version boundary independently: 2.1.246 fine, 2.1.247 broken, still broken on 2.1.250.
I ran into it before finding your issue and landed on the same root cause from a different angle — diffing the older binaries still on disk against the current one. Both retained builds read
isSticky()/getScrollTop()live on every render; 2.1.250 gates them onIo[2] !== ot.handle, so they are evaluated once at mount while the view is still pinned to the bottom, and every later render reuses that cachedtrue.Same asymmetry visible here: the "Jump to bottom" pill tracks the scroll position correctly while row 1 stays blank.
Confirming on Linux (WSL2), with a positive control that bears on the competing diagnosis in #91251.
Environment
@anthropic-ai/claude-code), not the native installer"tui": "fullscreen"The scrolling is driven headlessly. A Python script forks a pty, resumes one finished session, sends SGR wheel-up events and dumps the pyte screen buffer. No terminal emulator is involved, so iTerm2, Ghostty and the VS Code terminal are all out of the picture.
Same session, two builds, measured today
| build | row 0 after 12 wheel-up ticks |
| --- | --- |
| 2.1.246 |
❯followed by the first line of the scrolled-off prompt || 2.1.260 | blank (ordinary transcript text) |
Same session ID, same script, same keystroke sequence, half an hour apart. An earlier run of the same harness on 29 August had 2.1.240, 2.1.245 and 2.1.246 rendering the header, with 2.1.247, 2.1.250 and 2.1.251 not rendering it. That is the boundary this issue reports.
It is therefore still broken in 2.1.260, fourteen releases after 2.1.247, and the changelog entries from 2.1.240 through 2.1.260 say nothing about the sticky header.
What the positive control says about #91251
That thread concludes the header never worked at all, because the prompt extractor only handles
message.contentin its array shape, while typed prompts are stored as bare strings. The session I resumed has exactly two non-meta user messages, and both are string-shaped:2.1.246 pins the second one, printing
❯and its first non-empty line, off exactly that transcript. So on a resumed session whose storedcontentis a plain string, and whosecontent[0]is a newline character rather than a text block, the 2.1.246 extractor still reaches the text.Whatever the string shape breaks elsewhere, it cannot explain one build showing the header and another hiding it on identical input. The memoized
isSticky()described in this issue can.I can paste the probe script if reproducing this without a terminal in the loop is useful.
Adding a data point on impact, since pinning an older build has been the de-facto workaround in this thread.
Claude Fable 5.1 (
claude-fable-5-1) shipped in 2.1.257 and is now the default Fable model. Requesting it from an older client is refused server-side:Tested on 2.1.246 in headless print mode, both with plain
--model claude-fable-5-1and withANTHROPIC_CUSTOM_MODEL_OPTION=claude-fable-5-1. Same 400 either way,duration_api_ms: 0, zero tokens billed — the client-side custom-model option does not get around it, because the version gate is applied by the API, not by the CLI.So the floor for the current Fable model is 2.1.251, and the sticky header last renders in 2.1.246. There is no build that has both. Anyone who needs the current model has to give up the sticky prompt header entirely; for them the downgrade workaround is closed.