/**
* 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;
}