Guidance for any AI assistant working in this repository.
yokai — a React terminal renderer used by claude-corp. Pure-TypeScript Yoga flexbox, diff-based screen output, ScrollBox with viewport culling and hardware scroll hints. Forked from claude-code-kit, itself a fork of Ink.
Two packages in a pnpm monorepo:
@yokai-tui/renderer— React reconciler, components, event system, terminal I/O@yokai-tui/shared— pure-TS Yoga port, logging, env helpers
The renderer depends on the shared package, so always build shared first.
These are non-negotiable. Apply on every commit, every PR, every feature.
- Never commit directly to
main. Every change starts on a branch cut frommain. - Open a PR into
mainwhen ready. - Merge with normal merge commits only. Never rebase-merge. Never squash-merge. The full commit graph is preserved on purpose — future debugging and refactoring depend on it.
- Granular and frequent. One logical change = one commit.
- No "WIP" commits, no batched-up commits. If you're about to commit two unrelated things, split them.
- Co-authorship is mandatory on every commit:
Co-Authored-By: Mark <psyhik17@gmail.com> Co-Authored-By: Codex <noreply@anthropic.com>
- If there's any sense that "doing it the harder way will be harder now but better long-term" — do it the better way. Even if it takes more time.
- No rushed or underdeveloped solutions. No spaghetti.
- Yokai is a foundation library; downstream consumers rely on its stability.
React component tree
→ Reconciler (React 19 host config) reconciler.ts
→ DOM mutation + Yoga layout calc dom.ts / yoga-layout/index.ts
→ Tree traversal + text wrapping render-node-to-output.ts
→ Screen buffer (cell grid) screen.ts / output.ts
→ Frame diff → ANSI patches log-update.ts / frame.ts
→ stdout
Frames are double-buffered. Diffing is cell-by-cell. The renderer emits the minimal ANSI patch sequence each tick — a spinner update or a streamed line touches O(changed cells), not O(rows × cols).
| File | What it does |
|---|---|
packages/renderer/src/ink.tsx |
Main Ink class, frame lifecycle, alt-screen, signal handling, selection coordination |
packages/renderer/src/reconciler.ts |
React 19 host config — createInstance, commitUpdate, removeChild, focus |
packages/renderer/src/render-node-to-output.ts |
DFS traversal, text wrapping, ScrollBox drain, viewport culling |
packages/renderer/src/screen.ts |
Cell grid, char/style/hyperlink pools |
packages/renderer/src/log-update.ts |
Frame diffing, ANSI patch generation, DECSTBM scroll hints |
packages/renderer/src/selection.ts |
Text selection state machine (anchor/focus/virtualRow tracking) |
packages/renderer/src/focus.ts |
Tab-order focus manager, focus stack |
packages/renderer/src/components/ScrollBox.tsx |
Imperative scroll API, sticky scroll, clamp bounds |
packages/renderer/src/components/AlternateScreen.tsx |
Alt-screen enter/exit with mouse tracking |
packages/renderer/src/components/Draggable.tsx |
Drag primitive: gesture capture, raise-on-press, drag-time z boost, bounds clamp |
packages/renderer/src/components/DropTarget.tsx |
Drop receiver: accept filter, hover lifecycle, topmost-wins drop dispatch |
packages/renderer/src/components/Resizable.tsx |
Resize primitive with s / e / se handles |
packages/renderer/src/components/Surface/ |
Foundational rectangle primitive — paint / layer / clip / hit-test / elevation / backdrop. Surface.tsx is the React shell; layer.ts resolves named bands to zIndex; shadow.ts is the pure cell math for elevation. Draggable / Resizable paint through Surface; Window (A4) will compose it. |
packages/renderer/src/components/TextInput/ |
Editable text input — pure state machine + caret math + React shell |
packages/renderer/src/events/paste-event.ts |
PasteEvent class for the smart-bracketed-paste pipeline |
packages/renderer/src/components/PasteContext.ts |
Lets AlternateScreen configure App's paste threshold |
packages/renderer/src/components/FocusGroup.tsx |
Arrow-key navigation between focusable descendants |
packages/renderer/src/components/FocusRing.tsx |
Focusable Box with focus-visible border indicator |
packages/renderer/src/components/FocusContext.ts |
React context exposing FocusManager + root to hooks |
packages/renderer/src/hooks/use-focus.ts |
Per-element focus tracking + imperative focus |
packages/renderer/src/hooks/use-focus-manager.ts |
Global focus actions (focused, focus, focusNext/Previous, blur) |
packages/renderer/src/focus.ts |
FocusManager — activeElement, focus stack, Tab cycling, subscribe APIs |
packages/renderer/src/drag-registry.ts |
Module-scope coordination between Draggable and DropTarget |
packages/renderer/src/hit-test.ts |
Hit-test for click / mouse-down dispatch (z-order + escape-bounds traversal) |
packages/shared/src/yoga-layout/index.ts |
Pure-TS flexbox engine |
pnpm install
pnpm build # shared → renderer
pnpm typecheck
pnpm lint
pnpm testCI runs typecheck + lint + build + test on every push and PR to main (.github/workflows/ci.yml).
- Selection state is owned by Ink, not React. It survives re-renders and is mutated directly by event handlers.
- ScrollBox's
scrollTo/scrollBymutate the DOM in place. Not React state. Surprising, but intentional — required for race-free scroll under streaming content. - Yoga node lifecycle:
clearYogaNodeReferencesnulls all refs in a subtree beforefreeRecursive(). The root unmount path uses.free()not.freeRecursive()to avoid double-freeing children. Don't mess with this without understanding why. - Alt-screen cleanup is unconditional on signal-exit.
EXIT_ALT_SCREENandDISABLE_MOUSE_TRACKINGare sent every time because thealtScreenActiveflag can be stale.?1049lis a no-op when already on the main screen, so this is safe. virtualAnchorRow/virtualFocusRowtrack pre-clamp positions during selection drag-to-scroll. Both are required for the drag→follow transition to be correct. If you touch selection code, readselection.tsend-to-end first.onMouseDown+ gesture capture for drag interactions. Box exposesonMouseDown(e)alongsideonClick. Inside the handler, callinge.captureGesture({ onMove, onUp })claims all subsequent mouse-motion events and the eventual release for that one drag — selection extension is suppressed for the duration, and the captured handlers fire even when the cursor leaves the originally-pressed element's bounds. The active gesture lives onApp.activeGesture; it's drained on FOCUS_OUT and on lost-release recovery so a drag aborted by leaving the terminal window can't leave a dangling handler.onClickand selection still work normally when no gesture is captured. Readevents/mouse-event.test.tsfor the dispatch and capture semantics, and the comments aroundApp.handleMouseEventfor how the routing decisions interact with selection state.zIndexonly applies toposition: 'absolute'. TheStyles.zIndexproperty is silently ignored on in-flow / relative nodes (they don't overlap, so paint order has no meaning). A dev-mode warning fires fromsetStylewhen set on a non-absolute node. Stacking is flat per parent's render group, not CSS-stacking-context-global: a nested z-indexed absolute sorts among its siblings inside its parent, not against arbitrarily distant cousins. This emerges naturally fromrenderChildrenrecursing per parent — each call sorts only that parent's direct children. Equal effective-z preserves DOM order via stable sort, so the no-zIndex case stays bit-for-bit identical to pre-feature behavior. Negative zIndex paints under in-flow content (the backdrop pattern). Readrender-node-to-output.test.tsfor the exact paint-order semantics across overlap, nesting, and equal-z cases.- Dirty-absolute rects are collected tree-wide once per frame. The "force re-render clean siblings overlapping a moving absolute" guard in
renderChildrenreads from a module-scope list (globalDirtyAbsoluteRects) populated by a single walk at theink-rootentry of each render. Tree-wide becauseabsoluteClears(output.ts pass 1) is global — a moving absolute's clear can suppress blits at any level, including non-sibling subtrees. The earlier per-renderChildren pre-scan only saw direct dirty-absolute children and missed cross-subtree contamination (the constrained-drag notch bug). Don't revert this without re-introducing the regression test inrender-node-to-output.test.ts > clean cousin of a moving absolute is repainted. - Hit-test honors zIndex AND traverses outside parent bounds for absolute children.
hit-test.tsmirrorsrenderChildren's paint-order sort so the topmost painted box is also the one that receives the click. When a parent's rect doesn't contain the cursor, recursion still descends into ABSOLUTE children — they own their own coordinate space and may have been positioned outside the ancestor (raise-on-press + drag often takes them there). Without the escape-bounds traversal, dragging an absolute outside its container makes it unclickable. - Gesture capture is the substrate for drag/drop/resize.
<Draggable>,<DropTarget>, and<Resizable>all build onevent.captureGesture({ onMove, onUp })— there's no parallel "drag" or "resize" event system. The pure-helper / handler-press extraction pattern (handleDragPress(e, deps),handleResizePress(e, dir, deps)) keeps the gesture lifecycle testable without React. New components in this family should follow the same shape so tests don't have to spin up Ink. The drag/drop coordination state lives indrag-registry.ts, not in any component. <Surface>is the paint substrate for every desktop primitive.<Draggable>and<Resizable>paint through Surface; the upcoming<Window>(A4) composes Surface + Draggable + Resizable + title chrome. Renderer-side changes (host attributessurfaceHitTestBoundary/surfaceElevationconsumed byhit-test.ts/render-node-to-output.ts, layer→zIndex via React-sideresolveZIndex) affect all consumers transitively. Default Surface is byte-identical to<Box>—layer='base'resolves to no zIndex, no surface* attributes, no extra siblings. The named layer bands (base/docked/overlay/dropdown/modal/popover/tooltip/drag-ghost, gaps of 100/1000) are the load-bearing vocabulary. Readcomponents/Surface/types.ts,layer.ts,shadow.ts, and the hit-test / render-node-to-output tests withA23in their describe block before changing any of it.- Resizable currently CLIPS overflow (
overflow: 'hidden'default on the wrapper Box). True "box can't shrink past content" autoFit was attempted twice and reverted both times — measurement timing is the blocker (yoga'sgetComputedHeightis read BEFORE ink callscalculateLayout, so values are stale by one frame, and the auto-grow effect ended up fighting the user's south-handle drag). The right fix needs a measurement strategy that runs AFTER calculateLayout — most likely a post-render hook fed by ink'sonFrame. Tracked as a future enhancement; keep theoverflow: 'hidden'default until then. - Focus subscriptions on FocusManager. Two surfaces:
subscribeToFocus(node, listener)fires only on the named node's transitions (used byuseFocusso each consumer re-renders only when its own element changes), andsubscribe(listener)fires after every focus change (used byuseFocusManagerto keep itsfocusedvalue reactive). Both iterate snapshots of the listener set so a listener that unsubscribes during dispatch doesn't perturb others.handleNodeRemoveddrops the per-node listener bucket on cleanup so a stale listener can't keep a freed yoga subtree alive. - FocusContext is the React-side bridge to FocusManager. Lives on
Appand exposes{ manager, root }. Hooks (useFocus,useFocusManager) and<FocusGroup>consume it viauseContext. Components rendered OUTSIDE App (e.g. unit tests that mount a tree without going through Ink's render) get null and degrade to no-op imperatives + stable shapes — explicit null checks at every callsite, no throws. <FocusGroup>does NOT implement roving tabindex in v1. Tab cycles through everytabIndex >= 0in the entire tree (the existing FocusManager.focusNext walker); FocusGroup adds arrow-key navigation on top. That keeps Tab predictable for consumers who don't want to be Tab-bounded; consumers who DO want pure roving-tabindex behavior can settabIndex={-1}on inner items and only tabIndex={0} on the group's entry point. Roving-tabindex as an opt-in mode is a sound v2 if a real consumer asks.- FocusGroup uses
onKeyDown(notuseInput). Switched after TextInput shipped — useInput fires regardless of target, but TextInput needs to consume arrow keys for caret movement. With onKeyDown on the group's container Box, KeyboardEvent bubbles from the focused descendant; if the descendant calledpreventDefault()(TextInput does), FocusGroup seesdefaultPreventedand skips. Don't revert this without re-introducing the conflict. - Smart bracketed paste split.
App.handleParsedInputsplits pastes by length: ≤ threshold dispatched as per-character keypresses (so short pastes feel like typing and useInput sees a normal stream), > threshold fired once as aPasteEventthrough the DOM AND once via useInput as the full string withkey.isPasted=true. Threshold default is 32, configurable via<AlternateScreen pasteThreshold>which writes toApp.pasteThresholdviaPasteContext. The threshold lives on the App instance (mutable field) because the split runs outside React — read at parse time against the latest value, no React rerender of the input loop. - TextInput is a state-machine + React shell. The pure reducer in
components/TextInput/state.tsowns every editing operation (insertText, deleteBackward/Forward/WordBackward/LineBackward/LineForward, moveCaret with extend, setCaret, selectAll, undo, redo). The React component translates keystrokes into Action objects via the purekeyToActionmapper. Undo grouping merges consecutive same-kind insert/delete entries; paste always gets its own entry (so Ctrl+Z reverts a paste atomically). Caret rendering usesuseDeclaredCursorso the real terminal cursor follows — no synthetic glyph (matters for IME and screen readers).