/** * Screen: the top-level entry point that ties Solid rendering to the terminal. * * Creates a root TermNode, starts the frame scheduler, manages input, * and handles the terminal lifecycle (raw mode, cleanup on exit). * * Usage: * const screen = mount(() => , { fps: 30 }) * // later: * screen.unmount() */ import type { Element as JSXElement } from "solid-js"; import { flush } from "solid-js"; import { createInputBus, type InputBus, type InputBusInternal, InputContext, } from "../input/hooks.ts"; import { decodePasteBytes, type InputHandle, startInput, } from "../input/index.ts"; import { handleInputKey } from "../input/input-handler.ts"; import type { ParsedKey } from "../input/parse-keypress.ts"; import type { RawMouseEvent } from "../input/parse-mouse.ts"; import { createComponent, insertNode, render, type TermNode, } from "../renderer/term-node.ts"; import { diffFrames, serializeChanges, wrapSynchronized, } from "./frame-buffer.ts"; import { clearDirty, createRenderState, findHandler, hitTest, layout, paint, renderFrame, renderToAnsi, scrollBy, } from "./pipeline.ts"; import { clearSelection, copyToClipboard, getSelectedText, } from "./selection.ts"; import { terminalCaps } from "./terminal-caps.ts"; import { Node as YogaNode } from "./yoga.ts"; export interface ScreenOptions { /** Use alternate screen buffer (default: false — prefer primary buffer for terminal-native feel) */ altScreen?: boolean; /** Terminal columns (default: stdout columns or 80) */ cols?: number; /** * Content-driven height (default: false). When true, the active region * height equals the content height instead of the terminal height. * Content grows downward from the cursor position; when it exceeds the * viewport, old content scrolls into terminal scrollback via natural LFs. * This matches Claude Code / Ink's primary-screen rendering model. */ contentDriven?: boolean; /** Enable input handling (default: true) */ enableInput?: boolean; /** Target frames per second (default: 30) */ fps?: number; /** Enable kitty keyboard protocol (default: false) */ kittyKeyboard?: boolean; /** Enable mouse tracking (default: false) */ mouse?: boolean; /** Terminal rows (default: stdout rows or 24) */ rows?: number; /** Whether synchronized output is supported (default: false) */ syncOutput?: boolean; /** Write function (default: process.stdout.write) */ write?: (data: string) => void; } export interface Screen { /** * Commit a component to scrollback above the active render region. * Renders the JSX once, writes it permanently, then disposes the tree. * Use this to "finalize" completed output (e.g., finished tool calls, messages). */ commit(content: () => JSXElement): void; /** * Commit a component inline — renders once, disposes reactivity, and inserts * the frozen nodes into a target parent within the viewport. Unlike commit(), * the content stays visible in the active render region and participates in * layout/paint, but has no reactive subscriptions. */ commitTo( content: () => JSXElement, parent: TermNode, anchor?: TermNode, ): void; /** The input bus — for direct event subscription outside components. */ input: InputBus; /** Whether mouse tracking is currently enabled. */ mouse: boolean; /** Force an immediate render (bypasses frame scheduler). */ refresh(): void; /** Resize the screen. Triggers a full re-render. */ resize(cols: number, rows: number): void; /** The root TermNode — available for testing/inspection. */ root: TermNode; /** Enable or disable mouse tracking at runtime. */ setMouse(enabled: boolean): void; /** Stop the frame loop, dispose the Solid tree, restore terminal. */ unmount(): void; } function getTerminalSize(): { cols: number; rows: number } { const cols = process.stdout.columns ?? 80; const rows = process.stdout.rows ?? 24; return { cols, rows }; } export function mount( code: () => JSXElement, options: ScreenOptions = {}, ): Screen { const termSize = getTerminalSize(); let cols = options.cols ?? termSize.cols; let rows = options.rows ?? termSize.rows; const fps = options.fps ?? 30; const write = options.write ?? ((data: string) => process.stdout.write(data)); const syncOutput = options.syncOutput ?? terminalCaps.syncOutput; const enableInput = options.enableInput ?? true; const altScreen = options.altScreen ?? false; const contentDriven = options.contentDriven ?? false; // Track content-driven state let contentRows = 0; // visible rows on terminal (min of content, terminal) let prevViewportY = 0; // viewport offset from previous frame // Create root node with yoga const yogaRoot = new YogaNode(); yogaRoot.setWidth(cols); if (!contentDriven) { yogaRoot.setHeight(rows); } // else: unconstrained height initially — will be set per frame const root: TermNode = { type: "box", props: {}, children: [], parent: null, yogaNode: yogaRoot, screenRect: null, scrollTop: 0, dirty: true, _cachedText: null, }; // Render state (double-buffered frame buffers) let state = createRenderState( cols, rows, syncOutput, terminalCaps.hyperlinks, ); // Input bus — shared between the input system and Solid components via context const bus: InputBusInternal = createInputBus(); // Mount the Solid tree with InputContext provider // In Solid 2.0, createContext() returns the provider component directly const dispose = render( () => createComponent(InputContext, { value: bus as InputBus, get children() { return code(); }, }) as TermNode, root, ); // Find the first focused node in the tree function findFocusedInput(node: TermNode): TermNode | null { if (node.type === "input" && node.props.focused !== false) { return node; } for (const child of node.children) { const found = findFocusedInput(child); if (found) return found; } return null; } // Route key events to focused elements function handleInputElement(key: ParsedKey): boolean { const inputNode = findFocusedInput(root); if (!inputNode) return false; const value = (inputNode.props.value as string) ?? ""; const rawCursor = (inputNode.props._cursorPos as number) ?? value.length; // Clamp cursor — value may have been changed externally (e.g. submit clearing) const cursorPos = Math.max(0, Math.min(rawCursor, value.length)); const inputState = { value, cursorPos }; const multiline = inputNode.props.multiline as boolean | undefined; const result = handleInputKey(key, inputState, { multiline }); // Update cursor position on the node inputNode.props._cursorPos = result.cursorPos; if (result.changed) { const onChange = inputNode.props.onChange as | ((v: string) => void) | undefined; onChange?.(result.value); } if (result.submit) { const onSubmit = inputNode.props.onSubmit as | ((v: string) => void) | undefined; onSubmit?.(result.value); } return result.changed || result.submit || result.cursorPos !== cursorPos; } // Mouse hit-testing state let hoveredNode: TermNode | null = null; function handleMouseEvent(event: RawMouseEvent): void { // Mouse coords are already 0-indexed from the parser const col = event.x; const row = event.y; const target = hitTest(root, col, row); // Text selection handling if (event.type === "down" && event.button === 0) { state.selection.anchor = { col, row }; state.selection.focus = { col, row }; } else if (event.type === "drag" && state.selection.anchor) { state.selection.focus = { col, row }; } else if ( event.type === "up" && event.button === 0 && state.selection.anchor ) { state.selection.focus = { col, row }; const { anchor } = state.selection; if (anchor.col === col && anchor.row === row) { // Click with no drag — clear selection clearSelection(state.selection); } else { // Copy selected text to clipboard via OSC 52 // Use prevBuffer — it has the last rendered frame (buffer gets swapped) const text = getSelectedText(state.prevBuffer, state.selection); if (text) copyToClipboard(text, write); } } // Enter/leave tracking if (event.type === "move" || event.type === "drag") { if (target !== hoveredNode) { if (hoveredNode) { const leave = findHandler(hoveredNode, "onMouseLeave"); leave?.handler(event); } hoveredNode = target; if (target) { const enter = findHandler(target, "onMouseEnter"); enter?.handler(event); } } } // Click dispatch if (event.type === "up" && target) { const click = findHandler(target, "onClick"); click?.handler(event); } // Scroll dispatch to scroll containers if (event.type === "scroll" && target) { // Auto-scroll the nearest overflow="scroll" ancestor let scrollContainer: TermNode | null = target; while (scrollContainer) { if (scrollContainer.props.overflow === "scroll") { const delta = event.scroll?.direction === "up" ? -1 : 1; scrollBy(scrollContainer, delta); break; } scrollContainer = scrollContainer.parent; } // Still fire user's onScroll handler if present const scrollTarget = findHandler(target, "onScroll"); if (scrollTarget) { scrollTarget.handler(event); } } } // Start input handling let inputHandle: InputHandle | null = null; if (enableInput) { inputHandle = startInput( { onKey(key) { // Clear text selection on any keypress if (state.selection.anchor) clearSelection(state.selection); // Route to focused first, then dispatch to bus handleInputElement(key); bus._dispatchKey(key); // Render on next microtask — coalesces rapid keypresses scheduleRender(); }, onMouse(event) { handleMouseEvent(event); bus._dispatchMouse(event); }, onPaste(bytes) { bus._dispatchPaste(decodePasteBytes(bytes)); }, }, { mouse: options.mouse ?? false, kittyKeyboard: options.kittyKeyboard ?? terminalCaps.kittyKeyboard, }, ); } // Frame scheduler let frameTimer: Timer | null = null; let running = true; let renderPending = false; let renderedSinceTick = false; /** Flush signals and render a frame. */ function doRender() { if (!running) return; flush(); if (contentDriven) { if (!root.dirty) return; // ── Always unconstrained layout (CC pattern) ── yogaRoot.setHeight(undefined as unknown as number); layout(root, cols, undefined); clearDirty(root); // Measure natural content height let contentHeight = 0; for (const child of root.children) { if (child.yogaNode) { const bottom = child.yogaNode.getComputedTop() + child.yogaNode.getComputedHeight(); if (bottom > contentHeight) contentHeight = Math.ceil(bottom); } } contentHeight = Math.max(1, contentHeight); // Viewport: show the last `rows` rows of content const viewportY = Math.max(0, contentHeight - rows); const visibleRows = Math.min(contentHeight, rows); // ── Terminal adjustments ── const scrollDelta = viewportY - prevViewportY; const heightDelta = visibleRows - contentRows; // Growth and scroll can happen simultaneously (e.g., opening // command palette pushes content past the terminal bottom). // Total LFs = new terminal rows (heightDelta) + scroll amount. const totalLFs = Math.max(0, heightDelta) + Math.max(0, scrollDelta); if (totalLFs > 0 && contentRows > 0) { write(`\x1b[${contentRows};1H`); write("\n".repeat(totalLFs)); write(`\x1b[${rows}A`); } else if (heightDelta < 0 || scrollDelta < 0) { // Content shrunk or viewport shifted up — clear the entire // old visible area so stale content (border lines, suggestion // text) from the previous frame doesn't linger on screen. write(`\x1b[1;1H\x1b[0J`); } // ── Resize / shift prevBuffer to match terminal state after scroll ── if (visibleRows !== contentRows || scrollDelta !== 0) { const oldPrev = state.prevBuffer; const oldRows = state.rows; state = createRenderState( cols, visibleRows, syncOutput, terminalCaps.hyperlinks, ); if (scrollDelta > 0 && oldRows > 0) { // Terminal scrolled: shift prevBuffer up by scrollDelta. // After scroll, terminal row 1 has what was in old buffer row scrollDelta. const copyRows = Math.min(oldRows - scrollDelta, visibleRows); for (let row = 0; row < copyRows; row++) { const srcRow = row + scrollDelta; if (srcRow >= 0 && srcRow < oldRows) { for (let col = 0; col < cols; col++) { state.prevBuffer.set(col, row, oldPrev.get(col, srcRow)); } } } } else if (scrollDelta === 0 && heightDelta >= 0) { // No scroll, just grew: copy overlapping rows const overlap = Math.min(oldRows, visibleRows); for (let row = 0; row < overlap; row++) { for (let col = 0; col < cols; col++) { state.prevBuffer.set(col, row, oldPrev.get(col, row)); } } } // For shrinking (heightDelta < 0) or unscroll (scrollDelta < 0), // prevBuffer starts blank → full repaint. Sync output masks flicker. } contentRows = visibleRows; prevViewportY = viewportY; // ── Paint with viewport offset → only visible content in buffer ── state.buffer.clearDirtyRows(); paint(root, state.buffer, 0, -viewportY); state.activeHeight = state.buffer.contentHeight(); const dirtyRows = new Set(state.buffer.dirtyRows); for (const row of state.prevBuffer.dirtyRows) dirtyRows.add(row); const changes = diffFrames(state.prevBuffer, state.buffer, dirtyRows); const ansi = serializeChanges(changes, undefined, state.linksEnabled); const tmp = state.prevBuffer; state.prevBuffer = state.buffer; state.buffer = tmp; if (ansi) { const out = state.syncSupported ? wrapSynchronized(ansi) : ansi; write(out); } return; } const ansi = renderFrame(root, state); if (ansi) write(ansi); } /** Schedule a render on the next microtask (coalesces rapid input). */ function scheduleRender() { if (renderPending) return; renderPending = true; queueMicrotask(() => { renderPending = false; if (!running) return; renderedSinceTick = true; doRender(); }); } function tick() { if (!running) return; if (renderedSinceTick) { renderedSinceTick = false; return; } doRender(); } frameTimer = setInterval(tick, Math.round(1000 / fps)); if (altScreen) { write("\x1b[?1049h\x1b[?25l"); } else if (contentDriven) { // Content-driven: scroll viewport to guarantee content starts at row 1. // Same as CC/Ink's primary-screen setup — CUP (absolute positioning) // in serializeChanges relies on content at row 1. write(`${"\n".repeat(rows)}\x1b[${rows}A\x1b[0J\x1b[?25l`); } else { // Scroll terminal to make clean space, clear it, hide cursor write(`${"\n".repeat(rows)}\x1b[${rows}A\x1b[0J\x1b[?25l`); } // Initial render tick(); // Handle terminal resize const onResize = () => { const size = getTerminalSize(); screen.resize(size.cols, size.rows); }; process.stdout.on("resize", onResize); // Safety net: restore terminal on unexpected exit const onSignal = () => { screen.unmount(); process.exit(); }; const onExit = () => { screen.unmount(); }; process.on("SIGINT", onSignal); process.on("SIGTERM", onSignal); process.on("exit", onExit); let mouseActive = options.mouse ?? false; const screen: Screen = { root, input: bus, get mouse() { return mouseActive; }, set mouse(enabled: boolean) { screen.setMouse(enabled); }, setMouse(enabled: boolean) { if (enabled === mouseActive) return; mouseActive = enabled; if (enabled) { write("\x1b[?1006h\x1b[?1003h"); } else { write("\x1b[?1003l\x1b[?1006l"); } }, unmount() { if (!running) return; running = false; if (frameTimer) { clearInterval(frameTimer); frameTimer = null; } inputHandle?.stop(); inputHandle = null; if (mouseActive) { write("\x1b[?1003l\x1b[?1006l"); mouseActive = false; } dispose(); process.stdout.off("resize", onResize); process.off("SIGINT", onSignal); process.off("SIGTERM", onSignal); process.off("exit", onExit); if (altScreen) { // Leave alternate screen buffer + restore cursor write("\x1b[?25h\x1b[?1049l"); } else { // Restore the terminal's native cursor write("\x1b[?25h"); } }, commit(content: () => JSXElement) { if (altScreen) return; // alt-screen has no scrollback // 1. Render the component to ANSI const { ansi } = renderToAnsi(content, cols, terminalCaps.hyperlinks); // 2. Erase the active region from the viewport write("\x1b[1;1H\x1b[0J\x1b[0m"); // 3. Write rendered content at the viewport top write(ansi); // 4. Push committed content into scrollback write("\n".repeat(rows)); write("\x1b[H"); // 5. Reset frame buffers and repaint root.dirty = true; state = createRenderState( cols, rows, syncOutput, terminalCaps.hyperlinks, ); screen.refresh(); }, commitTo(content: () => JSXElement, parent: TermNode, anchor?: TermNode) { // Render content in an isolated Solid root const tempYoga = new YogaNode(); tempYoga.setWidth(cols); const tempRoot: TermNode = { type: "box", props: {}, children: [], parent: null, yogaNode: tempYoga, screenRect: null, scrollTop: 0, dirty: true, _cachedText: null, }; const dispose = render(content, tempRoot); // Dispose reactivity — effects die, nodes remain frozen dispose(); // Move children from temp root into target parent const children = [...tempRoot.children]; for (const child of children) { // Detach from temp root const idx = tempRoot.children.indexOf(child); tempRoot.children.splice(idx, 1); if (tempRoot.yogaNode && child.yogaNode) { tempRoot.yogaNode.removeChild(child.yogaNode); } child.parent = null; // Insert into target parent via renderer (handles yoga reparenting + markDirty) insertNode(parent, child, anchor); } tempYoga.free(); screen.refresh(); }, refresh() { doRender(); }, resize(newCols: number, newRows: number) { if (newCols < 1 || newRows < 1) return; clearSelection(state.selection); cols = newCols; rows = newRows; yogaRoot.setWidth(cols); if (!contentDriven) { yogaRoot.setHeight(rows); } else { // Content-driven: reset viewport tracking on resize contentRows = 0; prevViewportY = 0; } root.dirty = true; state = createRenderState( cols, contentDriven ? 1 : rows, syncOutput, terminalCaps.hyperlinks, ); // Clear the entire viewport to avoid ghost artifacts when terminal grows if (!altScreen) { write(`\x1b[H\x1b[0J`); } screen.refresh(); }, }; return screen; }