({
scrollable,
bottom,
overlay,
bottomFloat,
modal,
modalScrollRef,
scrollRef,
dividerYRef,
hidePill = false,
hideSticky = false,
newMessageCount = 0,
onPillClick,
}: Props)
| 283 | * so nothing can accidentally render outside it. |
| 284 | */ |
| 285 | export function FullscreenLayout({ |
| 286 | scrollable, |
| 287 | bottom, |
| 288 | overlay, |
| 289 | bottomFloat, |
| 290 | modal, |
| 291 | modalScrollRef, |
| 292 | scrollRef, |
| 293 | dividerYRef, |
| 294 | hidePill = false, |
| 295 | hideSticky = false, |
| 296 | newMessageCount = 0, |
| 297 | onPillClick, |
| 298 | }: Props): React.ReactNode { |
| 299 | const { rows: terminalRows, columns } = useTerminalSize(); |
| 300 | // Scroll-derived chrome state lives HERE, not in REPL. StickyTracker |
| 301 | // writes via ScrollChromeContext; pillVisible subscribes directly to |
| 302 | // ScrollBox. Both change rarely (pill flips once per threshold crossing, |
| 303 | // sticky changes ~5-20×/transcript) — re-rendering FullscreenLayout on |
| 304 | // those is fine; re-rendering the 6966-line REPL + its 22+ useAppState |
| 305 | // selectors per-scroll-frame was not. |
| 306 | const [stickyPrompt, setStickyPrompt] = useState<StickyPrompt | null>(null); |
| 307 | const chromeCtx = useMemo(() => ({ setStickyPrompt }), []); |
| 308 | // Boolean-quantized scroll subscription. Snapshot is "is viewport bottom |
| 309 | // above the divider y?" — Object.is on a boolean → FullscreenLayout only |
| 310 | // re-renders when the pill should actually flip, not per-frame. |
| 311 | const subscribe = useCallback( |
| 312 | (listener: () => void) => scrollRef?.current?.subscribe(listener) ?? (() => {}), |
| 313 | [scrollRef], |
| 314 | ); |
| 315 | const pillVisible = useSyncExternalStore(subscribe, () => { |
| 316 | const s = scrollRef?.current; |
| 317 | const dividerY = dividerYRef?.current; |
| 318 | if (!s || dividerY == null) return false; |
| 319 | return s.getScrollTop() + s.getPendingDelta() + s.getViewportHeight() < dividerY; |
| 320 | }); |
| 321 | // Wire up hyperlink click handling — in fullscreen mode, mouse tracking |
| 322 | // intercepts clicks before the terminal can open OSC 8 links natively. |
| 323 | useLayoutEffect(() => { |
| 324 | if (!isFullscreenEnvEnabled()) return; |
| 325 | const ink = instances.get(process.stdout); |
| 326 | if (!ink) return; |
| 327 | ink.onHyperlinkClick = url => { |
| 328 | // Most OSC 8 links emitted by Claude Code are file:// URLs from |
| 329 | // FilePathLink (FileEdit/FileWrite/FileRead tool output). openBrowser |
| 330 | // rejects non-http(s) protocols — route file: to openPath instead. |
| 331 | if (url.startsWith('file:')) { |
| 332 | try { |
| 333 | void openPath(fileURLToPath(url)); |
| 334 | } catch { |
| 335 | // Malformed file: URLs (e.g. file://host/path from plain-text |
| 336 | // detection) cause fileURLToPath to throw — ignore silently. |
| 337 | } |
| 338 | } else { |
| 339 | void openBrowser(url); |
| 340 | } |
| 341 | }; |
| 342 | return () => { |
nothing calls this directly
no test coverage detected