` in the browser.
+ */
+function Box(t0) {
+ const $ = _c(42);
+ let autoFocus;
+ let children;
+ let flexDirection;
+ let flexGrow;
+ let flexShrink;
+ let flexWrap;
+ let onBlur;
+ let onBlurCapture;
+ let onClick;
+ let onFocus;
+ let onFocusCapture;
+ let onKeyDown;
+ let onKeyDownCapture;
+ let onMouseEnter;
+ let onMouseLeave;
+ let ref;
+ let style;
+ let tabIndex;
+ if ($[0] !== t0) {
+ const {
+ children: t1,
+ flexWrap: t2,
+ flexDirection: t3,
+ flexGrow: t4,
+ flexShrink: t5,
+ ref: t6,
+ tabIndex: t7,
+ autoFocus: t8,
+ onClick: t9,
+ onFocus: t10,
+ onFocusCapture: t11,
+ onBlur: t12,
+ onBlurCapture: t13,
+ onMouseEnter: t14,
+ onMouseLeave: t15,
+ onKeyDown: t16,
+ onKeyDownCapture: t17,
+ ...t18
+ } = t0;
+ children = t1;
+ ref = t6;
+ tabIndex = t7;
+ autoFocus = t8;
+ onClick = t9;
+ onFocus = t10;
+ onFocusCapture = t11;
+ onBlur = t12;
+ onBlurCapture = t13;
+ onMouseEnter = t14;
+ onMouseLeave = t15;
+ onKeyDown = t16;
+ onKeyDownCapture = t17;
+ style = t18;
+ flexWrap = t2 === undefined ? "nowrap" : t2;
+ flexDirection = t3 === undefined ? "row" : t3;
+ flexGrow = t4 === undefined ? 0 : t4;
+ flexShrink = t5 === undefined ? 1 : t5;
+ warn.ifNotInteger(style.margin, "margin");
+ warn.ifNotInteger(style.marginX, "marginX");
+ warn.ifNotInteger(style.marginY, "marginY");
+ warn.ifNotInteger(style.marginTop, "marginTop");
+ warn.ifNotInteger(style.marginBottom, "marginBottom");
+ warn.ifNotInteger(style.marginLeft, "marginLeft");
+ warn.ifNotInteger(style.marginRight, "marginRight");
+ warn.ifNotInteger(style.padding, "padding");
+ warn.ifNotInteger(style.paddingX, "paddingX");
+ warn.ifNotInteger(style.paddingY, "paddingY");
+ warn.ifNotInteger(style.paddingTop, "paddingTop");
+ warn.ifNotInteger(style.paddingBottom, "paddingBottom");
+ warn.ifNotInteger(style.paddingLeft, "paddingLeft");
+ warn.ifNotInteger(style.paddingRight, "paddingRight");
+ warn.ifNotInteger(style.gap, "gap");
+ warn.ifNotInteger(style.columnGap, "columnGap");
+ warn.ifNotInteger(style.rowGap, "rowGap");
+ $[0] = t0;
+ $[1] = autoFocus;
+ $[2] = children;
+ $[3] = flexDirection;
+ $[4] = flexGrow;
+ $[5] = flexShrink;
+ $[6] = flexWrap;
+ $[7] = onBlur;
+ $[8] = onBlurCapture;
+ $[9] = onClick;
+ $[10] = onFocus;
+ $[11] = onFocusCapture;
+ $[12] = onKeyDown;
+ $[13] = onKeyDownCapture;
+ $[14] = onMouseEnter;
+ $[15] = onMouseLeave;
+ $[16] = ref;
+ $[17] = style;
+ $[18] = tabIndex;
+ } else {
+ autoFocus = $[1];
+ children = $[2];
+ flexDirection = $[3];
+ flexGrow = $[4];
+ flexShrink = $[5];
+ flexWrap = $[6];
+ onBlur = $[7];
+ onBlurCapture = $[8];
+ onClick = $[9];
+ onFocus = $[10];
+ onFocusCapture = $[11];
+ onKeyDown = $[12];
+ onKeyDownCapture = $[13];
+ onMouseEnter = $[14];
+ onMouseLeave = $[15];
+ ref = $[16];
+ style = $[17];
+ tabIndex = $[18];
+ }
+ const t1 = style.overflowX ?? style.overflow ?? "visible";
+ const t2 = style.overflowY ?? style.overflow ?? "visible";
+ let t3;
+ if ($[19] !== flexDirection || $[20] !== flexGrow || $[21] !== flexShrink || $[22] !== flexWrap || $[23] !== style || $[24] !== t1 || $[25] !== t2) {
+ t3 = {
+ flexWrap,
+ flexDirection,
+ flexGrow,
+ flexShrink,
+ ...style,
+ overflowX: t1,
+ overflowY: t2
+ };
+ $[19] = flexDirection;
+ $[20] = flexGrow;
+ $[21] = flexShrink;
+ $[22] = flexWrap;
+ $[23] = style;
+ $[24] = t1;
+ $[25] = t2;
+ $[26] = t3;
+ } else {
+ t3 = $[26];
+ }
+ let t4;
+ if ($[27] !== autoFocus || $[28] !== children || $[29] !== onBlur || $[30] !== onBlurCapture || $[31] !== onClick || $[32] !== onFocus || $[33] !== onFocusCapture || $[34] !== onKeyDown || $[35] !== onKeyDownCapture || $[36] !== onMouseEnter || $[37] !== onMouseLeave || $[38] !== ref || $[39] !== t3 || $[40] !== tabIndex) {
+ t4 =
{children};
+ $[27] = autoFocus;
+ $[28] = children;
+ $[29] = onBlur;
+ $[30] = onBlurCapture;
+ $[31] = onClick;
+ $[32] = onFocus;
+ $[33] = onFocusCapture;
+ $[34] = onKeyDown;
+ $[35] = onKeyDownCapture;
+ $[36] = onMouseEnter;
+ $[37] = onMouseLeave;
+ $[38] = ref;
+ $[39] = t3;
+ $[40] = tabIndex;
+ $[41] = t4;
+ } else {
+ t4 = $[41];
+ }
+ return t4;
+}
+export default Box;
+//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlByb3BzV2l0aENoaWxkcmVuIiwiUmVmIiwiRXhjZXB0IiwiRE9NRWxlbWVudCIsIkNsaWNrRXZlbnQiLCJGb2N1c0V2ZW50IiwiS2V5Ym9hcmRFdmVudCIsIlN0eWxlcyIsIndhcm4iLCJQcm9wcyIsInJlZiIsInRhYkluZGV4IiwiYXV0b0ZvY3VzIiwib25DbGljayIsImV2ZW50Iiwib25Gb2N1cyIsIm9uRm9jdXNDYXB0dXJlIiwib25CbHVyIiwib25CbHVyQ2FwdHVyZSIsIm9uS2V5RG93biIsIm9uS2V5RG93bkNhcHR1cmUiLCJvbk1vdXNlRW50ZXIiLCJvbk1vdXNlTGVhdmUiLCJCb3giLCJ0MCIsIiQiLCJfYyIsImNoaWxkcmVuIiwiZmxleERpcmVjdGlvbiIsImZsZXhHcm93IiwiZmxleFNocmluayIsImZsZXhXcmFwIiwic3R5bGUiLCJ0MSIsInQyIiwidDMiLCJ0NCIsInQ1IiwidDYiLCJ0NyIsInQ4IiwidDkiLCJ0MTAiLCJ0MTEiLCJ0MTIiLCJ0MTMiLCJ0MTQiLCJ0MTUiLCJ0MTYiLCJ0MTciLCJ0MTgiLCJ1bmRlZmluZWQiLCJpZk5vdEludGVnZXIiLCJtYXJnaW4iLCJtYXJnaW5YIiwibWFyZ2luWSIsIm1hcmdpblRvcCIsIm1hcmdpbkJvdHRvbSIsIm1hcmdpbkxlZnQiLCJtYXJnaW5SaWdodCIsInBhZGRpbmciLCJwYWRkaW5nWCIsInBhZGRpbmdZIiwicGFkZGluZ1RvcCIsInBhZGRpbmdCb3R0b20iLCJwYWRkaW5nTGVmdCIsInBhZGRpbmdSaWdodCIsImdhcCIsImNvbHVtbkdhcCIsInJvd0dhcCIsIm92ZXJmbG93WCIsIm92ZXJmbG93Iiwib3ZlcmZsb3dZIl0sInNvdXJjZXMiOlsiQm94LnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgJy4uL2dsb2JhbC5kLnRzJ1xuaW1wb3J0IFJlYWN0LCB7IHR5cGUgUHJvcHNXaXRoQ2hpbGRyZW4sIHR5cGUgUmVmIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgdHlwZSB7IEV4Y2VwdCB9IGZyb20gJ3R5cGUtZmVzdCdcbmltcG9ydCB0eXBlIHsgRE9NRWxlbWVudCB9IGZyb20gJy4uL2RvbS5qcydcbmltcG9ydCB0eXBlIHsgQ2xpY2tFdmVudCB9IGZyb20gJy4uL2V2ZW50cy9jbGljay1ldmVudC5qcydcbmltcG9ydCB0eXBlIHsgRm9jdXNFdmVudCB9IGZyb20gJy4uL2V2ZW50cy9mb2N1cy1ldmVudC5qcydcbmltcG9ydCB0eXBlIHsgS2V5Ym9hcmRFdmVudCB9IGZyb20gJy4uL2V2ZW50cy9rZXlib2FyZC1ldmVudC5qcydcbmltcG9ydCB0eXBlIHsgU3R5bGVzIH0gZnJvbSAnLi4vc3R5bGVzLmpzJ1xuaW1wb3J0ICogYXMgd2FybiBmcm9tICcuLi93YXJuLmpzJ1xuXG5leHBvcnQgdHlwZSBQcm9wcyA9IEV4Y2VwdDxTdHlsZXMsICd0ZXh0V3JhcCc+ICYge1xuICByZWY/OiBSZWY8RE9NRWxlbWVudD5cbiAgLyoqXG4gICAqIFRhYiBvcmRlciBpbmRleC4gTm9kZXMgd2l0aCBgdGFiSW5kZXggPj0gMGAgcGFydGljaXBhdGUgaW5cbiAgICogVGFiL1NoaWZ0K1RhYiBjeWNsaW5nOyBgLTFgIG1lYW5zIHByb2dyYW1tYXRpY2FsbHkgZm9jdXNhYmxlIG9ubHkuXG4gICAqL1xuICB0YWJJbmRleD86IG51bWJlclxuICAvKipcbiAgICogRm9jdXMgdGhpcyBlbGVtZW50IHdoZW4gaXQgbW91bnRzLiBMaWtlIHRoZSBIVE1MIGBhdXRvZm9jdXNgXG4gICAqIGF0dHJpYnV0ZSDigJQgdGhlIEZvY3VzTWFuYWdlciBjYWxscyBgZm9jdXMobm9kZSlgIGR1cmluZyB0aGVcbiAgICogcmVjb25jaWxlcidzIGBjb21taXRNb3VudGAgcGhhc2UuXG4gICAqL1xuICBhdXRvRm9jdXM/OiBib29sZWFuXG4gIC8qKlxuICAgKiBGaXJlZCBvbiBsZWZ0LWJ1dHRvbiBjbGljayAocHJlc3MgKyByZWxlYXNlIHdpdGhvdXQgZHJhZykuIE9ubHkgd29ya3NcbiAgICogaW5zaWRlIGA8QWx0ZXJuYXRlU2NyZWVuPmAgd2hlcmUgbW91c2UgdHJhY2tpbmcgaXMgZW5hYmxlZCDigJQgbm8tb3BcbiAgICogb3RoZXJ3aXNlLiBUaGUgZXZlbnQgYnViYmxlcyBmcm9tIHRoZSBkZWVwZXN0IGhpdCBCb3ggdXAgdGhyb3VnaFxuICAgKiBhbmNlc3RvcnM7IGNhbGwgYGV2ZW50LnN0b3BJbW1lZGlhdGVQcm9wYWdhdGlvbigpYCB0byBzdG9wIGJ1YmJsaW5nLlxuICAgKi9cbiAgb25DbGljaz86IChldmVudDogQ2xpY2tFdmVudCkgPT4gdm9pZFxuICBvbkZvY3VzPzogKGV2ZW50OiBGb2N1c0V2ZW50KSA9PiB2b2lkXG4gIG9uRm9jdXNDYXB0dXJlPzogKGV2ZW50OiBGb2N1c0V2ZW50KSA9PiB2b2lkXG4gIG9uQmx1cj86IChldmVudDogRm9jdXNFdmVudCkgPT4gdm9pZFxuICBvbkJsdXJDYXB0dXJlPzogKGV2ZW50OiBGb2N1c0V2ZW50KSA9PiB2b2lkXG4gIG9uS2V5RG93bj86IChldmVudDogS2V5Ym9hcmRFdmVudCkgPT4gdm9pZFxuICBvbktleURvd25DYXB0dXJlPzogKGV2ZW50OiBLZXlib2FyZEV2ZW50KSA9PiB2b2lkXG4gIC8qKlxuICAgKiBGaXJlZCB3aGVuIHRoZSBtb3VzZSBtb3ZlcyBpbnRvIHRoaXMgQm94J3MgcmVuZGVyZWQgcmVjdC4gTGlrZSBET01cbiAgICogYG1vdXNlZW50ZXJgLCBkb2VzIE5PVCBidWJibGUg4oCUIG1vdmluZyBiZXR3ZWVuIGNoaWxkcmVuIGRvZXMgbm90XG4gICAqIHJlLWZpcmUgb24gdGhlIHBhcmVudC4gT25seSB3b3JrcyBpbnNpZGUgYDxBbHRlcm5hdGVTY3JlZW4+YCB3aGVyZVxuICAgKiBtb2RlLTEwMDMgbW91c2UgdHJhY2tpbmcgaXMgZW5hYmxlZC5cbiAgICovXG4gIG9uTW91c2VFbnRlcj86ICgpID0+IHZvaWRcbiAgLyoqIEZpcmVkIHdoZW4gdGhlIG1vdXNlIG1vdmVzIG91dCBvZiB0aGlzIEJveCdzIHJlbmRlcmVkIHJlY3QuICovXG4gIG9uTW91c2VMZWF2ZT86ICgpID0+IHZvaWRcbn1cblxuLyoqXG4gKiBgPEJveD5gIGlzIGFuIGVzc2VudGlhbCBJbmsgY29tcG9uZW50IHRvIGJ1aWxkIHlvdXIgbGF5b3V0LiBJdCdzIGxpa2UgYDxkaXYgc3R5bGU9XCJkaXNwbGF5OiBmbGV4XCI+YCBpbiB0aGUgYnJvd3Nlci5cbiAqL1xuZnVuY3Rpb24gQm94KHtcbiAgY2hpbGRyZW4sXG4gIGZsZXhXcmFwID0gJ25vd3JhcCcsXG4gIGZsZXhEaXJlY3Rpb24gPSAncm93JyxcbiAgZmxleEdyb3cgPSAwLFxuICBmbGV4U2hyaW5rID0gMSxcbiAgcmVmLFxuICB0YWJJbmRleCxcbiAgYXV0b0ZvY3VzLFxuICBvbkNsaWNrLFxuICBvbkZvY3VzLFxuICBvbkZvY3VzQ2FwdHVyZSxcbiAgb25CbHVyLFxuICBvbkJsdXJDYXB0dXJlLFxuICBvbk1vdXNlRW50ZXIsXG4gIG9uTW91c2VMZWF2ZSxcbiAgb25LZXlEb3duLFxuICBvbktleURvd25DYXB0dXJlLFxuICAuLi5zdHlsZVxufTogUHJvcHNXaXRoQ2hpbGRyZW48UHJvcHM+KTogUmVhY3QuUmVhY3ROb2RlIHtcbiAgLy8gV2FybiBpZiBzcGFjaW5nIHZhbHVlcyBhcmUgbm90IGludGVnZXJzIHRvIHByZXZlbnQgZnJhY3Rpb25hbCBsYXlvdXQgZGltZW5zaW9uc1xuICB3YXJuLmlmTm90SW50ZWdlcihzdHlsZS5tYXJnaW4sICdtYXJnaW4nKVxuICB3YXJuLmlmTm90SW50ZWdlcihzdHlsZS5tYXJnaW5YLCAnbWFyZ2luWCcpXG4gIHdhcm4uaWZOb3RJbnRlZ2VyKHN0eWxlLm1hcmdpblksICdtYXJnaW5ZJylcbiAgd2Fybi5pZk5vdEludGVnZXIoc3R5bGUubWFyZ2luVG9wLCAnbWFyZ2luVG9wJylcbiAgd2Fybi5pZk5vdEludGVnZXIoc3R5bGUubWFyZ2luQm90dG9tLCAnbWFyZ2luQm90dG9tJylcbiAgd2Fybi5pZk5vdEludGVnZXIoc3R5bGUubWFyZ2luTGVmdCwgJ21hcmdpbkxlZnQnKVxuICB3YXJuLmlmTm90SW50ZWdlcihzdHlsZS5tYXJnaW5SaWdodCwgJ21hcmdpblJpZ2h0JylcbiAgd2Fybi5pZk5vdEludGVnZXIoc3R5bGUucGFkZGluZywgJ3BhZGRpbmcnKVxuICB3YXJuLmlmTm90SW50ZWdlcihzdHlsZS5wYWRkaW5nWCwgJ3BhZGRpbmdYJylcbiAgd2Fybi5pZk5vdEludGVnZXIoc3R5bGUucGFkZGluZ1ksICdwYWRkaW5nWScpXG4gIHdhcm4uaWZOb3RJbnRlZ2VyKHN0eWxlLnBhZGRpbmdUb3AsICdwYWRkaW5nVG9wJylcbiAgd2Fybi5pZk5vdEludGVnZXIoc3R5bGUucGFkZGluZ0JvdHRvbSwgJ3BhZGRpbmdCb3R0b20nKVxuICB3YXJuLmlmTm90SW50ZWdlcihzdHlsZS5wYWRkaW5nTGVmdCwgJ3BhZGRpbmdMZWZ0JylcbiAgd2Fybi5pZk5vdEludGVnZXIoc3R5bGUucGFkZGluZ1JpZ2h0LCAncGFkZGluZ1JpZ2h0JylcbiAgd2Fybi5pZk5vdEludGVnZXIoc3R5bGUuZ2FwLCAnZ2FwJylcbiAgd2Fybi5pZk5vdEludGVnZXIoc3R5bGUuY29sdW1uR2FwLCAnY29sdW1uR2FwJylcbiAgd2Fybi5pZk5vdEludGVnZXIoc3R5bGUucm93R2FwLCAncm93R2FwJylcblxuICByZXR1cm4gKFxuICAgIDxpbmstYm94XG4gICAgICByZWY9e3JlZn1cbiAgICAgIHRhYkluZGV4PXt0YWJJbmRleH1cbiAgICAgIGF1dG9Gb2N1cz17YXV0b0ZvY3VzfVxuICAgICAgb25DbGljaz17b25DbGlja31cbiAgICAgIG9uRm9jdXM9e29uRm9jdXN9XG4gICAgICBvbkZvY3VzQ2FwdHVyZT17b25Gb2N1c0NhcHR1cmV9XG4gICAgICBvbkJsdXI9e29uQmx1cn1cbiAgICAgIG9uQmx1ckNhcHR1cmU9e29uQmx1ckNhcHR1cmV9XG4gICAgICBvbk1vdXNlRW50ZXI9e29uTW91c2VFbnRlcn1cbiAgICAgIG9uTW91c2VMZWF2ZT17b25Nb3VzZUxlYXZlfVxuICAgICAgb25LZXlEb3duPXtvbktleURvd259XG4gICAgICBvbktleURvd25DYXB0dXJlPXtvbktleURvd25DYXB0dXJlfVxuICAgICAgc3R5bGU9e3tcbiAgICAgICAgZmxleFdyYXAsXG4gICAgICAgIGZsZXhEaXJlY3Rpb24sXG4gICAgICAgIGZsZXhHcm93LFxuICAgICAgICBmbGV4U2hyaW5rLFxuICAgICAgICAuLi5zdHlsZSxcbiAgICAgICAgb3ZlcmZsb3dYOiBzdHlsZS5vdmVyZmxvd1ggPz8gc3R5bGUub3ZlcmZsb3cgPz8gJ3Zpc2libGUnLFxuICAgICAgICBvdmVyZmxvd1k6IHN0eWxlLm92ZXJmbG93WSA/PyBzdHlsZS5vdmVyZmxvdyA/PyAndmlzaWJsZScsXG4gICAgICB9fVxuICAgID5cbiAgICAgIHtjaGlsZHJlbn1cbiAgICA8L2luay1ib3g+XG4gIClcbn1cblxuZXhwb3J0IGRlZmF1bHQgQm94XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPLGdCQUFnQjtBQUN2QixPQUFPQSxLQUFLLElBQUksS0FBS0MsaUJBQWlCLEVBQUUsS0FBS0MsR0FBRyxRQUFRLE9BQU87QUFDL0QsY0FBY0MsTUFBTSxRQUFRLFdBQVc7QUFDdkMsY0FBY0MsVUFBVSxRQUFRLFdBQVc7QUFDM0MsY0FBY0MsVUFBVSxRQUFRLDBCQUEwQjtBQUMxRCxjQUFjQyxVQUFVLFFBQVEsMEJBQTBCO0FBQzFELGNBQWNDLGFBQWEsUUFBUSw2QkFBNkI7QUFDaEUsY0FBY0MsTUFBTSxRQUFRLGNBQWM7QUFDMUMsT0FBTyxLQUFLQyxJQUFJLE1BQU0sWUFBWTtBQUVsQyxPQUFPLEtBQUtDLEtBQUssR0FBR1AsTUFBTSxDQUFDSyxNQUFNLEVBQUUsVUFBVSxDQUFDLEdBQUc7RUFDL0NHLEdBQUcsQ0FBQyxFQUFFVCxHQUFHLENBQUNFLFVBQVUsQ0FBQztFQUNyQjtBQUNGO0FBQ0E7QUFDQTtFQUNFUSxRQUFRLENBQUMsRUFBRSxNQUFNO0VBQ2pCO0FBQ0Y7QUFDQTtBQUNBO0FBQ0E7RUFDRUMsU0FBUyxDQUFDLEVBQUUsT0FBTztFQUNuQjtBQUNGO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7RUFDRUMsT0FBTyxDQUFDLEVBQUUsQ0FBQ0MsS0FBSyxFQUFFVixVQUFVLEVBQUUsR0FBRyxJQUFJO0VBQ3JDVyxPQUFPLENBQUMsRUFBRSxDQUFDRCxLQUFLLEVBQUVULFVBQVUsRUFBRSxHQUFHLElBQUk7RUFDckNXLGNBQWMsQ0FBQyxFQUFFLENBQUNGLEtBQUssRUFBRVQsVUFBVSxFQUFFLEdBQUcsSUFBSTtFQUM1Q1ksTUFBTSxDQUFDLEVBQUUsQ0FBQ0gsS0FBSyxFQUFFVCxVQUFVLEVBQUUsR0FBRyxJQUFJO0VBQ3BDYSxhQUFhLENBQUMsRUFBRSxDQUFDSixLQUFLLEVBQUVULFVBQVUsRUFBRSxHQUFHLElBQUk7RUFDM0NjLFNBQVMsQ0FBQyxFQUFFLENBQUNMLEtBQUssRUFBRVIsYUFBYSxFQUFFLEdBQUcsSUFBSTtFQUMxQ2MsZ0JBQWdCLENBQUMsRUFBRSxDQUFDTixLQUFLLEVBQUVSLGFBQWEsRUFBRSxHQUFHLElBQUk7RUFDakQ7QUFDRjtBQUNBO0FBQ0E7QUFDQTtBQUNBO0VBQ0VlLFlBQVksQ0FBQyxFQUFFLEdBQUcsR0FBRyxJQUFJO0VBQ3pCO0VBQ0FDLFlBQVksQ0FBQyxFQUFFLEdBQUcsR0FBRyxJQUFJO0FBQzNCLENBQUM7O0FBRUQ7QUFDQTtBQUNBO0FBQ0EsU0FBQUMsSUFBQUMsRUFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUFBLElBQUFkLFNBQUE7RUFBQSxJQUFBZSxRQUFBO0VBQUEsSUFBQUMsYUFBQTtFQUFBLElBQUFDLFFBQUE7RUFBQSxJQUFBQyxVQUFBO0VBQUEsSUFBQUMsUUFBQTtFQUFBLElBQUFkLE1BQUE7RUFBQSxJQUFBQyxhQUFBO0VBQUEsSUFBQUwsT0FBQTtFQUFBLElBQUFFLE9BQUE7RUFBQSxJQUFBQyxjQUFBO0VBQUEsSUFBQUcsU0FBQTtFQUFBLElBQUFDLGdCQUFBO0VBQUEsSUFBQUMsWUFBQTtFQUFBLElBQUFDLFlBQUE7RUFBQSxJQUFBWixHQUFBO0VBQUEsSUFBQXNCLEtBQUE7RUFBQSxJQUFBckIsUUFBQTtFQUFBLElBQUFjLENBQUEsUUFBQUQsRUFBQTtJQUFhO01BQUFHLFFBQUEsRUFBQU0sRUFBQTtNQUFBRixRQUFBLEVBQUFHLEVBQUE7TUFBQU4sYUFBQSxFQUFBTyxFQUFBO01BQUFOLFFBQUEsRUFBQU8sRUFBQTtNQUFBTixVQUFBLEVBQUFPLEVBQUE7TUFBQTNCLEdBQUEsRUFBQTRCLEVBQUE7TUFBQTNCLFFBQUEsRUFBQTRCLEVBQUE7TUFBQTNCLFNBQUEsRUFBQTRCLEVBQUE7TUFBQTNCLE9BQUEsRUFBQTRCLEVBQUE7TUFBQTFCLE9BQUEsRUFBQTJCLEdBQUE7TUFBQTFCLGNBQUEsRUFBQTJCLEdBQUE7TUFBQTFCLE1BQUEsRUFBQTJCLEdBQUE7TUFBQTFCLGFBQUEsRUFBQTJCLEdBQUE7TUFBQXhCLFlBQUEsRUFBQXlCLEdBQUE7TUFBQXhCLFlBQUEsRUFBQXlCLEdBQUE7TUFBQTVCLFNBQUEsRUFBQTZCLEdBQUE7TUFBQTVCLGdCQUFBLEVBQUE2QixHQUFBO01BQUEsR0FBQUM7SUFBQSxJQUFBMUIsRUFtQmM7SUFuQmRHLFFBQUEsR0FBQU0sRUFBQTtJQUFBdkIsR0FBQSxHQUFBNEIsRUFBQTtJQUFBM0IsUUFBQSxHQUFBNEIsRUFBQTtJQUFBM0IsU0FBQSxHQUFBNEIsRUFBQTtJQUFBM0IsT0FBQSxHQUFBNEIsRUFBQTtJQUFBMUIsT0FBQSxHQUFBMkIsR0FBQTtJQUFBMUIsY0FBQSxHQUFBMkIsR0FBQTtJQUFBMUIsTUFBQSxHQUFBMkIsR0FBQTtJQUFBMUIsYUFBQSxHQUFBMkIsR0FBQTtJQUFBeEIsWUFBQSxHQUFBeUIsR0FBQTtJQUFBeEIsWUFBQSxHQUFBeUIsR0FBQTtJQUFBNUIsU0FBQSxHQUFBNkIsR0FBQTtJQUFBNUIsZ0JBQUEsR0FBQTZCLEdBQUE7SUFBQWpCLEtBQUEsR0FBQWtCLEdBQUE7SUFFWG5CLFFBQUEsR0FBQUcsRUFBbUIsS0FBbkJpQixTQUFtQixHQUFuQixRQUFtQixHQUFuQmpCLEVBQW1CO0lBQ25CTixhQUFBLEdBQUFPLEVBQXFCLEtBQXJCZ0IsU0FBcUIsR0FBckIsS0FBcUIsR0FBckJoQixFQUFxQjtJQUNyQk4sUUFBQSxHQUFBTyxFQUFZLEtBQVplLFNBQVksR0FBWixDQUFZLEdBQVpmLEVBQVk7SUFDWk4sVUFBQSxHQUFBTyxFQUFjLEtBQWRjLFNBQWMsR0FBZCxDQUFjLEdBQWRkLEVBQWM7SUFnQmQ3QixJQUFJLENBQUE0QyxZQUFhLENBQUNwQixLQUFLLENBQUFxQixNQUFPLEVBQUUsUUFBUSxDQUFDO0lBQ3pDN0MsSUFBSSxDQUFBNEMsWUFBYSxDQUFDcEIsS0FBSyxDQUFBc0IsT0FBUSxFQUFFLFNBQVMsQ0FBQztJQUMzQzlDLElBQUksQ0FBQTRDLFlBQWEsQ0FBQ3BCLEtBQUssQ0FBQXVCLE9BQVEsRUFBRSxTQUFTLENBQUM7SUFDM0MvQyxJQUFJLENBQUE0QyxZQUFhLENBQUNwQixLQUFLLENBQUF3QixTQUFVLEVBQUUsV0FBVyxDQUFDO0lBQy9DaEQsSUFBSSxDQUFBNEMsWUFBYSxDQUFDcEIsS0FBSyxDQUFBeUIsWUFBYSxFQUFFLGNBQWMsQ0FBQztJQUNyRGpELElBQUksQ0FBQTRDLFlBQWEsQ0FBQ3BCLEtBQUssQ0FBQTBCLFVBQVcsRUFBRSxZQUFZLENBQUM7SUFDakRsRCxJQUFJLENBQUE0QyxZQUFhLENBQUNwQixLQUFLLENBQUEyQixXQUFZLEVBQUUsYUFBYSxDQUFDO0lBQ25EbkQsSUFBSSxDQUFBNEMsWUFBYSxDQUFDcEIsS0FBSyxDQUFBNEIsT0FBUSxFQUFFLFNBQVMsQ0FBQztJQUMzQ3BELElBQUksQ0FBQTRDLFlBQWEsQ0FBQ3BCLEtBQUssQ0FBQTZCLFFBQVMsRUFBRSxVQUFVLENBQUM7SUFDN0NyRCxJQUFJLENBQUE0QyxZQUFhLENBQUNwQixLQUFLLENBQUE4QixRQUFTLEVBQUUsVUFBVSxDQUFDO0lBQzdDdEQsSUFBSSxDQUFBNEMsWUFBYSxDQUFDcEIsS0FBSyxDQUFBK0IsVUFBVyxFQUFFLFlBQVksQ0FBQztJQUNqRHZELElBQUksQ0FBQTRDLFlBQWEsQ0FBQ3BCLEtBQUssQ0FBQWdDLGFBQWMsRUFBRSxlQUFlLENBQUM7SUFDdkR4RCxJQUFJLENBQUE0QyxZQUFhLENBQUNwQixLQUFLLENBQUFpQyxXQUFZLEVBQUUsYUFBYSxDQUFDO0lBQ25EekQsSUFBSSxDQUFBNEMsWUFBYSxDQUFDcEIsS0FBSyxDQUFBa0MsWUFBYSxFQUFFLGNBQWMsQ0FBQztJQUNyRDFELElBQUksQ0FBQTRDLFlBQWEsQ0FBQ3BCLEtBQUssQ0FBQW1DLEdBQUksRUFBRSxLQUFLLENBQUM7SUFDbkMzRCxJQUFJLENBQUE0QyxZQUFhLENBQUNwQixLQUFLLENBQUFvQyxTQUFVLEVBQUUsV0FBVyxDQUFDO0lBQy9DNUQsSUFBSSxDQUFBNEMsWUFBYSxDQUFDcEIsS0FBSyxDQUFBcUMsTUFBTyxFQUFFLFFBQVEsQ0FBQztJQUFBNUMsQ0FBQSxNQUFBRCxFQUFBO0lBQUFDLENBQUEsTUFBQWIsU0FBQTtJQUFBYSxDQUFBLE1BQUFFLFFBQUE7SUFBQUYsQ0FBQSxNQUFBRyxhQUFBO0lBQUFILENBQUEsTUFBQUksUUFBQTtJQUFBSixDQUFBLE1BQUFLLFVBQUE7SUFBQUwsQ0FBQSxNQUFBTSxRQUFBO0lBQUFOLENBQUEsTUFBQVIsTUFBQTtJQUFBUSxDQUFBLE1BQUFQLGFBQUE7SUFBQU8sQ0FBQSxNQUFBWixPQUFBO0lBQUFZLENBQUEsT0FBQVYsT0FBQTtJQUFBVSxDQUFBLE9BQUFULGNBQUE7SUFBQVMsQ0FBQSxPQUFBTixTQUFBO0lBQUFNLENBQUEsT0FBQUwsZ0JBQUE7SUFBQUssQ0FBQSxPQUFBSixZQUFBO0lBQUFJLENBQUEsT0FBQUgsWUFBQTtJQUFBRyxDQUFBLE9BQUFmLEdBQUE7SUFBQWUsQ0FBQSxPQUFBTyxLQUFBO0lBQUFQLENBQUEsT0FBQWQsUUFBQTtFQUFBO0lBQUFDLFNBQUEsR0FBQWEsQ0FBQTtJQUFBRSxRQUFBLEdBQUFGLENBQUE7SUFBQUcsYUFBQSxHQUFBSCxDQUFBO0lBQUFJLFFBQUEsR0FBQUosQ0FBQTtJQUFBSyxVQUFBLEdBQUFMLENBQUE7SUFBQU0sUUFBQSxHQUFBTixDQUFBO0lBQUFSLE1BQUEsR0FBQVEsQ0FBQTtJQUFBUCxhQUFBLEdBQUFPLENBQUE7SUFBQVosT0FBQSxHQUFBWSxDQUFBO0lBQUFWLE9BQUEsR0FBQVUsQ0FBQTtJQUFBVCxjQUFBLEdBQUFTLENBQUE7SUFBQU4sU0FBQSxHQUFBTSxDQUFBO0lBQUFMLGdCQUFBLEdBQUFLLENBQUE7SUFBQUosWUFBQSxHQUFBSSxDQUFBO0lBQUFILFlBQUEsR0FBQUcsQ0FBQTtJQUFBZixHQUFBLEdBQUFlLENBQUE7SUFBQU8sS0FBQSxHQUFBUCxDQUFBO0lBQUFkLFFBQUEsR0FBQWMsQ0FBQTtFQUFBO0VBc0J4QixNQUFBUSxFQUFBLEdBQUFELEtBQUssQ0FBQXNDLFNBQTRCLElBQWR0QyxLQUFLLENBQUF1QyxRQUFzQixJQUE5QyxTQUE4QztFQUM5QyxNQUFBckMsRUFBQSxHQUFBRixLQUFLLENBQUF3QyxTQUE0QixJQUFkeEMsS0FBSyxDQUFBdUMsUUFBc0IsSUFBOUMsU0FBOEM7RUFBQSxJQUFBcEMsRUFBQTtFQUFBLElBQUFWLENBQUEsU0FBQUcsYUFBQSxJQUFBSCxDQUFBLFNBQUFJLFFBQUEsSUFBQUosQ0FBQSxTQUFBSyxVQUFBLElBQUFMLENBQUEsU0FBQU0sUUFBQSxJQUFBTixDQUFBLFNBQUFPLEtBQUEsSUFBQVAsQ0FBQSxTQUFBUSxFQUFBLElBQUFSLENBQUEsU0FBQVMsRUFBQTtJQVBwREMsRUFBQTtNQUFBSixRQUFBO01BQUFILGFBQUE7TUFBQUMsUUFBQTtNQUFBQyxVQUFBO01BQUEsR0FLRkUsS0FBSztNQUFBc0MsU0FBQSxFQUNHckMsRUFBOEM7TUFBQXVDLFNBQUEsRUFDOUN0QztJQUNiLENBQUM7SUFBQVQsQ0FBQSxPQUFBRyxhQUFBO0lBQUFILENBQUEsT0FBQUksUUFBQTtJQUFBSixDQUFBLE9BQUFLLFVBQUE7SUFBQUwsQ0FBQSxPQUFBTSxRQUFBO0lBQUFOLENBQUEsT0FBQU8sS0FBQTtJQUFBUCxDQUFBLE9BQUFRLEVBQUE7SUFBQVIsQ0FBQSxPQUFBUyxFQUFBO0lBQUFULENBQUEsT0FBQVUsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQVYsQ0FBQTtFQUFBO0VBQUEsSUFBQVcsRUFBQTtFQUFBLElBQUFYLENBQUEsU0FBQWIsU0FBQSxJQUFBYSxDQUFBLFNBQUFFLFFBQUEsSUFBQUYsQ0FBQSxTQUFBUixNQUFBLElBQUFRLENBQUEsU0FBQVAsYUFBQSxJQUFBTyxDQUFBLFNBQUFaLE9BQUEsSUFBQVksQ0FBQSxTQUFBVixPQUFBLElBQUFVLENBQUEsU0FBQVQsY0FBQSxJQUFBUyxDQUFBLFNBQUFOLFNBQUEsSUFBQU0sQ0FBQSxTQUFBTCxnQkFBQSxJQUFBSyxDQUFBLFNBQUFKLFlBQUEsSUFBQUksQ0FBQSxTQUFBSCxZQUFBLElBQUFHLENBQUEsU0FBQWYsR0FBQSxJQUFBZSxDQUFBLFNBQUFVLEVBQUEsSUFBQVYsQ0FBQSxTQUFBZCxRQUFBO0lBckJIeUIsRUFBQSxXQXdCVSxDQXZCSDFCLEdBQUcsQ0FBSEEsSUFBRSxDQUFDLENBQ0VDLFFBQVEsQ0FBUkEsU0FBTyxDQUFDLENBQ1BDLFNBQVMsQ0FBVEEsVUFBUSxDQUFDLENBQ1hDLE9BQU8sQ0FBUEEsUUFBTSxDQUFDLENBQ1BFLE9BQU8sQ0FBUEEsUUFBTSxDQUFDLENBQ0FDLGNBQWMsQ0FBZEEsZUFBYSxDQUFDLENBQ3RCQyxNQUFNLENBQU5BLE9BQUssQ0FBQyxDQUNDQyxhQUFhLENBQWJBLGNBQVksQ0FBQyxDQUNkRyxZQUFZLENBQVpBLGFBQVcsQ0FBQyxDQUNaQyxZQUFZLENBQVpBLGFBQVcsQ0FBQyxDQUNmSCxTQUFTLENBQVRBLFVBQVEsQ0FBQyxDQUNGQyxnQkFBZ0IsQ0FBaEJBLGlCQUFlLENBQUMsQ0FDM0IsS0FRTixDQVJNLENBQUFlLEVBUVAsQ0FBQyxDQUVBUixTQUFPLENBQ1YsRUF4QkEsT0F3QlU7SUFBQUYsQ0FBQSxPQUFBYixTQUFBO0lBQUFhLENBQUEsT0FBQUUsUUFBQTtJQUFBRixDQUFBLE9BQUFSLE1BQUE7SUFBQVEsQ0FBQSxPQUFBUCxhQUFBO0lBQUFPLENBQUEsT0FBQVosT0FBQTtJQUFBWSxDQUFBLE9BQUFWLE9BQUE7SUFBQVUsQ0FBQSxPQUFBVCxjQUFBO0lBQUFTLENBQUEsT0FBQU4sU0FBQTtJQUFBTSxDQUFBLE9BQUFMLGdCQUFBO0lBQUFLLENBQUEsT0FBQUosWUFBQTtJQUFBSSxDQUFBLE9BQUFILFlBQUE7SUFBQUcsQ0FBQSxPQUFBZixHQUFBO0lBQUFlLENBQUEsT0FBQVUsRUFBQTtJQUFBVixDQUFBLE9BQUFkLFFBQUE7SUFBQWMsQ0FBQSxPQUFBVyxFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBWCxDQUFBO0VBQUE7RUFBQSxPQXhCVlcsRUF3QlU7QUFBQTtBQUlkLGVBQWViLEdBQUciLCJpZ25vcmVMaXN0IjpbXX0=
\ No newline at end of file
diff --git a/src/ink/components/Button.tsx b/src/ink/components/Button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8dc35f06cb6e1eeda93d1d3d4f298120b75ccfa1
--- /dev/null
+++ b/src/ink/components/Button.tsx
@@ -0,0 +1,192 @@
+import { c as _c } from "react/compiler-runtime";
+import React, { type Ref, useCallback, useEffect, useRef, useState } from 'react';
+import type { Except } from 'type-fest';
+import type { DOMElement } from '../dom.js';
+import type { ClickEvent } from '../events/click-event.js';
+import type { FocusEvent } from '../events/focus-event.js';
+import type { KeyboardEvent } from '../events/keyboard-event.js';
+import type { Styles } from '../styles.js';
+import Box from './Box.js';
+type ButtonState = {
+ focused: boolean;
+ hovered: boolean;
+ active: boolean;
+};
+export type Props = Except
& {
+ ref?: Ref;
+ /**
+ * Called when the button is activated via Enter, Space, or click.
+ */
+ onAction: () => void;
+ /**
+ * Tab order index. Defaults to 0 (in tab order).
+ * Set to -1 for programmatically focusable only.
+ */
+ tabIndex?: number;
+ /**
+ * Focus this button when it mounts.
+ */
+ autoFocus?: boolean;
+ /**
+ * Render prop receiving the interactive state. Use this to
+ * style children based on focus/hover/active — Button itself
+ * is intentionally unstyled.
+ *
+ * If not provided, children render as-is (no state-dependent styling).
+ */
+ children: ((state: ButtonState) => React.ReactNode) | React.ReactNode;
+};
+function Button(t0) {
+ const $ = _c(30);
+ let autoFocus;
+ let children;
+ let onAction;
+ let ref;
+ let style;
+ let t1;
+ if ($[0] !== t0) {
+ ({
+ onAction,
+ tabIndex: t1,
+ autoFocus,
+ children,
+ ref,
+ ...style
+ } = t0);
+ $[0] = t0;
+ $[1] = autoFocus;
+ $[2] = children;
+ $[3] = onAction;
+ $[4] = ref;
+ $[5] = style;
+ $[6] = t1;
+ } else {
+ autoFocus = $[1];
+ children = $[2];
+ onAction = $[3];
+ ref = $[4];
+ style = $[5];
+ t1 = $[6];
+ }
+ const tabIndex = t1 === undefined ? 0 : t1;
+ const [isFocused, setIsFocused] = useState(false);
+ const [isHovered, setIsHovered] = useState(false);
+ const [isActive, setIsActive] = useState(false);
+ const activeTimer = useRef(null);
+ let t2;
+ let t3;
+ if ($[7] === Symbol.for("react.memo_cache_sentinel")) {
+ t2 = () => () => {
+ if (activeTimer.current) {
+ clearTimeout(activeTimer.current);
+ }
+ };
+ t3 = [];
+ $[7] = t2;
+ $[8] = t3;
+ } else {
+ t2 = $[7];
+ t3 = $[8];
+ }
+ useEffect(t2, t3);
+ let t4;
+ if ($[9] !== onAction) {
+ t4 = e => {
+ if (e.key === "return" || e.key === " ") {
+ e.preventDefault();
+ setIsActive(true);
+ onAction();
+ if (activeTimer.current) {
+ clearTimeout(activeTimer.current);
+ }
+ activeTimer.current = setTimeout(_temp, 100, setIsActive);
+ }
+ };
+ $[9] = onAction;
+ $[10] = t4;
+ } else {
+ t4 = $[10];
+ }
+ const handleKeyDown = t4;
+ let t5;
+ if ($[11] !== onAction) {
+ t5 = _e => {
+ onAction();
+ };
+ $[11] = onAction;
+ $[12] = t5;
+ } else {
+ t5 = $[12];
+ }
+ const handleClick = t5;
+ let t6;
+ if ($[13] === Symbol.for("react.memo_cache_sentinel")) {
+ t6 = _e_0 => setIsFocused(true);
+ $[13] = t6;
+ } else {
+ t6 = $[13];
+ }
+ const handleFocus = t6;
+ let t7;
+ if ($[14] === Symbol.for("react.memo_cache_sentinel")) {
+ t7 = _e_1 => setIsFocused(false);
+ $[14] = t7;
+ } else {
+ t7 = $[14];
+ }
+ const handleBlur = t7;
+ let t8;
+ if ($[15] === Symbol.for("react.memo_cache_sentinel")) {
+ t8 = () => setIsHovered(true);
+ $[15] = t8;
+ } else {
+ t8 = $[15];
+ }
+ const handleMouseEnter = t8;
+ let t9;
+ if ($[16] === Symbol.for("react.memo_cache_sentinel")) {
+ t9 = () => setIsHovered(false);
+ $[16] = t9;
+ } else {
+ t9 = $[16];
+ }
+ const handleMouseLeave = t9;
+ let t10;
+ if ($[17] !== children || $[18] !== isActive || $[19] !== isFocused || $[20] !== isHovered) {
+ const state = {
+ focused: isFocused,
+ hovered: isHovered,
+ active: isActive
+ };
+ t10 = typeof children === "function" ? children(state) : children;
+ $[17] = children;
+ $[18] = isActive;
+ $[19] = isFocused;
+ $[20] = isHovered;
+ $[21] = t10;
+ } else {
+ t10 = $[21];
+ }
+ const content = t10;
+ let t11;
+ if ($[22] !== autoFocus || $[23] !== content || $[24] !== handleClick || $[25] !== handleKeyDown || $[26] !== ref || $[27] !== style || $[28] !== tabIndex) {
+ t11 = {content};
+ $[22] = autoFocus;
+ $[23] = content;
+ $[24] = handleClick;
+ $[25] = handleKeyDown;
+ $[26] = ref;
+ $[27] = style;
+ $[28] = tabIndex;
+ $[29] = t11;
+ } else {
+ t11 = $[29];
+ }
+ return t11;
+}
+function _temp(setter) {
+ return setter(false);
+}
+export default Button;
+export type { ButtonState };
+//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlJlZiIsInVzZUNhbGxiYWNrIiwidXNlRWZmZWN0IiwidXNlUmVmIiwidXNlU3RhdGUiLCJFeGNlcHQiLCJET01FbGVtZW50IiwiQ2xpY2tFdmVudCIsIkZvY3VzRXZlbnQiLCJLZXlib2FyZEV2ZW50IiwiU3R5bGVzIiwiQm94IiwiQnV0dG9uU3RhdGUiLCJmb2N1c2VkIiwiaG92ZXJlZCIsImFjdGl2ZSIsIlByb3BzIiwicmVmIiwib25BY3Rpb24iLCJ0YWJJbmRleCIsImF1dG9Gb2N1cyIsImNoaWxkcmVuIiwic3RhdGUiLCJSZWFjdE5vZGUiLCJCdXR0b24iLCJ0MCIsIiQiLCJfYyIsInN0eWxlIiwidDEiLCJ1bmRlZmluZWQiLCJpc0ZvY3VzZWQiLCJzZXRJc0ZvY3VzZWQiLCJpc0hvdmVyZWQiLCJzZXRJc0hvdmVyZWQiLCJpc0FjdGl2ZSIsInNldElzQWN0aXZlIiwiYWN0aXZlVGltZXIiLCJ0MiIsInQzIiwiU3ltYm9sIiwiZm9yIiwiY3VycmVudCIsImNsZWFyVGltZW91dCIsInQ0IiwiZSIsImtleSIsInByZXZlbnREZWZhdWx0Iiwic2V0VGltZW91dCIsIl90ZW1wIiwiaGFuZGxlS2V5RG93biIsInQ1IiwiX2UiLCJoYW5kbGVDbGljayIsInQ2IiwiX2VfMCIsImhhbmRsZUZvY3VzIiwidDciLCJfZV8xIiwiaGFuZGxlQmx1ciIsInQ4IiwiaGFuZGxlTW91c2VFbnRlciIsInQ5IiwiaGFuZGxlTW91c2VMZWF2ZSIsInQxMCIsImNvbnRlbnQiLCJ0MTEiLCJzZXR0ZXIiXSwic291cmNlcyI6WyJCdXR0b24udHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCBSZWFjdCwge1xuICB0eXBlIFJlZixcbiAgdXNlQ2FsbGJhY2ssXG4gIHVzZUVmZmVjdCxcbiAgdXNlUmVmLFxuICB1c2VTdGF0ZSxcbn0gZnJvbSAncmVhY3QnXG5pbXBvcnQgdHlwZSB7IEV4Y2VwdCB9IGZyb20gJ3R5cGUtZmVzdCdcbmltcG9ydCB0eXBlIHsgRE9NRWxlbWVudCB9IGZyb20gJy4uL2RvbS5qcydcbmltcG9ydCB0eXBlIHsgQ2xpY2tFdmVudCB9IGZyb20gJy4uL2V2ZW50cy9jbGljay1ldmVudC5qcydcbmltcG9ydCB0eXBlIHsgRm9jdXNFdmVudCB9IGZyb20gJy4uL2V2ZW50cy9mb2N1cy1ldmVudC5qcydcbmltcG9ydCB0eXBlIHsgS2V5Ym9hcmRFdmVudCB9IGZyb20gJy4uL2V2ZW50cy9rZXlib2FyZC1ldmVudC5qcydcbmltcG9ydCB0eXBlIHsgU3R5bGVzIH0gZnJvbSAnLi4vc3R5bGVzLmpzJ1xuaW1wb3J0IEJveCBmcm9tICcuL0JveC5qcydcblxudHlwZSBCdXR0b25TdGF0ZSA9IHtcbiAgZm9jdXNlZDogYm9vbGVhblxuICBob3ZlcmVkOiBib29sZWFuXG4gIGFjdGl2ZTogYm9vbGVhblxufVxuXG5leHBvcnQgdHlwZSBQcm9wcyA9IEV4Y2VwdDxTdHlsZXMsICd0ZXh0V3JhcCc+ICYge1xuICByZWY/OiBSZWY8RE9NRWxlbWVudD5cbiAgLyoqXG4gICAqIENhbGxlZCB3aGVuIHRoZSBidXR0b24gaXMgYWN0aXZhdGVkIHZpYSBFbnRlciwgU3BhY2UsIG9yIGNsaWNrLlxuICAgKi9cbiAgb25BY3Rpb246ICgpID0+IHZvaWRcbiAgLyoqXG4gICAqIFRhYiBvcmRlciBpbmRleC4gRGVmYXVsdHMgdG8gMCAoaW4gdGFiIG9yZGVyKS5cbiAgICogU2V0IHRvIC0xIGZvciBwcm9ncmFtbWF0aWNhbGx5IGZvY3VzYWJsZSBvbmx5LlxuICAgKi9cbiAgdGFiSW5kZXg/OiBudW1iZXJcbiAgLyoqXG4gICAqIEZvY3VzIHRoaXMgYnV0dG9uIHdoZW4gaXQgbW91bnRzLlxuICAgKi9cbiAgYXV0b0ZvY3VzPzogYm9vbGVhblxuICAvKipcbiAgICogUmVuZGVyIHByb3AgcmVjZWl2aW5nIHRoZSBpbnRlcmFjdGl2ZSBzdGF0ZS4gVXNlIHRoaXMgdG9cbiAgICogc3R5bGUgY2hpbGRyZW4gYmFzZWQgb24gZm9jdXMvaG92ZXIvYWN0aXZlIOKAlCBCdXR0b24gaXRzZWxmXG4gICAqIGlzIGludGVudGlvbmFsbHkgdW5zdHlsZWQuXG4gICAqXG4gICAqIElmIG5vdCBwcm92aWRlZCwgY2hpbGRyZW4gcmVuZGVyIGFzLWlzIChubyBzdGF0ZS1kZXBlbmRlbnQgc3R5bGluZykuXG4gICAqL1xuICBjaGlsZHJlbjogKChzdGF0ZTogQnV0dG9uU3RhdGUpID0+IFJlYWN0LlJlYWN0Tm9kZSkgfCBSZWFjdC5SZWFjdE5vZGVcbn1cblxuZnVuY3Rpb24gQnV0dG9uKHtcbiAgb25BY3Rpb24sXG4gIHRhYkluZGV4ID0gMCxcbiAgYXV0b0ZvY3VzLFxuICBjaGlsZHJlbixcbiAgcmVmLFxuICAuLi5zdHlsZVxufTogUHJvcHMpOiBSZWFjdC5SZWFjdE5vZGUge1xuICBjb25zdCBbaXNGb2N1c2VkLCBzZXRJc0ZvY3VzZWRdID0gdXNlU3RhdGUoZmFsc2UpXG4gIGNvbnN0IFtpc0hvdmVyZWQsIHNldElzSG92ZXJlZF0gPSB1c2VTdGF0ZShmYWxzZSlcbiAgY29uc3QgW2lzQWN0aXZlLCBzZXRJc0FjdGl2ZV0gPSB1c2VTdGF0ZShmYWxzZSlcblxuICBjb25zdCBhY3RpdmVUaW1lciA9IHVzZVJlZjxSZXR1cm5UeXBlPHR5cGVvZiBzZXRUaW1lb3V0PiB8IG51bGw+KG51bGwpXG5cbiAgdXNlRWZmZWN0KCgpID0+IHtcbiAgICByZXR1cm4gKCkgPT4ge1xuICAgICAgaWYgKGFjdGl2ZVRpbWVyLmN1cnJlbnQpIGNsZWFyVGltZW91dChhY3RpdmVUaW1lci5jdXJyZW50KVxuICAgIH1cbiAgfSwgW10pXG5cbiAgY29uc3QgaGFuZGxlS2V5RG93biA9IHVzZUNhbGxiYWNrKFxuICAgIChlOiBLZXlib2FyZEV2ZW50KSA9PiB7XG4gICAgICBpZiAoZS5rZXkgPT09ICdyZXR1cm4nIHx8IGUua2V5ID09PSAnICcpIHtcbiAgICAgICAgZS5wcmV2ZW50RGVmYXVsdCgpXG4gICAgICAgIHNldElzQWN0aXZlKHRydWUpXG4gICAgICAgIG9uQWN0aW9uKClcbiAgICAgICAgaWYgKGFjdGl2ZVRpbWVyLmN1cnJlbnQpIGNsZWFyVGltZW91dChhY3RpdmVUaW1lci5jdXJyZW50KVxuICAgICAgICBhY3RpdmVUaW1lci5jdXJyZW50ID0gc2V0VGltZW91dChcbiAgICAgICAgICBzZXR0ZXIgPT4gc2V0dGVyKGZhbHNlKSxcbiAgICAgICAgICAxMDAsXG4gICAgICAgICAgc2V0SXNBY3RpdmUsXG4gICAgICAgIClcbiAgICAgIH1cbiAgICB9LFxuICAgIFtvbkFjdGlvbl0sXG4gIClcblxuICBjb25zdCBoYW5kbGVDbGljayA9IHVzZUNhbGxiYWNrKFxuICAgIChfZTogQ2xpY2tFdmVudCkgPT4ge1xuICAgICAgb25BY3Rpb24oKVxuICAgIH0sXG4gICAgW29uQWN0aW9uXSxcbiAgKVxuXG4gIGNvbnN0IGhhbmRsZUZvY3VzID0gdXNlQ2FsbGJhY2soKF9lOiBGb2N1c0V2ZW50KSA9PiBzZXRJc0ZvY3VzZWQodHJ1ZSksIFtdKVxuICBjb25zdCBoYW5kbGVCbHVyID0gdXNlQ2FsbGJhY2soKF9lOiBGb2N1c0V2ZW50KSA9PiBzZXRJc0ZvY3VzZWQoZmFsc2UpLCBbXSlcbiAgY29uc3QgaGFuZGxlTW91c2VFbnRlciA9IHVzZUNhbGxiYWNrKCgpID0+IHNldElzSG92ZXJlZCh0cnVlKSwgW10pXG4gIGNvbnN0IGhhbmRsZU1vdXNlTGVhdmUgPSB1c2VDYWxsYmFjaygoKSA9PiBzZXRJc0hvdmVyZWQoZmFsc2UpLCBbXSlcblxuICBjb25zdCBzdGF0ZTogQnV0dG9uU3RhdGUgPSB7XG4gICAgZm9jdXNlZDogaXNGb2N1c2VkLFxuICAgIGhvdmVyZWQ6IGlzSG92ZXJlZCxcbiAgICBhY3RpdmU6IGlzQWN0aXZlLFxuICB9XG4gIGNvbnN0IGNvbnRlbnQgPSB0eXBlb2YgY2hpbGRyZW4gPT09ICdmdW5jdGlvbicgPyBjaGlsZHJlbihzdGF0ZSkgOiBjaGlsZHJlblxuXG4gIHJldHVybiAoXG4gICAgPEJveFxuICAgICAgcmVmPXtyZWZ9XG4gICAgICB0YWJJbmRleD17dGFiSW5kZXh9XG4gICAgICBhdXRvRm9jdXM9e2F1dG9Gb2N1c31cbiAgICAgIG9uS2V5RG93bj17aGFuZGxlS2V5RG93bn1cbiAgICAgIG9uQ2xpY2s9e2hhbmRsZUNsaWNrfVxuICAgICAgb25Gb2N1cz17aGFuZGxlRm9jdXN9XG4gICAgICBvbkJsdXI9e2hhbmRsZUJsdXJ9XG4gICAgICBvbk1vdXNlRW50ZXI9e2hhbmRsZU1vdXNlRW50ZXJ9XG4gICAgICBvbk1vdXNlTGVhdmU9e2hhbmRsZU1vdXNlTGVhdmV9XG4gICAgICB7Li4uc3R5bGV9XG4gICAgPlxuICAgICAge2NvbnRlbnR9XG4gICAgPC9Cb3g+XG4gIClcbn1cblxuZXhwb3J0IGRlZmF1bHQgQnV0dG9uXG5leHBvcnQgdHlwZSB7IEJ1dHRvblN0YXRlIH1cbiJdLCJtYXBwaW5ncyI6IjtBQUFBLE9BQU9BLEtBQUssSUFDVixLQUFLQyxHQUFHLEVBQ1JDLFdBQVcsRUFDWEMsU0FBUyxFQUNUQyxNQUFNLEVBQ05DLFFBQVEsUUFDSCxPQUFPO0FBQ2QsY0FBY0MsTUFBTSxRQUFRLFdBQVc7QUFDdkMsY0FBY0MsVUFBVSxRQUFRLFdBQVc7QUFDM0MsY0FBY0MsVUFBVSxRQUFRLDBCQUEwQjtBQUMxRCxjQUFjQyxVQUFVLFFBQVEsMEJBQTBCO0FBQzFELGNBQWNDLGFBQWEsUUFBUSw2QkFBNkI7QUFDaEUsY0FBY0MsTUFBTSxRQUFRLGNBQWM7QUFDMUMsT0FBT0MsR0FBRyxNQUFNLFVBQVU7QUFFMUIsS0FBS0MsV0FBVyxHQUFHO0VBQ2pCQyxPQUFPLEVBQUUsT0FBTztFQUNoQkMsT0FBTyxFQUFFLE9BQU87RUFDaEJDLE1BQU0sRUFBRSxPQUFPO0FBQ2pCLENBQUM7QUFFRCxPQUFPLEtBQUtDLEtBQUssR0FBR1gsTUFBTSxDQUFDSyxNQUFNLEVBQUUsVUFBVSxDQUFDLEdBQUc7RUFDL0NPLEdBQUcsQ0FBQyxFQUFFakIsR0FBRyxDQUFDTSxVQUFVLENBQUM7RUFDckI7QUFDRjtBQUNBO0VBQ0VZLFFBQVEsRUFBRSxHQUFHLEdBQUcsSUFBSTtFQUNwQjtBQUNGO0FBQ0E7QUFDQTtFQUNFQyxRQUFRLENBQUMsRUFBRSxNQUFNO0VBQ2pCO0FBQ0Y7QUFDQTtFQUNFQyxTQUFTLENBQUMsRUFBRSxPQUFPO0VBQ25CO0FBQ0Y7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0VBQ0VDLFFBQVEsRUFBRSxDQUFDLENBQUNDLEtBQUssRUFBRVYsV0FBVyxFQUFFLEdBQUdiLEtBQUssQ0FBQ3dCLFNBQVMsQ0FBQyxHQUFHeEIsS0FBSyxDQUFDd0IsU0FBUztBQUN2RSxDQUFDO0FBRUQsU0FBQUMsT0FBQUMsRUFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUFBLElBQUFQLFNBQUE7RUFBQSxJQUFBQyxRQUFBO0VBQUEsSUFBQUgsUUFBQTtFQUFBLElBQUFELEdBQUE7RUFBQSxJQUFBVyxLQUFBO0VBQUEsSUFBQUMsRUFBQTtFQUFBLElBQUFILENBQUEsUUFBQUQsRUFBQTtJQUFnQjtNQUFBUCxRQUFBO01BQUFDLFFBQUEsRUFBQVUsRUFBQTtNQUFBVCxTQUFBO01BQUFDLFFBQUE7TUFBQUosR0FBQTtNQUFBLEdBQUFXO0lBQUEsSUFBQUgsRUFPUjtJQUFBQyxDQUFBLE1BQUFELEVBQUE7SUFBQUMsQ0FBQSxNQUFBTixTQUFBO0lBQUFNLENBQUEsTUFBQUwsUUFBQTtJQUFBSyxDQUFBLE1BQUFSLFFBQUE7SUFBQVEsQ0FBQSxNQUFBVCxHQUFBO0lBQUFTLENBQUEsTUFBQUUsS0FBQTtJQUFBRixDQUFBLE1BQUFHLEVBQUE7RUFBQTtJQUFBVCxTQUFBLEdBQUFNLENBQUE7SUFBQUwsUUFBQSxHQUFBSyxDQUFBO0lBQUFSLFFBQUEsR0FBQVEsQ0FBQTtJQUFBVCxHQUFBLEdBQUFTLENBQUE7SUFBQUUsS0FBQSxHQUFBRixDQUFBO0lBQUFHLEVBQUEsR0FBQUgsQ0FBQTtFQUFBO0VBTE4sTUFBQVAsUUFBQSxHQUFBVSxFQUFZLEtBQVpDLFNBQVksR0FBWixDQUFZLEdBQVpELEVBQVk7RUFNWixPQUFBRSxTQUFBLEVBQUFDLFlBQUEsSUFBa0M1QixRQUFRLENBQUMsS0FBSyxDQUFDO0VBQ2pELE9BQUE2QixTQUFBLEVBQUFDLFlBQUEsSUFBa0M5QixRQUFRLENBQUMsS0FBSyxDQUFDO0VBQ2pELE9BQUErQixRQUFBLEVBQUFDLFdBQUEsSUFBZ0NoQyxRQUFRLENBQUMsS0FBSyxDQUFDO0VBRS9DLE1BQUFpQyxXQUFBLEdBQW9CbEMsTUFBTSxDQUF1QyxJQUFJLENBQUM7RUFBQSxJQUFBbUMsRUFBQTtFQUFBLElBQUFDLEVBQUE7RUFBQSxJQUFBYixDQUFBLFFBQUFjLE1BQUEsQ0FBQUMsR0FBQTtJQUU1REgsRUFBQSxHQUFBQSxDQUFBLEtBQ0Q7TUFDTCxJQUFJRCxXQUFXLENBQUFLLE9BQVE7UUFBRUMsWUFBWSxDQUFDTixXQUFXLENBQUFLLE9BQVEsQ0FBQztNQUFBO0lBQUEsQ0FFN0Q7SUFBRUgsRUFBQSxLQUFFO0lBQUFiLENBQUEsTUFBQVksRUFBQTtJQUFBWixDQUFBLE1BQUFhLEVBQUE7RUFBQTtJQUFBRCxFQUFBLEdBQUFaLENBQUE7SUFBQWEsRUFBQSxHQUFBYixDQUFBO0VBQUE7RUFKTHhCLFNBQVMsQ0FBQ29DLEVBSVQsRUFBRUMsRUFBRSxDQUFDO0VBQUEsSUFBQUssRUFBQTtFQUFBLElBQUFsQixDQUFBLFFBQUFSLFFBQUE7SUFHSjBCLEVBQUEsR0FBQUMsQ0FBQTtNQUNFLElBQUlBLENBQUMsQ0FBQUMsR0FBSSxLQUFLLFFBQXlCLElBQWJELENBQUMsQ0FBQUMsR0FBSSxLQUFLLEdBQUc7UUFDckNELENBQUMsQ0FBQUUsY0FBZSxDQUFDLENBQUM7UUFDbEJYLFdBQVcsQ0FBQyxJQUFJLENBQUM7UUFDakJsQixRQUFRLENBQUMsQ0FBQztRQUNWLElBQUltQixXQUFXLENBQUFLLE9BQVE7VUFBRUMsWUFBWSxDQUFDTixXQUFXLENBQUFLLE9BQVEsQ0FBQztRQUFBO1FBQzFETCxXQUFXLENBQUFLLE9BQUEsR0FBV00sVUFBVSxDQUM5QkMsS0FBdUIsRUFDdkIsR0FBRyxFQUNIYixXQUNGLENBSm1CO01BQUE7SUFLcEIsQ0FDRjtJQUFBVixDQUFBLE1BQUFSLFFBQUE7SUFBQVEsQ0FBQSxPQUFBa0IsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQWxCLENBQUE7RUFBQTtFQWJILE1BQUF3QixhQUFBLEdBQXNCTixFQWVyQjtFQUFBLElBQUFPLEVBQUE7RUFBQSxJQUFBekIsQ0FBQSxTQUFBUixRQUFBO0lBR0NpQyxFQUFBLEdBQUFDLEVBQUE7TUFDRWxDLFFBQVEsQ0FBQyxDQUFDO0lBQUEsQ0FDWDtJQUFBUSxDQUFBLE9BQUFSLFFBQUE7SUFBQVEsQ0FBQSxPQUFBeUIsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQXpCLENBQUE7RUFBQTtFQUhILE1BQUEyQixXQUFBLEdBQW9CRixFQUtuQjtFQUFBLElBQUFHLEVBQUE7RUFBQSxJQUFBNUIsQ0FBQSxTQUFBYyxNQUFBLENBQUFDLEdBQUE7SUFFK0JhLEVBQUEsR0FBQUMsSUFBQSxJQUFvQnZCLFlBQVksQ0FBQyxJQUFJLENBQUM7SUFBQU4sQ0FBQSxPQUFBNEIsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQTVCLENBQUE7RUFBQTtFQUF0RSxNQUFBOEIsV0FBQSxHQUFvQkYsRUFBdUQ7RUFBQSxJQUFBRyxFQUFBO0VBQUEsSUFBQS9CLENBQUEsU0FBQWMsTUFBQSxDQUFBQyxHQUFBO0lBQzVDZ0IsRUFBQSxHQUFBQyxJQUFBLElBQW9CMUIsWUFBWSxDQUFDLEtBQUssQ0FBQztJQUFBTixDQUFBLE9BQUErQixFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBL0IsQ0FBQTtFQUFBO0VBQXRFLE1BQUFpQyxVQUFBLEdBQW1CRixFQUF3RDtFQUFBLElBQUFHLEVBQUE7RUFBQSxJQUFBbEMsQ0FBQSxTQUFBYyxNQUFBLENBQUFDLEdBQUE7SUFDdENtQixFQUFBLEdBQUFBLENBQUEsS0FBTTFCLFlBQVksQ0FBQyxJQUFJLENBQUM7SUFBQVIsQ0FBQSxPQUFBa0MsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQWxDLENBQUE7RUFBQTtFQUE3RCxNQUFBbUMsZ0JBQUEsR0FBeUJELEVBQXlDO0VBQUEsSUFBQUUsRUFBQTtFQUFBLElBQUFwQyxDQUFBLFNBQUFjLE1BQUEsQ0FBQUMsR0FBQTtJQUM3QnFCLEVBQUEsR0FBQUEsQ0FBQSxLQUFNNUIsWUFBWSxDQUFDLEtBQUssQ0FBQztJQUFBUixDQUFBLE9BQUFvQyxFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBcEMsQ0FBQTtFQUFBO0VBQTlELE1BQUFxQyxnQkFBQSxHQUF5QkQsRUFBMEM7RUFBQSxJQUFBRSxHQUFBO0VBQUEsSUFBQXRDLENBQUEsU0FBQUwsUUFBQSxJQUFBSyxDQUFBLFNBQUFTLFFBQUEsSUFBQVQsQ0FBQSxTQUFBSyxTQUFBLElBQUFMLENBQUEsU0FBQU8sU0FBQTtJQUVuRSxNQUFBWCxLQUFBLEdBQTJCO01BQUFULE9BQUEsRUFDaEJrQixTQUFTO01BQUFqQixPQUFBLEVBQ1RtQixTQUFTO01BQUFsQixNQUFBLEVBQ1ZvQjtJQUNWLENBQUM7SUFDZTZCLEdBQUEsVUFBTzNDLFFBQVEsS0FBSyxVQUF1QyxHQUExQkEsUUFBUSxDQUFDQyxLQUFnQixDQUFDLEdBQTNERCxRQUEyRDtJQUFBSyxDQUFBLE9BQUFMLFFBQUE7SUFBQUssQ0FBQSxPQUFBUyxRQUFBO0lBQUFULENBQUEsT0FBQUssU0FBQTtJQUFBTCxDQUFBLE9BQUFPLFNBQUE7SUFBQVAsQ0FBQSxPQUFBc0MsR0FBQTtFQUFBO0lBQUFBLEdBQUEsR0FBQXRDLENBQUE7RUFBQTtFQUEzRSxNQUFBdUMsT0FBQSxHQUFnQkQsR0FBMkQ7RUFBQSxJQUFBRSxHQUFBO0VBQUEsSUFBQXhDLENBQUEsU0FBQU4sU0FBQSxJQUFBTSxDQUFBLFNBQUF1QyxPQUFBLElBQUF2QyxDQUFBLFNBQUEyQixXQUFBLElBQUEzQixDQUFBLFNBQUF3QixhQUFBLElBQUF4QixDQUFBLFNBQUFULEdBQUEsSUFBQVMsQ0FBQSxTQUFBRSxLQUFBLElBQUFGLENBQUEsU0FBQVAsUUFBQTtJQUd6RStDLEdBQUEsSUFBQyxHQUFHLENBQ0dqRCxHQUFHLENBQUhBLElBQUUsQ0FBQyxDQUNFRSxRQUFRLENBQVJBLFNBQU8sQ0FBQyxDQUNQQyxTQUFTLENBQVRBLFVBQVEsQ0FBQyxDQUNUOEIsU0FBYSxDQUFiQSxjQUFZLENBQUMsQ0FDZkcsT0FBVyxDQUFYQSxZQUFVLENBQUMsQ0FDWEcsT0FBVyxDQUFYQSxZQUFVLENBQUMsQ0FDWkcsTUFBVSxDQUFWQSxXQUFTLENBQUMsQ0FDSkUsWUFBZ0IsQ0FBaEJBLGlCQUFlLENBQUMsQ0FDaEJFLFlBQWdCLENBQWhCQSxpQkFBZSxDQUFDLEtBQzFCbkMsS0FBSyxFQUVScUMsUUFBTSxDQUNULEVBYkMsR0FBRyxDQWFFO0lBQUF2QyxDQUFBLE9BQUFOLFNBQUE7SUFBQU0sQ0FBQSxPQUFBdUMsT0FBQTtJQUFBdkMsQ0FBQSxPQUFBMkIsV0FBQTtJQUFBM0IsQ0FBQSxPQUFBd0IsYUFBQTtJQUFBeEIsQ0FBQSxPQUFBVCxHQUFBO0lBQUFTLENBQUEsT0FBQUUsS0FBQTtJQUFBRixDQUFBLE9BQUFQLFFBQUE7SUFBQU8sQ0FBQSxPQUFBd0MsR0FBQTtFQUFBO0lBQUFBLEdBQUEsR0FBQXhDLENBQUE7RUFBQTtFQUFBLE9BYk53QyxHQWFNO0FBQUE7QUF0RVYsU0FBQWpCLE1BQUFrQixNQUFBO0VBQUEsT0E0Qm9CQSxNQUFNLENBQUMsS0FBSyxDQUFDO0FBQUE7QUE4Q2pDLGVBQWUzQyxNQUFNO0FBQ3JCLGNBQWNaLFdBQVciLCJpZ25vcmVMaXN0IjpbXX0=
\ No newline at end of file
diff --git a/src/ink/components/ClockContext.tsx b/src/ink/components/ClockContext.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0f24839dfce619809cb3b4b79a3d436d5ea4baa4
--- /dev/null
+++ b/src/ink/components/ClockContext.tsx
@@ -0,0 +1,112 @@
+import { c as _c } from "react/compiler-runtime";
+import React, { createContext, useEffect, useState } from 'react';
+import { FRAME_INTERVAL_MS } from '../constants.js';
+import { useTerminalFocus } from '../hooks/use-terminal-focus.js';
+export type Clock = {
+ subscribe: (onChange: () => void, keepAlive: boolean) => () => void;
+ now: () => number;
+ setTickInterval: (ms: number) => void;
+};
+export function createClock(tickIntervalMs: number): Clock {
+ const subscribers = new Map<() => void, boolean>();
+ let interval: ReturnType | null = null;
+ let currentTickIntervalMs = tickIntervalMs;
+ let startTime = 0;
+ // Snapshot of the current tick's time, ensuring all subscribers in the same
+ // tick see the same value (keeps animations synchronized)
+ let tickTime = 0;
+ function tick(): void {
+ tickTime = Date.now() - startTime;
+ for (const onChange of subscribers.keys()) {
+ onChange();
+ }
+ }
+ function updateInterval(): void {
+ const anyKeepAlive = [...subscribers.values()].some(Boolean);
+ if (anyKeepAlive) {
+ if (interval) {
+ clearInterval(interval);
+ interval = null;
+ }
+ if (startTime === 0) {
+ startTime = Date.now();
+ }
+ interval = setInterval(tick, currentTickIntervalMs);
+ } else if (interval) {
+ clearInterval(interval);
+ interval = null;
+ }
+ }
+ return {
+ subscribe(onChange, keepAlive) {
+ subscribers.set(onChange, keepAlive);
+ updateInterval();
+ return () => {
+ subscribers.delete(onChange);
+ updateInterval();
+ };
+ },
+ now() {
+ if (startTime === 0) {
+ startTime = Date.now();
+ }
+ // When the clock interval is running, return the synchronized tickTime
+ // so all subscribers in the same tick see the same value.
+ // When paused (no keepAlive subscribers), return real-time to avoid
+ // returning a stale tickTime from the last tick before the pause.
+ if (interval && tickTime) {
+ return tickTime;
+ }
+ return Date.now() - startTime;
+ },
+ setTickInterval(ms) {
+ if (ms === currentTickIntervalMs) return;
+ currentTickIntervalMs = ms;
+ updateInterval();
+ }
+ };
+}
+export const ClockContext = createContext(null);
+const BLURRED_TICK_INTERVAL_MS = FRAME_INTERVAL_MS * 2;
+
+// Own component so App.tsx doesn't re-render when the clock is created.
+// The clock value is stable (created once via useState), so the provider
+// never causes consumer re-renders on its own.
+export function ClockProvider(t0) {
+ const $ = _c(7);
+ const {
+ children
+ } = t0;
+ const [clock] = useState(_temp);
+ const focused = useTerminalFocus();
+ let t1;
+ let t2;
+ if ($[0] !== clock || $[1] !== focused) {
+ t1 = () => {
+ clock.setTickInterval(focused ? FRAME_INTERVAL_MS : BLURRED_TICK_INTERVAL_MS);
+ };
+ t2 = [clock, focused];
+ $[0] = clock;
+ $[1] = focused;
+ $[2] = t1;
+ $[3] = t2;
+ } else {
+ t1 = $[2];
+ t2 = $[3];
+ }
+ useEffect(t1, t2);
+ let t3;
+ if ($[4] !== children || $[5] !== clock) {
+ t3 = {children};
+ $[4] = children;
+ $[5] = clock;
+ $[6] = t3;
+ } else {
+ t3 = $[6];
+ }
+ return t3;
+}
+function _temp() {
+ return createClock(FRAME_INTERVAL_MS);
+}
+//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsImNyZWF0ZUNvbnRleHQiLCJ1c2VFZmZlY3QiLCJ1c2VTdGF0ZSIsIkZSQU1FX0lOVEVSVkFMX01TIiwidXNlVGVybWluYWxGb2N1cyIsIkNsb2NrIiwic3Vic2NyaWJlIiwib25DaGFuZ2UiLCJrZWVwQWxpdmUiLCJub3ciLCJzZXRUaWNrSW50ZXJ2YWwiLCJtcyIsImNyZWF0ZUNsb2NrIiwidGlja0ludGVydmFsTXMiLCJzdWJzY3JpYmVycyIsIk1hcCIsImludGVydmFsIiwiUmV0dXJuVHlwZSIsInNldEludGVydmFsIiwiY3VycmVudFRpY2tJbnRlcnZhbE1zIiwic3RhcnRUaW1lIiwidGlja1RpbWUiLCJ0aWNrIiwiRGF0ZSIsImtleXMiLCJ1cGRhdGVJbnRlcnZhbCIsImFueUtlZXBBbGl2ZSIsInZhbHVlcyIsInNvbWUiLCJCb29sZWFuIiwiY2xlYXJJbnRlcnZhbCIsInNldCIsImRlbGV0ZSIsIkNsb2NrQ29udGV4dCIsIkJMVVJSRURfVElDS19JTlRFUlZBTF9NUyIsIkNsb2NrUHJvdmlkZXIiLCJ0MCIsIiQiLCJfYyIsImNoaWxkcmVuIiwiY2xvY2siLCJfdGVtcCIsImZvY3VzZWQiLCJ0MSIsInQyIiwidDMiXSwic291cmNlcyI6WyJDbG9ja0NvbnRleHQudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCBSZWFjdCwgeyBjcmVhdGVDb250ZXh0LCB1c2VFZmZlY3QsIHVzZVN0YXRlIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBGUkFNRV9JTlRFUlZBTF9NUyB9IGZyb20gJy4uL2NvbnN0YW50cy5qcydcbmltcG9ydCB7IHVzZVRlcm1pbmFsRm9jdXMgfSBmcm9tICcuLi9ob29rcy91c2UtdGVybWluYWwtZm9jdXMuanMnXG5cbmV4cG9ydCB0eXBlIENsb2NrID0ge1xuICBzdWJzY3JpYmU6IChvbkNoYW5nZTogKCkgPT4gdm9pZCwga2VlcEFsaXZlOiBib29sZWFuKSA9PiAoKSA9PiB2b2lkXG4gIG5vdzogKCkgPT4gbnVtYmVyXG4gIHNldFRpY2tJbnRlcnZhbDogKG1zOiBudW1iZXIpID0+IHZvaWRcbn1cblxuZXhwb3J0IGZ1bmN0aW9uIGNyZWF0ZUNsb2NrKHRpY2tJbnRlcnZhbE1zOiBudW1iZXIpOiBDbG9jayB7XG4gIGNvbnN0IHN1YnNjcmliZXJzID0gbmV3IE1hcDwoKSA9PiB2b2lkLCBib29sZWFuPigpXG4gIGxldCBpbnRlcnZhbDogUmV0dXJuVHlwZTx0eXBlb2Ygc2V0SW50ZXJ2YWw+IHwgbnVsbCA9IG51bGxcbiAgbGV0IGN1cnJlbnRUaWNrSW50ZXJ2YWxNcyA9IHRpY2tJbnRlcnZhbE1zXG4gIGxldCBzdGFydFRpbWUgPSAwXG4gIC8vIFNuYXBzaG90IG9mIHRoZSBjdXJyZW50IHRpY2sncyB0aW1lLCBlbnN1cmluZyBhbGwgc3Vic2NyaWJlcnMgaW4gdGhlIHNhbWVcbiAgLy8gdGljayBzZWUgdGhlIHNhbWUgdmFsdWUgKGtlZXBzIGFuaW1hdGlvbnMgc3luY2hyb25pemVkKVxuICBsZXQgdGlja1RpbWUgPSAwXG5cbiAgZnVuY3Rpb24gdGljaygpOiB2b2lkIHtcbiAgICB0aWNrVGltZSA9IERhdGUubm93KCkgLSBzdGFydFRpbWVcbiAgICBmb3IgKGNvbnN0IG9uQ2hhbmdlIG9mIHN1YnNjcmliZXJzLmtleXMoKSkge1xuICAgICAgb25DaGFuZ2UoKVxuICAgIH1cbiAgfVxuXG4gIGZ1bmN0aW9uIHVwZGF0ZUludGVydmFsKCk6IHZvaWQge1xuICAgIGNvbnN0IGFueUtlZXBBbGl2ZSA9IFsuLi5zdWJzY3JpYmVycy52YWx1ZXMoKV0uc29tZShCb29sZWFuKVxuXG4gICAgaWYgKGFueUtlZXBBbGl2ZSkge1xuICAgICAgaWYgKGludGVydmFsKSB7XG4gICAgICAgIGNsZWFySW50ZXJ2YWwoaW50ZXJ2YWwpXG4gICAgICAgIGludGVydmFsID0gbnVsbFxuICAgICAgfVxuICAgICAgaWYgKHN0YXJ0VGltZSA9PT0gMCkge1xuICAgICAgICBzdGFydFRpbWUgPSBEYXRlLm5vdygpXG4gICAgICB9XG4gICAgICBpbnRlcnZhbCA9IHNldEludGVydmFsKHRpY2ssIGN1cnJlbnRUaWNrSW50ZXJ2YWxNcylcbiAgICB9IGVsc2UgaWYgKGludGVydmFsKSB7XG4gICAgICBjbGVhckludGVydmFsKGludGVydmFsKVxuICAgICAgaW50ZXJ2YWwgPSBudWxsXG4gICAgfVxuICB9XG5cbiAgcmV0dXJuIHtcbiAgICBzdWJzY3JpYmUob25DaGFuZ2UsIGtlZXBBbGl2ZSkge1xuICAgICAgc3Vic2NyaWJlcnMuc2V0KG9uQ2hhbmdlLCBrZWVwQWxpdmUpXG4gICAgICB1cGRhdGVJbnRlcnZhbCgpXG4gICAgICByZXR1cm4gKCkgPT4ge1xuICAgICAgICBzdWJzY3JpYmVycy5kZWxldGUob25DaGFuZ2UpXG4gICAgICAgIHVwZGF0ZUludGVydmFsKClcbiAgICAgIH1cbiAgICB9LFxuXG4gICAgbm93KCkge1xuICAgICAgaWYgKHN0YXJ0VGltZSA9PT0gMCkge1xuICAgICAgICBzdGFydFRpbWUgPSBEYXRlLm5vdygpXG4gICAgICB9XG4gICAgICAvLyBXaGVuIHRoZSBjbG9jayBpbnRlcnZhbCBpcyBydW5uaW5nLCByZXR1cm4gdGhlIHN5bmNocm9uaXplZCB0aWNrVGltZVxuICAgICAgLy8gc28gYWxsIHN1YnNjcmliZXJzIGluIHRoZSBzYW1lIHRpY2sgc2VlIHRoZSBzYW1lIHZhbHVlLlxuICAgICAgLy8gV2hlbiBwYXVzZWQgKG5vIGtlZXBBbGl2ZSBzdWJzY3JpYmVycyksIHJldHVybiByZWFsLXRpbWUgdG8gYXZvaWRcbiAgICAgIC8vIHJldHVybmluZyBhIHN0YWxlIHRpY2tUaW1lIGZyb20gdGhlIGxhc3QgdGljayBiZWZvcmUgdGhlIHBhdXNlLlxuICAgICAgaWYgKGludGVydmFsICYmIHRpY2tUaW1lKSB7XG4gICAgICAgIHJldHVybiB0aWNrVGltZVxuICAgICAgfVxuICAgICAgcmV0dXJuIERhdGUubm93KCkgLSBzdGFydFRpbWVcbiAgICB9LFxuXG4gICAgc2V0VGlja0ludGVydmFsKG1zKSB7XG4gICAgICBpZiAobXMgPT09IGN1cnJlbnRUaWNrSW50ZXJ2YWxNcykgcmV0dXJuXG4gICAgICBjdXJyZW50VGlja0ludGVydmFsTXMgPSBtc1xuICAgICAgdXBkYXRlSW50ZXJ2YWwoKVxuICAgIH0sXG4gIH1cbn1cblxuZXhwb3J0IGNvbnN0IENsb2NrQ29udGV4dCA9IGNyZWF0ZUNvbnRleHQ8Q2xvY2sgfCBudWxsPihudWxsKVxuXG5jb25zdCBCTFVSUkVEX1RJQ0tfSU5URVJWQUxfTVMgPSBGUkFNRV9JTlRFUlZBTF9NUyAqIDJcblxuLy8gT3duIGNvbXBvbmVudCBzbyBBcHAudHN4IGRvZXNuJ3QgcmUtcmVuZGVyIHdoZW4gdGhlIGNsb2NrIGlzIGNyZWF0ZWQuXG4vLyBUaGUgY2xvY2sgdmFsdWUgaXMgc3RhYmxlIChjcmVhdGVkIG9uY2UgdmlhIHVzZVN0YXRlKSwgc28gdGhlIHByb3ZpZGVyXG4vLyBuZXZlciBjYXVzZXMgY29uc3VtZXIgcmUtcmVuZGVycyBvbiBpdHMgb3duLlxuZXhwb3J0IGZ1bmN0aW9uIENsb2NrUHJvdmlkZXIoe1xuICBjaGlsZHJlbixcbn06IHtcbiAgY2hpbGRyZW46IFJlYWN0LlJlYWN0Tm9kZVxufSk6IFJlYWN0LlJlYWN0Tm9kZSB7XG4gIGNvbnN0IFtjbG9ja10gPSB1c2VTdGF0ZSgoKSA9PiBjcmVhdGVDbG9jayhGUkFNRV9JTlRFUlZBTF9NUykpXG4gIGNvbnN0IGZvY3VzZWQgPSB1c2VUZXJtaW5hbEZvY3VzKClcblxuICB1c2VFZmZlY3QoKCkgPT4ge1xuICAgIGNsb2NrLnNldFRpY2tJbnRlcnZhbChcbiAgICAgIGZvY3VzZWQgPyBGUkFNRV9JTlRFUlZBTF9NUyA6IEJMVVJSRURfVElDS19JTlRFUlZBTF9NUyxcbiAgICApXG4gIH0sIFtjbG9jaywgZm9jdXNlZF0pXG5cbiAgcmV0dXJuIDxDbG9ja0NvbnRleHQuUHJvdmlkZXIgdmFsdWU9e2Nsb2NrfT57Y2hpbGRyZW59PC9DbG9ja0NvbnRleHQuUHJvdmlkZXI+XG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLElBQUlDLGFBQWEsRUFBRUMsU0FBUyxFQUFFQyxRQUFRLFFBQVEsT0FBTztBQUNqRSxTQUFTQyxpQkFBaUIsUUFBUSxpQkFBaUI7QUFDbkQsU0FBU0MsZ0JBQWdCLFFBQVEsZ0NBQWdDO0FBRWpFLE9BQU8sS0FBS0MsS0FBSyxHQUFHO0VBQ2xCQyxTQUFTLEVBQUUsQ0FBQ0MsUUFBUSxFQUFFLEdBQUcsR0FBRyxJQUFJLEVBQUVDLFNBQVMsRUFBRSxPQUFPLEVBQUUsR0FBRyxHQUFHLEdBQUcsSUFBSTtFQUNuRUMsR0FBRyxFQUFFLEdBQUcsR0FBRyxNQUFNO0VBQ2pCQyxlQUFlLEVBQUUsQ0FBQ0MsRUFBRSxFQUFFLE1BQU0sRUFBRSxHQUFHLElBQUk7QUFDdkMsQ0FBQztBQUVELE9BQU8sU0FBU0MsV0FBV0EsQ0FBQ0MsY0FBYyxFQUFFLE1BQU0sQ0FBQyxFQUFFUixLQUFLLENBQUM7RUFDekQsTUFBTVMsV0FBVyxHQUFHLElBQUlDLEdBQUcsQ0FBQyxHQUFHLEdBQUcsSUFBSSxFQUFFLE9BQU8sQ0FBQyxDQUFDLENBQUM7RUFDbEQsSUFBSUMsUUFBUSxFQUFFQyxVQUFVLENBQUMsT0FBT0MsV0FBVyxDQUFDLEdBQUcsSUFBSSxHQUFHLElBQUk7RUFDMUQsSUFBSUMscUJBQXFCLEdBQUdOLGNBQWM7RUFDMUMsSUFBSU8sU0FBUyxHQUFHLENBQUM7RUFDakI7RUFDQTtFQUNBLElBQUlDLFFBQVEsR0FBRyxDQUFDO0VBRWhCLFNBQVNDLElBQUlBLENBQUEsQ0FBRSxFQUFFLElBQUksQ0FBQztJQUNwQkQsUUFBUSxHQUFHRSxJQUFJLENBQUNkLEdBQUcsQ0FBQyxDQUFDLEdBQUdXLFNBQVM7SUFDakMsS0FBSyxNQUFNYixRQUFRLElBQUlPLFdBQVcsQ0FBQ1UsSUFBSSxDQUFDLENBQUMsRUFBRTtNQUN6Q2pCLFFBQVEsQ0FBQyxDQUFDO0lBQ1o7RUFDRjtFQUVBLFNBQVNrQixjQUFjQSxDQUFBLENBQUUsRUFBRSxJQUFJLENBQUM7SUFDOUIsTUFBTUMsWUFBWSxHQUFHLENBQUMsR0FBR1osV0FBVyxDQUFDYSxNQUFNLENBQUMsQ0FBQyxDQUFDLENBQUNDLElBQUksQ0FBQ0MsT0FBTyxDQUFDO0lBRTVELElBQUlILFlBQVksRUFBRTtNQUNoQixJQUFJVixRQUFRLEVBQUU7UUFDWmMsYUFBYSxDQUFDZCxRQUFRLENBQUM7UUFDdkJBLFFBQVEsR0FBRyxJQUFJO01BQ2pCO01BQ0EsSUFBSUksU0FBUyxLQUFLLENBQUMsRUFBRTtRQUNuQkEsU0FBUyxHQUFHRyxJQUFJLENBQUNkLEdBQUcsQ0FBQyxDQUFDO01BQ3hCO01BQ0FPLFFBQVEsR0FBR0UsV0FBVyxDQUFDSSxJQUFJLEVBQUVILHFCQUFxQixDQUFDO0lBQ3JELENBQUMsTUFBTSxJQUFJSCxRQUFRLEVBQUU7TUFDbkJjLGFBQWEsQ0FBQ2QsUUFBUSxDQUFDO01BQ3ZCQSxRQUFRLEdBQUcsSUFBSTtJQUNqQjtFQUNGO0VBRUEsT0FBTztJQUNMVixTQUFTQSxDQUFDQyxRQUFRLEVBQUVDLFNBQVMsRUFBRTtNQUM3Qk0sV0FBVyxDQUFDaUIsR0FBRyxDQUFDeEIsUUFBUSxFQUFFQyxTQUFTLENBQUM7TUFDcENpQixjQUFjLENBQUMsQ0FBQztNQUNoQixPQUFPLE1BQU07UUFDWFgsV0FBVyxDQUFDa0IsTUFBTSxDQUFDekIsUUFBUSxDQUFDO1FBQzVCa0IsY0FBYyxDQUFDLENBQUM7TUFDbEIsQ0FBQztJQUNILENBQUM7SUFFRGhCLEdBQUdBLENBQUEsRUFBRztNQUNKLElBQUlXLFNBQVMsS0FBSyxDQUFDLEVBQUU7UUFDbkJBLFNBQVMsR0FBR0csSUFBSSxDQUFDZCxHQUFHLENBQUMsQ0FBQztNQUN4QjtNQUNBO01BQ0E7TUFDQTtNQUNBO01BQ0EsSUFBSU8sUUFBUSxJQUFJSyxRQUFRLEVBQUU7UUFDeEIsT0FBT0EsUUFBUTtNQUNqQjtNQUNBLE9BQU9FLElBQUksQ0FBQ2QsR0FBRyxDQUFDLENBQUMsR0FBR1csU0FBUztJQUMvQixDQUFDO0lBRURWLGVBQWVBLENBQUNDLEVBQUUsRUFBRTtNQUNsQixJQUFJQSxFQUFFLEtBQUtRLHFCQUFxQixFQUFFO01BQ2xDQSxxQkFBcUIsR0FBR1IsRUFBRTtNQUMxQmMsY0FBYyxDQUFDLENBQUM7SUFDbEI7RUFDRixDQUFDO0FBQ0g7QUFFQSxPQUFPLE1BQU1RLFlBQVksR0FBR2pDLGFBQWEsQ0FBQ0ssS0FBSyxHQUFHLElBQUksQ0FBQyxDQUFDLElBQUksQ0FBQztBQUU3RCxNQUFNNkIsd0JBQXdCLEdBQUcvQixpQkFBaUIsR0FBRyxDQUFDOztBQUV0RDtBQUNBO0FBQ0E7QUFDQSxPQUFPLFNBQUFnQyxjQUFBQyxFQUFBO0VBQUEsTUFBQUMsQ0FBQSxHQUFBQyxFQUFBO0VBQXVCO0lBQUFDO0VBQUEsSUFBQUgsRUFJN0I7RUFDQyxPQUFBSSxLQUFBLElBQWdCdEMsUUFBUSxDQUFDdUMsS0FBb0MsQ0FBQztFQUM5RCxNQUFBQyxPQUFBLEdBQWdCdEMsZ0JBQWdCLENBQUMsQ0FBQztFQUFBLElBQUF1QyxFQUFBO0VBQUEsSUFBQUMsRUFBQTtFQUFBLElBQUFQLENBQUEsUUFBQUcsS0FBQSxJQUFBSCxDQUFBLFFBQUFLLE9BQUE7SUFFeEJDLEVBQUEsR0FBQUEsQ0FBQTtNQUNSSCxLQUFLLENBQUE5QixlQUFnQixDQUNuQmdDLE9BQU8sR0FBUHZDLGlCQUFzRCxHQUF0RCtCLHdCQUNGLENBQUM7SUFBQSxDQUNGO0lBQUVVLEVBQUEsSUFBQ0osS0FBSyxFQUFFRSxPQUFPLENBQUM7SUFBQUwsQ0FBQSxNQUFBRyxLQUFBO0lBQUFILENBQUEsTUFBQUssT0FBQTtJQUFBTCxDQUFBLE1BQUFNLEVBQUE7SUFBQU4sQ0FBQSxNQUFBTyxFQUFBO0VBQUE7SUFBQUQsRUFBQSxHQUFBTixDQUFBO0lBQUFPLEVBQUEsR0FBQVAsQ0FBQTtFQUFBO0VBSm5CcEMsU0FBUyxDQUFDMEMsRUFJVCxFQUFFQyxFQUFnQixDQUFDO0VBQUEsSUFBQUMsRUFBQTtFQUFBLElBQUFSLENBQUEsUUFBQUUsUUFBQSxJQUFBRixDQUFBLFFBQUFHLEtBQUE7SUFFYkssRUFBQSwwQkFBOEJMLEtBQUssQ0FBTEEsTUFBSSxDQUFDLENBQUdELFNBQU8sQ0FBRSx3QkFBd0I7SUFBQUYsQ0FBQSxNQUFBRSxRQUFBO0lBQUFGLENBQUEsTUFBQUcsS0FBQTtJQUFBSCxDQUFBLE1BQUFRLEVBQUE7RUFBQTtJQUFBQSxFQUFBLEdBQUFSLENBQUE7RUFBQTtFQUFBLE9BQXZFUSxFQUF1RTtBQUFBO0FBZHpFLFNBQUFKLE1BQUE7RUFBQSxPQUswQjdCLFdBQVcsQ0FBQ1QsaUJBQWlCLENBQUM7QUFBQSIsImlnbm9yZUxpc3QiOltdfQ==
\ No newline at end of file
diff --git a/src/ink/components/CursorDeclarationContext.ts b/src/ink/components/CursorDeclarationContext.ts
new file mode 100644
index 0000000000000000000000000000000000000000..358c80409a6bc2dd808edfc52fe419024846f5d6
--- /dev/null
+++ b/src/ink/components/CursorDeclarationContext.ts
@@ -0,0 +1,32 @@
+import { createContext } from 'react'
+import type { DOMElement } from '../dom.js'
+
+export type CursorDeclaration = {
+ /** Display column (terminal cell width) within the declared node */
+ readonly relativeX: number
+ /** Line number within the declared node */
+ readonly relativeY: number
+ /** The ink-box DOMElement whose yoga layout provides the absolute origin */
+ readonly node: DOMElement
+}
+
+/**
+ * Setter for the declared cursor position.
+ *
+ * The optional second argument makes `null` a conditional clear: the
+ * declaration is only cleared if the currently-declared node matches
+ * `clearIfNode`. This makes the hook safe for sibling components
+ * (e.g. list items) that transfer focus among themselves — without the
+ * node check, a newly-unfocused item's clear could clobber a
+ * newly-focused sibling's set depending on layout-effect order.
+ */
+export type CursorDeclarationSetter = (
+ declaration: CursorDeclaration | null,
+ clearIfNode?: DOMElement | null,
+) => void
+
+const CursorDeclarationContext = createContext(
+ () => {},
+)
+
+export default CursorDeclarationContext
diff --git a/src/ink/components/Link.tsx b/src/ink/components/Link.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..82341db6489298c975d28e52d3ebe19b8b90eb6e
--- /dev/null
+++ b/src/ink/components/Link.tsx
@@ -0,0 +1,42 @@
+import { c as _c } from "react/compiler-runtime";
+import type { ReactNode } from 'react';
+import React from 'react';
+import { supportsHyperlinks } from '../supports-hyperlinks.js';
+import Text from './Text.js';
+export type Props = {
+ readonly children?: ReactNode;
+ readonly url: string;
+ readonly fallback?: ReactNode;
+};
+export default function Link(t0) {
+ const $ = _c(5);
+ const {
+ children,
+ url,
+ fallback
+ } = t0;
+ const content = children ?? url;
+ if (supportsHyperlinks()) {
+ let t1;
+ if ($[0] !== content || $[1] !== url) {
+ t1 = {content};
+ $[0] = content;
+ $[1] = url;
+ $[2] = t1;
+ } else {
+ t1 = $[2];
+ }
+ return t1;
+ }
+ const t1 = fallback ?? content;
+ let t2;
+ if ($[3] !== t1) {
+ t2 = {t1};
+ $[3] = t1;
+ $[4] = t2;
+ } else {
+ t2 = $[4];
+ }
+ return t2;
+}
+//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdE5vZGUiLCJSZWFjdCIsInN1cHBvcnRzSHlwZXJsaW5rcyIsIlRleHQiLCJQcm9wcyIsImNoaWxkcmVuIiwidXJsIiwiZmFsbGJhY2siLCJMaW5rIiwidDAiLCIkIiwiX2MiLCJjb250ZW50IiwidDEiLCJ0MiJdLCJzb3VyY2VzIjpbIkxpbmsudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB0eXBlIHsgUmVhY3ROb2RlIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBzdXBwb3J0c0h5cGVybGlua3MgfSBmcm9tICcuLi9zdXBwb3J0cy1oeXBlcmxpbmtzLmpzJ1xuaW1wb3J0IFRleHQgZnJvbSAnLi9UZXh0LmpzJ1xuXG5leHBvcnQgdHlwZSBQcm9wcyA9IHtcbiAgcmVhZG9ubHkgY2hpbGRyZW4/OiBSZWFjdE5vZGVcbiAgcmVhZG9ubHkgdXJsOiBzdHJpbmdcbiAgcmVhZG9ubHkgZmFsbGJhY2s/OiBSZWFjdE5vZGVcbn1cblxuZXhwb3J0IGRlZmF1bHQgZnVuY3Rpb24gTGluayh7XG4gIGNoaWxkcmVuLFxuICB1cmwsXG4gIGZhbGxiYWNrLFxufTogUHJvcHMpOiBSZWFjdC5SZWFjdE5vZGUge1xuICAvLyBVc2UgY2hpbGRyZW4gaWYgcHJvdmlkZWQsIG90aGVyd2lzZSBkaXNwbGF5IHRoZSBVUkxcbiAgY29uc3QgY29udGVudCA9IGNoaWxkcmVuID8/IHVybFxuXG4gIGlmIChzdXBwb3J0c0h5cGVybGlua3MoKSkge1xuICAgIC8vIFdyYXAgaW4gVGV4dCB0byBlbnN1cmUgd2UncmUgaW4gYSB0ZXh0IGNvbnRleHRcbiAgICAvLyAoaW5rLWxpbmsgaXMgYSB0ZXh0IGVsZW1lbnQgbGlrZSBpbmstdGV4dClcbiAgICByZXR1cm4gKFxuICAgICAgPFRleHQ+XG4gICAgICAgIDxpbmstbGluayBocmVmPXt1cmx9Pntjb250ZW50fTwvaW5rLWxpbms+XG4gICAgICA8L1RleHQ+XG4gICAgKVxuICB9XG5cbiAgcmV0dXJuIDxUZXh0PntmYWxsYmFjayA/PyBjb250ZW50fTwvVGV4dD5cbn1cbiJdLCJtYXBwaW5ncyI6IjtBQUFBLGNBQWNBLFNBQVMsUUFBUSxPQUFPO0FBQ3RDLE9BQU9DLEtBQUssTUFBTSxPQUFPO0FBQ3pCLFNBQVNDLGtCQUFrQixRQUFRLDJCQUEyQjtBQUM5RCxPQUFPQyxJQUFJLE1BQU0sV0FBVztBQUU1QixPQUFPLEtBQUtDLEtBQUssR0FBRztFQUNsQixTQUFTQyxRQUFRLENBQUMsRUFBRUwsU0FBUztFQUM3QixTQUFTTSxHQUFHLEVBQUUsTUFBTTtFQUNwQixTQUFTQyxRQUFRLENBQUMsRUFBRVAsU0FBUztBQUMvQixDQUFDO0FBRUQsZUFBZSxTQUFBUSxLQUFBQyxFQUFBO0VBQUEsTUFBQUMsQ0FBQSxHQUFBQyxFQUFBO0VBQWM7SUFBQU4sUUFBQTtJQUFBQyxHQUFBO0lBQUFDO0VBQUEsSUFBQUUsRUFJckI7RUFFTixNQUFBRyxPQUFBLEdBQWdCUCxRQUFlLElBQWZDLEdBQWU7RUFFL0IsSUFBSUosa0JBQWtCLENBQUMsQ0FBQztJQUFBLElBQUFXLEVBQUE7SUFBQSxJQUFBSCxDQUFBLFFBQUFFLE9BQUEsSUFBQUYsQ0FBQSxRQUFBSixHQUFBO01BSXBCTyxFQUFBLElBQUMsSUFBSSxDQUNILFNBQXlDLENBQXpCUCxJQUFHLENBQUhBLElBQUUsQ0FBQyxDQUFHTSxRQUFNLENBQUUsRUFBOUIsUUFBeUMsQ0FDM0MsRUFGQyxJQUFJLENBRUU7TUFBQUYsQ0FBQSxNQUFBRSxPQUFBO01BQUFGLENBQUEsTUFBQUosR0FBQTtNQUFBSSxDQUFBLE1BQUFHLEVBQUE7SUFBQTtNQUFBQSxFQUFBLEdBQUFILENBQUE7SUFBQTtJQUFBLE9BRlBHLEVBRU87RUFBQTtFQUlHLE1BQUFBLEVBQUEsR0FBQU4sUUFBbUIsSUFBbkJLLE9BQW1CO0VBQUEsSUFBQUUsRUFBQTtFQUFBLElBQUFKLENBQUEsUUFBQUcsRUFBQTtJQUExQkMsRUFBQSxJQUFDLElBQUksQ0FBRSxDQUFBRCxFQUFrQixDQUFFLEVBQTFCLElBQUksQ0FBNkI7SUFBQUgsQ0FBQSxNQUFBRyxFQUFBO0lBQUFILENBQUEsTUFBQUksRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUosQ0FBQTtFQUFBO0VBQUEsT0FBbENJLEVBQWtDO0FBQUEiLCJpZ25vcmVMaXN0IjpbXX0=
\ No newline at end of file
diff --git a/src/ink/components/Newline.tsx b/src/ink/components/Newline.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5edf61882f36bdda97243879485d57ab25e09136
--- /dev/null
+++ b/src/ink/components/Newline.tsx
@@ -0,0 +1,39 @@
+import { c as _c } from "react/compiler-runtime";
+import React from 'react';
+export type Props = {
+ /**
+ * Number of newlines to insert.
+ *
+ * @default 1
+ */
+ readonly count?: number;
+};
+
+/**
+ * Adds one or more newline (\n) characters. Must be used within components.
+ */
+export default function Newline(t0) {
+ const $ = _c(4);
+ const {
+ count: t1
+ } = t0;
+ const count = t1 === undefined ? 1 : t1;
+ let t2;
+ if ($[0] !== count) {
+ t2 = "\n".repeat(count);
+ $[0] = count;
+ $[1] = t2;
+ } else {
+ t2 = $[1];
+ }
+ let t3;
+ if ($[2] !== t2) {
+ t3 = {t2};
+ $[2] = t2;
+ $[3] = t3;
+ } else {
+ t3 = $[3];
+ }
+ return t3;
+}
+//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlByb3BzIiwiY291bnQiLCJOZXdsaW5lIiwidDAiLCIkIiwiX2MiLCJ0MSIsInVuZGVmaW5lZCIsInQyIiwicmVwZWF0IiwidDMiXSwic291cmNlcyI6WyJOZXdsaW5lLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5cbmV4cG9ydCB0eXBlIFByb3BzID0ge1xuICAvKipcbiAgICogTnVtYmVyIG9mIG5ld2xpbmVzIHRvIGluc2VydC5cbiAgICpcbiAgICogQGRlZmF1bHQgMVxuICAgKi9cbiAgcmVhZG9ubHkgY291bnQ/OiBudW1iZXJcbn1cblxuLyoqXG4gKiBBZGRzIG9uZSBvciBtb3JlIG5ld2xpbmUgKFxcbikgY2hhcmFjdGVycy4gTXVzdCBiZSB1c2VkIHdpdGhpbiA8VGV4dD4gY29tcG9uZW50cy5cbiAqL1xuZXhwb3J0IGRlZmF1bHQgZnVuY3Rpb24gTmV3bGluZSh7IGNvdW50ID0gMSB9OiBQcm9wcykge1xuICByZXR1cm4gPGluay10ZXh0PnsnXFxuJy5yZXBlYXQoY291bnQpfTwvaW5rLXRleHQ+XG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLE1BQU0sT0FBTztBQUV6QixPQUFPLEtBQUtDLEtBQUssR0FBRztFQUNsQjtBQUNGO0FBQ0E7QUFDQTtBQUNBO0VBQ0UsU0FBU0MsS0FBSyxDQUFDLEVBQUUsTUFBTTtBQUN6QixDQUFDOztBQUVEO0FBQ0E7QUFDQTtBQUNBLGVBQWUsU0FBQUMsUUFBQUMsRUFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUFpQjtJQUFBSixLQUFBLEVBQUFLO0VBQUEsSUFBQUgsRUFBb0I7RUFBbEIsTUFBQUYsS0FBQSxHQUFBSyxFQUFTLEtBQVRDLFNBQVMsR0FBVCxDQUFTLEdBQVRELEVBQVM7RUFBQSxJQUFBRSxFQUFBO0VBQUEsSUFBQUosQ0FBQSxRQUFBSCxLQUFBO0lBQ3ZCTyxFQUFBLE9BQUksQ0FBQUMsTUFBTyxDQUFDUixLQUFLLENBQUM7SUFBQUcsQ0FBQSxNQUFBSCxLQUFBO0lBQUFHLENBQUEsTUFBQUksRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUosQ0FBQTtFQUFBO0VBQUEsSUFBQU0sRUFBQTtFQUFBLElBQUFOLENBQUEsUUFBQUksRUFBQTtJQUE3QkUsRUFBQSxZQUF5QyxDQUE5QixDQUFBRixFQUFpQixDQUFFLEVBQTlCLFFBQXlDO0lBQUFKLENBQUEsTUFBQUksRUFBQTtJQUFBSixDQUFBLE1BQUFNLEVBQUE7RUFBQTtJQUFBQSxFQUFBLEdBQUFOLENBQUE7RUFBQTtFQUFBLE9BQXpDTSxFQUF5QztBQUFBIiwiaWdub3JlTGlzdCI6W119
\ No newline at end of file
diff --git a/src/ink/components/RawAnsi.tsx b/src/ink/components/RawAnsi.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..919e453b283412a51fcea3a4804e7045ca8c54e3
--- /dev/null
+++ b/src/ink/components/RawAnsi.tsx
@@ -0,0 +1,57 @@
+import { c as _c } from "react/compiler-runtime";
+import React from 'react';
+type Props = {
+ /**
+ * Pre-rendered ANSI lines. Each element must be exactly one terminal row
+ * (already wrapped to `width` by the producer) with ANSI escape codes inline.
+ */
+ lines: string[];
+ /** Column width the producer wrapped to. Sent to Yoga as the fixed leaf width. */
+ width: number;
+};
+
+/**
+ * Bypass the → React tree → Yoga → squash → re-serialize roundtrip for
+ * content that is already terminal-ready.
+ *
+ * Use this when an external renderer (e.g. the ColorDiff NAPI module) has
+ * already produced ANSI-escaped, width-wrapped output. A normal mount
+ * reparses that output into one React per style span, lays out each
+ * span as a Yoga flex child, then walks the tree to re-emit the same escape
+ * codes it was given. For a long transcript full of syntax-highlighted diffs
+ * that roundtrip is the dominant cost of the render.
+ *
+ * This component emits a single Yoga leaf with a constant-time measure func
+ * (width × lines.length) and hands the joined string straight to output.write(),
+ * which already splits on '\n' and parses ANSI into the screen buffer.
+ */
+export function RawAnsi(t0) {
+ const $ = _c(6);
+ const {
+ lines,
+ width
+ } = t0;
+ if (lines.length === 0) {
+ return null;
+ }
+ let t1;
+ if ($[0] !== lines) {
+ t1 = lines.join("\n");
+ $[0] = lines;
+ $[1] = t1;
+ } else {
+ t1 = $[1];
+ }
+ let t2;
+ if ($[2] !== lines.length || $[3] !== t1 || $[4] !== width) {
+ t2 = ;
+ $[2] = lines.length;
+ $[3] = t1;
+ $[4] = width;
+ $[5] = t2;
+ } else {
+ t2 = $[5];
+ }
+ return t2;
+}
+//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlByb3BzIiwibGluZXMiLCJ3aWR0aCIsIlJhd0Fuc2kiLCJ0MCIsIiQiLCJfYyIsImxlbmd0aCIsInQxIiwiam9pbiIsInQyIl0sInNvdXJjZXMiOlsiUmF3QW5zaS50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0IGZyb20gJ3JlYWN0J1xuXG50eXBlIFByb3BzID0ge1xuICAvKipcbiAgICogUHJlLXJlbmRlcmVkIEFOU0kgbGluZXMuIEVhY2ggZWxlbWVudCBtdXN0IGJlIGV4YWN0bHkgb25lIHRlcm1pbmFsIHJvd1xuICAgKiAoYWxyZWFkeSB3cmFwcGVkIHRvIGB3aWR0aGAgYnkgdGhlIHByb2R1Y2VyKSB3aXRoIEFOU0kgZXNjYXBlIGNvZGVzIGlubGluZS5cbiAgICovXG4gIGxpbmVzOiBzdHJpbmdbXVxuICAvKiogQ29sdW1uIHdpZHRoIHRoZSBwcm9kdWNlciB3cmFwcGVkIHRvLiBTZW50IHRvIFlvZ2EgYXMgdGhlIGZpeGVkIGxlYWYgd2lkdGguICovXG4gIHdpZHRoOiBudW1iZXJcbn1cblxuLyoqXG4gKiBCeXBhc3MgdGhlIDxBbnNpPiDihpIgUmVhY3QgdHJlZSDihpIgWW9nYSDihpIgc3F1YXNoIOKGkiByZS1zZXJpYWxpemUgcm91bmR0cmlwIGZvclxuICogY29udGVudCB0aGF0IGlzIGFscmVhZHkgdGVybWluYWwtcmVhZHkuXG4gKlxuICogVXNlIHRoaXMgd2hlbiBhbiBleHRlcm5hbCByZW5kZXJlciAoZS5nLiB0aGUgQ29sb3JEaWZmIE5BUEkgbW9kdWxlKSBoYXNcbiAqIGFscmVhZHkgcHJvZHVjZWQgQU5TSS1lc2NhcGVkLCB3aWR0aC13cmFwcGVkIG91dHB1dC4gQSBub3JtYWwgPEFuc2k+IG1vdW50XG4gKiByZXBhcnNlcyB0aGF0IG91dHB1dCBpbnRvIG9uZSBSZWFjdCA8VGV4dD4gcGVyIHN0eWxlIHNwYW4sIGxheXMgb3V0IGVhY2hcbiAqIHNwYW4gYXMgYSBZb2dhIGZsZXggY2hpbGQsIHRoZW4gd2Fsa3MgdGhlIHRyZWUgdG8gcmUtZW1pdCB0aGUgc2FtZSBlc2NhcGVcbiAqIGNvZGVzIGl0IHdhcyBnaXZlbi4gRm9yIGEgbG9uZyB0cmFuc2NyaXB0IGZ1bGwgb2Ygc3ludGF4LWhpZ2hsaWdodGVkIGRpZmZzXG4gKiB0aGF0IHJvdW5kdHJpcCBpcyB0aGUgZG9taW5hbnQgY29zdCBvZiB0aGUgcmVuZGVyLlxuICpcbiAqIFRoaXMgY29tcG9uZW50IGVtaXRzIGEgc2luZ2xlIFlvZ2EgbGVhZiB3aXRoIGEgY29uc3RhbnQtdGltZSBtZWFzdXJlIGZ1bmNcbiAqICh3aWR0aCDDlyBsaW5lcy5sZW5ndGgpIGFuZCBoYW5kcyB0aGUgam9pbmVkIHN0cmluZyBzdHJhaWdodCB0byBvdXRwdXQud3JpdGUoKSxcbiAqIHdoaWNoIGFscmVhZHkgc3BsaXRzIG9uICdcXG4nIGFuZCBwYXJzZXMgQU5TSSBpbnRvIHRoZSBzY3JlZW4gYnVmZmVyLlxuICovXG5leHBvcnQgZnVuY3Rpb24gUmF3QW5zaSh7IGxpbmVzLCB3aWR0aCB9OiBQcm9wcyk6IFJlYWN0LlJlYWN0Tm9kZSB7XG4gIGlmIChsaW5lcy5sZW5ndGggPT09IDApIHtcbiAgICByZXR1cm4gbnVsbFxuICB9XG4gIHJldHVybiAoXG4gICAgPGluay1yYXctYW5zaVxuICAgICAgcmF3VGV4dD17bGluZXMuam9pbignXFxuJyl9XG4gICAgICByYXdXaWR0aD17d2lkdGh9XG4gICAgICByYXdIZWlnaHQ9e2xpbmVzLmxlbmd0aH1cbiAgICAvPlxuICApXG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLE1BQU0sT0FBTztBQUV6QixLQUFLQyxLQUFLLEdBQUc7RUFDWDtBQUNGO0FBQ0E7QUFDQTtFQUNFQyxLQUFLLEVBQUUsTUFBTSxFQUFFO0VBQ2Y7RUFDQUMsS0FBSyxFQUFFLE1BQU07QUFDZixDQUFDOztBQUVEO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBLE9BQU8sU0FBQUMsUUFBQUMsRUFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUFpQjtJQUFBTCxLQUFBO0lBQUFDO0VBQUEsSUFBQUUsRUFBdUI7RUFDN0MsSUFBSUgsS0FBSyxDQUFBTSxNQUFPLEtBQUssQ0FBQztJQUFBLE9BQ2IsSUFBSTtFQUFBO0VBQ1osSUFBQUMsRUFBQTtFQUFBLElBQUFILENBQUEsUUFBQUosS0FBQTtJQUdZTyxFQUFBLEdBQUFQLEtBQUssQ0FBQVEsSUFBSyxDQUFDLElBQUksQ0FBQztJQUFBSixDQUFBLE1BQUFKLEtBQUE7SUFBQUksQ0FBQSxNQUFBRyxFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBSCxDQUFBO0VBQUE7RUFBQSxJQUFBSyxFQUFBO0VBQUEsSUFBQUwsQ0FBQSxRQUFBSixLQUFBLENBQUFNLE1BQUEsSUFBQUYsQ0FBQSxRQUFBRyxFQUFBLElBQUFILENBQUEsUUFBQUgsS0FBQTtJQUQzQlEsRUFBQSxnQkFJRSxDQUhTLE9BQWdCLENBQWhCLENBQUFGLEVBQWUsQ0FBQyxDQUNmTixRQUFLLENBQUxBLE1BQUksQ0FBQyxDQUNKLFNBQVksQ0FBWixDQUFBRCxLQUFLLENBQUFNLE1BQU0sQ0FBQyxHQUN2QjtJQUFBRixDQUFBLE1BQUFKLEtBQUEsQ0FBQU0sTUFBQTtJQUFBRixDQUFBLE1BQUFHLEVBQUE7SUFBQUgsQ0FBQSxNQUFBSCxLQUFBO0lBQUFHLENBQUEsTUFBQUssRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUwsQ0FBQTtFQUFBO0VBQUEsT0FKRkssRUFJRTtBQUFBIiwiaWdub3JlTGlzdCI6W119
\ No newline at end of file
diff --git a/src/ink/components/ScrollBox.tsx b/src/ink/components/ScrollBox.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..03e4a31cb1dab9818a259f89aa46cd83d6c9309b
--- /dev/null
+++ b/src/ink/components/ScrollBox.tsx
@@ -0,0 +1,237 @@
+import React, { type PropsWithChildren, type Ref, useImperativeHandle, useRef, useState } from 'react';
+import type { Except } from 'type-fest';
+import { markScrollActivity } from '../../bootstrap/state.js';
+import type { DOMElement } from '../dom.js';
+import { markDirty, scheduleRenderFrom } from '../dom.js';
+import { markCommitStart } from '../reconciler.js';
+import type { Styles } from '../styles.js';
+import '../global.d.ts';
+import Box from './Box.js';
+export type ScrollBoxHandle = {
+ scrollTo: (y: number) => void;
+ scrollBy: (dy: number) => void;
+ /**
+ * Scroll so `el`'s top is at the viewport top (plus `offset`). Unlike
+ * scrollTo which bakes a number that's stale by the time the throttled
+ * render fires, this defers the position read to render time —
+ * render-node-to-output reads `el.yogaNode.getComputedTop()` in the
+ * SAME Yoga pass that computes scrollHeight. Deterministic. One-shot.
+ */
+ scrollToElement: (el: DOMElement, offset?: number) => void;
+ scrollToBottom: () => void;
+ getScrollTop: () => number;
+ getPendingDelta: () => number;
+ getScrollHeight: () => number;
+ /**
+ * Like getScrollHeight, but reads Yoga directly instead of the cached
+ * value written by render-node-to-output (throttled, up to 16ms stale).
+ * Use when you need a fresh value in useLayoutEffect after a React commit
+ * that grew content. Slightly more expensive (native Yoga call).
+ */
+ getFreshScrollHeight: () => number;
+ getViewportHeight: () => number;
+ /**
+ * Absolute screen-buffer row of the first visible content line (inside
+ * padding). Used for drag-to-scroll edge detection.
+ */
+ getViewportTop: () => number;
+ /**
+ * True when scroll is pinned to the bottom. Set by scrollToBottom, the
+ * initial stickyScroll attribute, and by the renderer when positional
+ * follow fires (scrollTop at prevMax, content grows). Cleared by
+ * scrollTo/scrollBy. Stable signal for "at bottom" that doesn't depend on
+ * layout values (unlike scrollTop+viewportH >= scrollHeight).
+ */
+ isSticky: () => boolean;
+ /**
+ * Subscribe to imperative scroll changes (scrollTo/scrollBy/scrollToBottom).
+ * Does NOT fire for stickyScroll updates done by the Ink renderer — those
+ * happen during Ink's render phase after React has committed. Callers that
+ * care about the sticky case should treat "at bottom" as a fallback.
+ */
+ subscribe: (listener: () => void) => () => void;
+ /**
+ * Set the render-time scrollTop clamp to the currently-mounted children's
+ * coverage span. Called by useVirtualScroll after computing its range;
+ * render-node-to-output clamps scrollTop to [min, max] so burst scrollTo
+ * calls that race past React's async re-render show the edge of mounted
+ * content instead of blank spacer. Pass undefined to disable (sticky,
+ * cold start).
+ */
+ setClampBounds: (min: number | undefined, max: number | undefined) => void;
+};
+export type ScrollBoxProps = Except & {
+ ref?: Ref;
+ /**
+ * When true, automatically pins scroll position to the bottom when content
+ * grows. Unset manually via scrollTo/scrollBy to break the stickiness.
+ */
+ stickyScroll?: boolean;
+};
+
+/**
+ * A Box with `overflow: scroll` and an imperative scroll API.
+ *
+ * Children are laid out at their full Yoga-computed height inside a
+ * constrained container. At render time, only children intersecting the
+ * visible window (scrollTop..scrollTop+height) are rendered (viewport
+ * culling). Content is translated by -scrollTop and clipped to the box bounds.
+ *
+ * Works best inside a fullscreen (constrained-height root) Ink tree.
+ */
+function ScrollBox({
+ children,
+ ref,
+ stickyScroll,
+ ...style
+}: PropsWithChildren): React.ReactNode {
+ const domRef = useRef(null);
+ // scrollTo/scrollBy bypass React: they mutate scrollTop on the DOM node,
+ // mark it dirty, and call the root's throttled scheduleRender directly.
+ // The Ink renderer reads scrollTop from the node — no React state needed,
+ // no reconciler overhead per wheel event. The microtask defer coalesces
+ // multiple scrollBy calls in one input batch (discreteUpdates) into one
+ // render — otherwise scheduleRender's leading edge fires on the FIRST
+ // event before subsequent events mutate scrollTop. scrollToBottom still
+ // forces a React render: sticky is attribute-observed, no DOM-only path.
+ const [, forceRender] = useState(0);
+ const listenersRef = useRef(new Set<() => void>());
+ const renderQueuedRef = useRef(false);
+ const notify = () => {
+ for (const l of listenersRef.current) l();
+ };
+ function scrollMutated(el: DOMElement): void {
+ // Signal background intervals (IDE poll, LSP poll, GCS fetch, orphan
+ // check) to skip their next tick — they compete for the event loop and
+ // contributed to 1402ms max frame gaps during scroll drain.
+ markScrollActivity();
+ markDirty(el);
+ markCommitStart();
+ notify();
+ if (renderQueuedRef.current) return;
+ renderQueuedRef.current = true;
+ queueMicrotask(() => {
+ renderQueuedRef.current = false;
+ scheduleRenderFrom(el);
+ });
+ }
+ useImperativeHandle(ref, (): ScrollBoxHandle => ({
+ scrollTo(y: number) {
+ const el = domRef.current;
+ if (!el) return;
+ // Explicit false overrides the DOM attribute so manual scroll
+ // breaks stickiness. Render code checks ?? precedence.
+ el.stickyScroll = false;
+ el.pendingScrollDelta = undefined;
+ el.scrollAnchor = undefined;
+ el.scrollTop = Math.max(0, Math.floor(y));
+ scrollMutated(el);
+ },
+ scrollToElement(el: DOMElement, offset = 0) {
+ const box = domRef.current;
+ if (!box) return;
+ box.stickyScroll = false;
+ box.pendingScrollDelta = undefined;
+ box.scrollAnchor = {
+ el,
+ offset
+ };
+ scrollMutated(box);
+ },
+ scrollBy(dy: number) {
+ const el = domRef.current;
+ if (!el) return;
+ el.stickyScroll = false;
+ // Wheel input cancels any in-flight anchor seek — user override.
+ el.scrollAnchor = undefined;
+ // Accumulate in pendingScrollDelta; renderer drains it at a capped
+ // rate so fast flicks show intermediate frames. Pure accumulator:
+ // scroll-up followed by scroll-down naturally cancels.
+ el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy);
+ scrollMutated(el);
+ },
+ scrollToBottom() {
+ const el = domRef.current;
+ if (!el) return;
+ el.pendingScrollDelta = undefined;
+ el.stickyScroll = true;
+ markDirty(el);
+ notify();
+ forceRender(n => n + 1);
+ },
+ getScrollTop() {
+ return domRef.current?.scrollTop ?? 0;
+ },
+ getPendingDelta() {
+ // Accumulated-but-not-yet-drained delta. useVirtualScroll needs
+ // this to mount the union [committed, committed+pending] range —
+ // otherwise intermediate drain frames find no children (blank).
+ return domRef.current?.pendingScrollDelta ?? 0;
+ },
+ getScrollHeight() {
+ return domRef.current?.scrollHeight ?? 0;
+ },
+ getFreshScrollHeight() {
+ const content = domRef.current?.childNodes[0] as DOMElement | undefined;
+ return content?.yogaNode?.getComputedHeight() ?? domRef.current?.scrollHeight ?? 0;
+ },
+ getViewportHeight() {
+ return domRef.current?.scrollViewportHeight ?? 0;
+ },
+ getViewportTop() {
+ return domRef.current?.scrollViewportTop ?? 0;
+ },
+ isSticky() {
+ const el = domRef.current;
+ if (!el) return false;
+ return el.stickyScroll ?? Boolean(el.attributes['stickyScroll']);
+ },
+ subscribe(listener: () => void) {
+ listenersRef.current.add(listener);
+ return () => listenersRef.current.delete(listener);
+ },
+ setClampBounds(min, max) {
+ const el = domRef.current;
+ if (!el) return;
+ el.scrollClampMin = min;
+ el.scrollClampMax = max;
+ }
+ }),
+ // notify/scrollMutated are inline (no useCallback) but only close over
+ // refs + imports — stable. Empty deps avoids rebuilding the handle on
+ // every render (which re-registers the ref = churn).
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ []);
+
+ // Structure: outer viewport (overflow:scroll, constrained height) >
+ // inner content (flexGrow:1, flexShrink:0 — fills at least the viewport
+ // but grows beyond it for tall content). flexGrow:1 lets children use
+ // spacers to pin elements to the bottom of the scroll area. Yoga's
+ // Overflow.Scroll prevents the viewport from growing to fit the content.
+ // The renderer computes scrollHeight from the content box and culls
+ // content's children based on scrollTop.
+ //
+ // stickyScroll is passed as a DOM attribute (via ink-box directly) so it's
+ // available on the first render — ref callbacks fire after the initial
+ // commit, which is too late for the first frame.
+ return {
+ domRef.current = el;
+ if (el) el.scrollTop ??= 0;
+ }} style={{
+ flexWrap: 'nowrap',
+ flexDirection: style.flexDirection ?? 'row',
+ flexGrow: style.flexGrow ?? 0,
+ flexShrink: style.flexShrink ?? 1,
+ ...style,
+ overflowX: 'scroll',
+ overflowY: 'scroll'
+ }} {...stickyScroll ? {
+ stickyScroll: true
+ } : {}}>
+
+ {children}
+
+ ;
+}
+export default ScrollBox;
+//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlByb3BzV2l0aENoaWxkcmVuIiwiUmVmIiwidXNlSW1wZXJhdGl2ZUhhbmRsZSIsInVzZVJlZiIsInVzZVN0YXRlIiwiRXhjZXB0IiwibWFya1Njcm9sbEFjdGl2aXR5IiwiRE9NRWxlbWVudCIsIm1hcmtEaXJ0eSIsInNjaGVkdWxlUmVuZGVyRnJvbSIsIm1hcmtDb21taXRTdGFydCIsIlN0eWxlcyIsIkJveCIsIlNjcm9sbEJveEhhbmRsZSIsInNjcm9sbFRvIiwieSIsInNjcm9sbEJ5IiwiZHkiLCJzY3JvbGxUb0VsZW1lbnQiLCJlbCIsIm9mZnNldCIsInNjcm9sbFRvQm90dG9tIiwiZ2V0U2Nyb2xsVG9wIiwiZ2V0UGVuZGluZ0RlbHRhIiwiZ2V0U2Nyb2xsSGVpZ2h0IiwiZ2V0RnJlc2hTY3JvbGxIZWlnaHQiLCJnZXRWaWV3cG9ydEhlaWdodCIsImdldFZpZXdwb3J0VG9wIiwiaXNTdGlja3kiLCJzdWJzY3JpYmUiLCJsaXN0ZW5lciIsInNldENsYW1wQm91bmRzIiwibWluIiwibWF4IiwiU2Nyb2xsQm94UHJvcHMiLCJyZWYiLCJzdGlja3lTY3JvbGwiLCJTY3JvbGxCb3giLCJjaGlsZHJlbiIsInN0eWxlIiwiUmVhY3ROb2RlIiwiZG9tUmVmIiwiZm9yY2VSZW5kZXIiLCJsaXN0ZW5lcnNSZWYiLCJTZXQiLCJyZW5kZXJRdWV1ZWRSZWYiLCJub3RpZnkiLCJsIiwiY3VycmVudCIsInNjcm9sbE11dGF0ZWQiLCJxdWV1ZU1pY3JvdGFzayIsInBlbmRpbmdTY3JvbGxEZWx0YSIsInVuZGVmaW5lZCIsInNjcm9sbEFuY2hvciIsInNjcm9sbFRvcCIsIk1hdGgiLCJmbG9vciIsImJveCIsIm4iLCJzY3JvbGxIZWlnaHQiLCJjb250ZW50IiwiY2hpbGROb2RlcyIsInlvZ2FOb2RlIiwiZ2V0Q29tcHV0ZWRIZWlnaHQiLCJzY3JvbGxWaWV3cG9ydEhlaWdodCIsInNjcm9sbFZpZXdwb3J0VG9wIiwiQm9vbGVhbiIsImF0dHJpYnV0ZXMiLCJhZGQiLCJkZWxldGUiLCJzY3JvbGxDbGFtcE1pbiIsInNjcm9sbENsYW1wTWF4IiwiZmxleFdyYXAiLCJmbGV4RGlyZWN0aW9uIiwiZmxleEdyb3ciLCJmbGV4U2hyaW5rIiwib3ZlcmZsb3dYIiwib3ZlcmZsb3dZIl0sInNvdXJjZXMiOlsiU2Nyb2xsQm94LnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QsIHtcbiAgdHlwZSBQcm9wc1dpdGhDaGlsZHJlbixcbiAgdHlwZSBSZWYsXG4gIHVzZUltcGVyYXRpdmVIYW5kbGUsXG4gIHVzZVJlZixcbiAgdXNlU3RhdGUsXG59IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHR5cGUgeyBFeGNlcHQgfSBmcm9tICd0eXBlLWZlc3QnXG5pbXBvcnQgeyBtYXJrU2Nyb2xsQWN0aXZpdHkgfSBmcm9tICcuLi8uLi9ib290c3RyYXAvc3RhdGUuanMnXG5pbXBvcnQgdHlwZSB7IERPTUVsZW1lbnQgfSBmcm9tICcuLi9kb20uanMnXG5pbXBvcnQgeyBtYXJrRGlydHksIHNjaGVkdWxlUmVuZGVyRnJvbSB9IGZyb20gJy4uL2RvbS5qcydcbmltcG9ydCB7IG1hcmtDb21taXRTdGFydCB9IGZyb20gJy4uL3JlY29uY2lsZXIuanMnXG5pbXBvcnQgdHlwZSB7IFN0eWxlcyB9IGZyb20gJy4uL3N0eWxlcy5qcydcbmltcG9ydCAnLi4vZ2xvYmFsLmQudHMnXG5pbXBvcnQgQm94IGZyb20gJy4vQm94LmpzJ1xuXG5leHBvcnQgdHlwZSBTY3JvbGxCb3hIYW5kbGUgPSB7XG4gIHNjcm9sbFRvOiAoeTogbnVtYmVyKSA9PiB2b2lkXG4gIHNjcm9sbEJ5OiAoZHk6IG51bWJlcikgPT4gdm9pZFxuICAvKipcbiAgICogU2Nyb2xsIHNvIGBlbGAncyB0b3AgaXMgYXQgdGhlIHZpZXdwb3J0IHRvcCAocGx1cyBgb2Zmc2V0YCkuIFVubGlrZVxuICAgKiBzY3JvbGxUbyB3aGljaCBiYWtlcyBhIG51bWJlciB0aGF0J3Mgc3RhbGUgYnkgdGhlIHRpbWUgdGhlIHRocm90dGxlZFxuICAgKiByZW5kZXIgZmlyZXMsIHRoaXMgZGVmZXJzIHRoZSBwb3NpdGlvbiByZWFkIHRvIHJlbmRlciB0aW1lIOKAlFxuICAgKiByZW5kZXItbm9kZS10by1vdXRwdXQgcmVhZHMgYGVsLnlvZ2FOb2RlLmdldENvbXB1dGVkVG9wKClgIGluIHRoZVxuICAgKiBTQU1FIFlvZ2EgcGFzcyB0aGF0IGNvbXB1dGVzIHNjcm9sbEhlaWdodC4gRGV0ZXJtaW5pc3RpYy4gT25lLXNob3QuXG4gICAqL1xuICBzY3JvbGxUb0VsZW1lbnQ6IChlbDogRE9NRWxlbWVudCwgb2Zmc2V0PzogbnVtYmVyKSA9PiB2b2lkXG4gIHNjcm9sbFRvQm90dG9tOiAoKSA9PiB2b2lkXG4gIGdldFNjcm9sbFRvcDogKCkgPT4gbnVtYmVyXG4gIGdldFBlbmRpbmdEZWx0YTogKCkgPT4gbnVtYmVyXG4gIGdldFNjcm9sbEhlaWdodDogKCkgPT4gbnVtYmVyXG4gIC8qKlxuICAgKiBMaWtlIGdldFNjcm9sbEhlaWdodCwgYnV0IHJlYWRzIFlvZ2EgZGlyZWN0bHkgaW5zdGVhZCBvZiB0aGUgY2FjaGVkXG4gICAqIHZhbHVlIHdyaXR0ZW4gYnkgcmVuZGVyLW5vZGUtdG8tb3V0cHV0ICh0aHJvdHRsZWQsIHVwIHRvIDE2bXMgc3RhbGUpLlxuICAgKiBVc2Ugd2hlbiB5b3UgbmVlZCBhIGZyZXNoIHZhbHVlIGluIHVzZUxheW91dEVmZmVjdCBhZnRlciBhIFJlYWN0IGNvbW1pdFxuICAgKiB0aGF0IGdyZXcgY29udGVudC4gU2xpZ2h0bHkgbW9yZSBleHBlbnNpdmUgKG5hdGl2ZSBZb2dhIGNhbGwpLlxuICAgKi9cbiAgZ2V0RnJlc2hTY3JvbGxIZWlnaHQ6ICgpID0+IG51bWJlclxuICBnZXRWaWV3cG9ydEhlaWdodDogKCkgPT4gbnVtYmVyXG4gIC8qKlxuICAgKiBBYnNvbHV0ZSBzY3JlZW4tYnVmZmVyIHJvdyBvZiB0aGUgZmlyc3QgdmlzaWJsZSBjb250ZW50IGxpbmUgKGluc2lkZVxuICAgKiBwYWRkaW5nKS4gVXNlZCBmb3IgZHJhZy10by1zY3JvbGwgZWRnZSBkZXRlY3Rpb24uXG4gICAqL1xuICBnZXRWaWV3cG9ydFRvcDogKCkgPT4gbnVtYmVyXG4gIC8qKlxuICAgKiBUcnVlIHdoZW4gc2Nyb2xsIGlzIHBpbm5lZCB0byB0aGUgYm90dG9tLiBTZXQgYnkgc2Nyb2xsVG9Cb3R0b20sIHRoZVxuICAgKiBpbml0aWFsIHN0aWNreVNjcm9sbCBhdHRyaWJ1dGUsIGFuZCBieSB0aGUgcmVuZGVyZXIgd2hlbiBwb3NpdGlvbmFsXG4gICAqIGZvbGxvdyBmaXJlcyAoc2Nyb2xsVG9wIGF0IHByZXZNYXgsIGNvbnRlbnQgZ3Jvd3MpLiBDbGVhcmVkIGJ5XG4gICAqIHNjcm9sbFRvL3Njcm9sbEJ5LiBTdGFibGUgc2lnbmFsIGZvciBcImF0IGJvdHRvbVwiIHRoYXQgZG9lc24ndCBkZXBlbmQgb25cbiAgICogbGF5b3V0IHZhbHVlcyAodW5saWtlIHNjcm9sbFRvcCt2aWV3cG9ydEggPj0gc2Nyb2xsSGVpZ2h0KS5cbiAgICovXG4gIGlzU3RpY2t5OiAoKSA9PiBib29sZWFuXG4gIC8qKlxuICAgKiBTdWJzY3JpYmUgdG8gaW1wZXJhdGl2ZSBzY3JvbGwgY2hhbmdlcyAoc2Nyb2xsVG8vc2Nyb2xsQnkvc2Nyb2xsVG9Cb3R0b20pLlxuICAgKiBEb2VzIE5PVCBmaXJlIGZvciBzdGlja3lTY3JvbGwgdXBkYXRlcyBkb25lIGJ5IHRoZSBJbmsgcmVuZGVyZXIg4oCUIHRob3NlXG4gICAqIGhhcHBlbiBkdXJpbmcgSW5rJ3MgcmVuZGVyIHBoYXNlIGFmdGVyIFJlYWN0IGhhcyBjb21taXR0ZWQuIENhbGxlcnMgdGhhdFxuICAgKiBjYXJlIGFib3V0IHRoZSBzdGlja3kgY2FzZSBzaG91bGQgdHJlYXQgXCJhdCBib3R0b21cIiBhcyBhIGZhbGxiYWNrLlxuICAgKi9cbiAgc3Vic2NyaWJlOiAobGlzdGVuZXI6ICgpID0+IHZvaWQpID0+ICgpID0+IHZvaWRcbiAgLyoqXG4gICAqIFNldCB0aGUgcmVuZGVyLXRpbWUgc2Nyb2xsVG9wIGNsYW1wIHRvIHRoZSBjdXJyZW50bHktbW91bnRlZCBjaGlsZHJlbidzXG4gICAqIGNvdmVyYWdlIHNwYW4uIENhbGxlZCBieSB1c2VWaXJ0dWFsU2Nyb2xsIGFmdGVyIGNvbXB1dGluZyBpdHMgcmFuZ2U7XG4gICAqIHJlbmRlci1ub2RlLXRvLW91dHB1dCBjbGFtcHMgc2Nyb2xsVG9wIHRvIFttaW4sIG1heF0gc28gYnVyc3Qgc2Nyb2xsVG9cbiAgICogY2FsbHMgdGhhdCByYWNlIHBhc3QgUmVhY3QncyBhc3luYyByZS1yZW5kZXIgc2hvdyB0aGUgZWRnZSBvZiBtb3VudGVkXG4gICAqIGNvbnRlbnQgaW5zdGVhZCBvZiBibGFuayBzcGFjZXIuIFBhc3MgdW5kZWZpbmVkIHRvIGRpc2FibGUgKHN0aWNreSxcbiAgICogY29sZCBzdGFydCkuXG4gICAqL1xuICBzZXRDbGFtcEJvdW5kczogKG1pbjogbnVtYmVyIHwgdW5kZWZpbmVkLCBtYXg6IG51bWJlciB8IHVuZGVmaW5lZCkgPT4gdm9pZFxufVxuXG5leHBvcnQgdHlwZSBTY3JvbGxCb3hQcm9wcyA9IEV4Y2VwdDxcbiAgU3R5bGVzLFxuICAndGV4dFdyYXAnIHwgJ292ZXJmbG93JyB8ICdvdmVyZmxvd1gnIHwgJ292ZXJmbG93WSdcbj4gJiB7XG4gIHJlZj86IFJlZjxTY3JvbGxCb3hIYW5kbGU+XG4gIC8qKlxuICAgKiBXaGVuIHRydWUsIGF1dG9tYXRpY2FsbHkgcGlucyBzY3JvbGwgcG9zaXRpb24gdG8gdGhlIGJvdHRvbSB3aGVuIGNvbnRlbnRcbiAgICogZ3Jvd3MuIFVuc2V0IG1hbnVhbGx5IHZpYSBzY3JvbGxUby9zY3JvbGxCeSB0byBicmVhayB0aGUgc3RpY2tpbmVzcy5cbiAgICovXG4gIHN0aWNreVNjcm9sbD86IGJvb2xlYW5cbn1cblxuLyoqXG4gKiBBIEJveCB3aXRoIGBvdmVyZmxvdzogc2Nyb2xsYCBhbmQgYW4gaW1wZXJhdGl2ZSBzY3JvbGwgQVBJLlxuICpcbiAqIENoaWxkcmVuIGFyZSBsYWlkIG91dCBhdCB0aGVpciBmdWxsIFlvZ2EtY29tcHV0ZWQgaGVpZ2h0IGluc2lkZSBhXG4gKiBjb25zdHJhaW5lZCBjb250YWluZXIuIEF0IHJlbmRlciB0aW1lLCBvbmx5IGNoaWxkcmVuIGludGVyc2VjdGluZyB0aGVcbiAqIHZpc2libGUgd2luZG93IChzY3JvbGxUb3AuLnNjcm9sbFRvcCtoZWlnaHQpIGFyZSByZW5kZXJlZCAodmlld3BvcnRcbiAqIGN1bGxpbmcpLiBDb250ZW50IGlzIHRyYW5zbGF0ZWQgYnkgLXNjcm9sbFRvcCBhbmQgY2xpcHBlZCB0byB0aGUgYm94IGJvdW5kcy5cbiAqXG4gKiBXb3JrcyBiZXN0IGluc2lkZSBhIGZ1bGxzY3JlZW4gKGNvbnN0cmFpbmVkLWhlaWdodCByb290KSBJbmsgdHJlZS5cbiAqL1xuZnVuY3Rpb24gU2Nyb2xsQm94KHtcbiAgY2hpbGRyZW4sXG4gIHJlZixcbiAgc3RpY2t5U2Nyb2xsLFxuICAuLi5zdHlsZVxufTogUHJvcHNXaXRoQ2hpbGRyZW48U2Nyb2xsQm94UHJvcHM+KTogUmVhY3QuUmVhY3ROb2RlIHtcbiAgY29uc3QgZG9tUmVmID0gdXNlUmVmPERPTUVsZW1lbnQ+KG51bGwpXG4gIC8vIHNjcm9sbFRvL3Njcm9sbEJ5IGJ5cGFzcyBSZWFjdDogdGhleSBtdXRhdGUgc2Nyb2xsVG9wIG9uIHRoZSBET00gbm9kZSxcbiAgLy8gbWFyayBpdCBkaXJ0eSwgYW5kIGNhbGwgdGhlIHJvb3QncyB0aHJvdHRsZWQgc2NoZWR1bGVSZW5kZXIgZGlyZWN0bHkuXG4gIC8vIFRoZSBJbmsgcmVuZGVyZXIgcmVhZHMgc2Nyb2xsVG9wIGZyb20gdGhlIG5vZGUg4oCUIG5vIFJlYWN0IHN0YXRlIG5lZWRlZCxcbiAgLy8gbm8gcmVjb25jaWxlciBvdmVyaGVhZCBwZXIgd2hlZWwgZXZlbnQuIFRoZSBtaWNyb3Rhc2sgZGVmZXIgY29hbGVzY2VzXG4gIC8vIG11bHRpcGxlIHNjcm9sbEJ5IGNhbGxzIGluIG9uZSBpbnB1dCBiYXRjaCAoZGlzY3JldGVVcGRhdGVzKSBpbnRvIG9uZVxuICAvLyByZW5kZXIg4oCUIG90aGVyd2lzZSBzY2hlZHVsZVJlbmRlcidzIGxlYWRpbmcgZWRnZSBmaXJlcyBvbiB0aGUgRklSU1RcbiAgLy8gZXZlbnQgYmVmb3JlIHN1YnNlcXVlbnQgZXZlbnRzIG11dGF0ZSBzY3JvbGxUb3AuIHNjcm9sbFRvQm90dG9tIHN0aWxsXG4gIC8vIGZvcmNlcyBhIFJlYWN0IHJlbmRlcjogc3RpY2t5IGlzIGF0dHJpYnV0ZS1vYnNlcnZlZCwgbm8gRE9NLW9ubHkgcGF0aC5cbiAgY29uc3QgWywgZm9yY2VSZW5kZXJdID0gdXNlU3RhdGUoMClcbiAgY29uc3QgbGlzdGVuZXJzUmVmID0gdXNlUmVmKG5ldyBTZXQ8KCkgPT4gdm9pZD4oKSlcbiAgY29uc3QgcmVuZGVyUXVldWVkUmVmID0gdXNlUmVmKGZhbHNlKVxuXG4gIGNvbnN0IG5vdGlmeSA9ICgpID0+IHtcbiAgICBmb3IgKGNvbnN0IGwgb2YgbGlzdGVuZXJzUmVmLmN1cnJlbnQpIGwoKVxuICB9XG5cbiAgZnVuY3Rpb24gc2Nyb2xsTXV0YXRlZChlbDogRE9NRWxlbWVudCk6IHZvaWQge1xuICAgIC8vIFNpZ25hbCBiYWNrZ3JvdW5kIGludGVydmFscyAoSURFIHBvbGwsIExTUCBwb2xsLCBHQ1MgZmV0Y2gsIG9ycGhhblxuICAgIC8vIGNoZWNrKSB0byBza2lwIHRoZWlyIG5leHQgdGljayDigJQgdGhleSBjb21wZXRlIGZvciB0aGUgZXZlbnQgbG9vcCBhbmRcbiAgICAvLyBjb250cmlidXRlZCB0byAxNDAybXMgbWF4IGZyYW1lIGdhcHMgZHVyaW5nIHNjcm9sbCBkcmFpbi5cbiAgICBtYXJrU2Nyb2xsQWN0aXZpdHkoKVxuICAgIG1hcmtEaXJ0eShlbClcbiAgICBtYXJrQ29tbWl0U3RhcnQoKVxuICAgIG5vdGlmeSgpXG4gICAgaWYgKHJlbmRlclF1ZXVlZFJlZi5jdXJyZW50KSByZXR1cm5cbiAgICByZW5kZXJRdWV1ZWRSZWYuY3VycmVudCA9IHRydWVcbiAgICBxdWV1ZU1pY3JvdGFzaygoKSA9PiB7XG4gICAgICByZW5kZXJRdWV1ZWRSZWYuY3VycmVudCA9IGZhbHNlXG4gICAgICBzY2hlZHVsZVJlbmRlckZyb20oZWwpXG4gICAgfSlcbiAgfVxuXG4gIHVzZUltcGVyYXRpdmVIYW5kbGUoXG4gICAgcmVmLFxuICAgICgpOiBTY3JvbGxCb3hIYW5kbGUgPT4gKHtcbiAgICAgIHNjcm9sbFRvKHk6IG51bWJlcikge1xuICAgICAgICBjb25zdCBlbCA9IGRvbVJlZi5jdXJyZW50XG4gICAgICAgIGlmICghZWwpIHJldHVyblxuICAgICAgICAvLyBFeHBsaWNpdCBmYWxzZSBvdmVycmlkZXMgdGhlIERPTSBhdHRyaWJ1dGUgc28gbWFudWFsIHNjcm9sbFxuICAgICAgICAvLyBicmVha3Mgc3RpY2tpbmVzcy4gUmVuZGVyIGNvZGUgY2hlY2tzID8/IHByZWNlZGVuY2UuXG4gICAgICAgIGVsLnN0aWNreVNjcm9sbCA9IGZhbHNlXG4gICAgICAgIGVsLnBlbmRpbmdTY3JvbGxEZWx0YSA9IHVuZGVmaW5lZFxuICAgICAgICBlbC5zY3JvbGxBbmNob3IgPSB1bmRlZmluZWRcbiAgICAgICAgZWwuc2Nyb2xsVG9wID0gTWF0aC5tYXgoMCwgTWF0aC5mbG9vcih5KSlcbiAgICAgICAgc2Nyb2xsTXV0YXRlZChlbClcbiAgICAgIH0sXG4gICAgICBzY3JvbGxUb0VsZW1lbnQoZWw6IERPTUVsZW1lbnQsIG9mZnNldCA9IDApIHtcbiAgICAgICAgY29uc3QgYm94ID0gZG9tUmVmLmN1cnJlbnRcbiAgICAgICAgaWYgKCFib3gpIHJldHVyblxuICAgICAgICBib3guc3RpY2t5U2Nyb2xsID0gZmFsc2VcbiAgICAgICAgYm94LnBlbmRpbmdTY3JvbGxEZWx0YSA9IHVuZGVmaW5lZFxuICAgICAgICBib3guc2Nyb2xsQW5jaG9yID0geyBlbCwgb2Zmc2V0IH1cbiAgICAgICAgc2Nyb2xsTXV0YXRlZChib3gpXG4gICAgICB9LFxuICAgICAgc2Nyb2xsQnkoZHk6IG51bWJlcikge1xuICAgICAgICBjb25zdCBlbCA9IGRvbVJlZi5jdXJyZW50XG4gICAgICAgIGlmICghZWwpIHJldHVyblxuICAgICAgICBlbC5zdGlja3lTY3JvbGwgPSBmYWxzZVxuICAgICAgICAvLyBXaGVlbCBpbnB1dCBjYW5jZWxzIGFueSBpbi1mbGlnaHQgYW5jaG9yIHNlZWsg4oCUIHVzZXIgb3ZlcnJpZGUuXG4gICAgICAgIGVsLnNjcm9sbEFuY2hvciA9IHVuZGVmaW5lZFxuICAgICAgICAvLyBBY2N1bXVsYXRlIGluIHBlbmRpbmdTY3JvbGxEZWx0YTsgcmVuZGVyZXIgZHJhaW5zIGl0IGF0IGEgY2FwcGVkXG4gICAgICAgIC8vIHJhdGUgc28gZmFzdCBmbGlja3Mgc2hvdyBpbnRlcm1lZGlhdGUgZnJhbWVzLiBQdXJlIGFjY3VtdWxhdG9yOlxuICAgICAgICAvLyBzY3JvbGwtdXAgZm9sbG93ZWQgYnkgc2Nyb2xsLWRvd24gbmF0dXJhbGx5IGNhbmNlbHMuXG4gICAgICAgIGVsLnBlbmRpbmdTY3JvbGxEZWx0YSA9IChlbC5wZW5kaW5nU2Nyb2xsRGVsdGEgPz8gMCkgKyBNYXRoLmZsb29yKGR5KVxuICAgICAgICBzY3JvbGxNdXRhdGVkKGVsKVxuICAgICAgfSxcbiAgICAgIHNjcm9sbFRvQm90dG9tKCkge1xuICAgICAgICBjb25zdCBlbCA9IGRvbVJlZi5jdXJyZW50XG4gICAgICAgIGlmICghZWwpIHJldHVyblxuICAgICAgICBlbC5wZW5kaW5nU2Nyb2xsRGVsdGEgPSB1bmRlZmluZWRcbiAgICAgICAgZWwuc3RpY2t5U2Nyb2xsID0gdHJ1ZVxuICAgICAgICBtYXJrRGlydHkoZWwpXG4gICAgICAgIG5vdGlmeSgpXG4gICAgICAgIGZvcmNlUmVuZGVyKG4gPT4gbiArIDEpXG4gICAgICB9LFxuICAgICAgZ2V0U2Nyb2xsVG9wKCkge1xuICAgICAgICByZXR1cm4gZG9tUmVmLmN1cnJlbnQ/LnNjcm9sbFRvcCA/PyAwXG4gICAgICB9LFxuICAgICAgZ2V0UGVuZGluZ0RlbHRhKCkge1xuICAgICAgICAvLyBBY2N1bXVsYXRlZC1idXQtbm90LXlldC1kcmFpbmVkIGRlbHRhLiB1c2VWaXJ0dWFsU2Nyb2xsIG5lZWRzXG4gICAgICAgIC8vIHRoaXMgdG8gbW91bnQgdGhlIHVuaW9uIFtjb21taXR0ZWQsIGNvbW1pdHRlZCtwZW5kaW5nXSByYW5nZSDigJRcbiAgICAgICAgLy8gb3RoZXJ3aXNlIGludGVybWVkaWF0ZSBkcmFpbiBmcmFtZXMgZmluZCBubyBjaGlsZHJlbiAoYmxhbmspLlxuICAgICAgICByZXR1cm4gZG9tUmVmLmN1cnJlbnQ/LnBlbmRpbmdTY3JvbGxEZWx0YSA/PyAwXG4gICAgICB9LFxuICAgICAgZ2V0U2Nyb2xsSGVpZ2h0KCkge1xuICAgICAgICByZXR1cm4gZG9tUmVmLmN1cnJlbnQ/LnNjcm9sbEhlaWdodCA/PyAwXG4gICAgICB9LFxuICAgICAgZ2V0RnJlc2hTY3JvbGxIZWlnaHQoKSB7XG4gICAgICAgIGNvbnN0IGNvbnRlbnQgPSBkb21SZWYuY3VycmVudD8uY2hpbGROb2Rlc1swXSBhcyBET01FbGVtZW50IHwgdW5kZWZpbmVkXG4gICAgICAgIHJldHVybiAoXG4gICAgICAgICAgY29udGVudD8ueW9nYU5vZGU/LmdldENvbXB1dGVkSGVpZ2h0KCkgPz9cbiAgICAgICAgICBkb21SZWYuY3VycmVudD8uc2Nyb2xsSGVpZ2h0ID8/XG4gICAgICAgICAgMFxuICAgICAgICApXG4gICAgICB9LFxuICAgICAgZ2V0Vmlld3BvcnRIZWlnaHQoKSB7XG4gICAgICAgIHJldHVybiBkb21SZWYuY3VycmVudD8uc2Nyb2xsVmlld3BvcnRIZWlnaHQgPz8gMFxuICAgICAgfSxcbiAgICAgIGdldFZpZXdwb3J0VG9wKCkge1xuICAgICAgICByZXR1cm4gZG9tUmVmLmN1cnJlbnQ/LnNjcm9sbFZpZXdwb3J0VG9wID8/IDBcbiAgICAgIH0sXG4gICAgICBpc1N0aWNreSgpIHtcbiAgICAgICAgY29uc3QgZWwgPSBkb21SZWYuY3VycmVudFxuICAgICAgICBpZiAoIWVsKSByZXR1cm4gZmFsc2VcbiAgICAgICAgcmV0dXJuIGVsLnN0aWNreVNjcm9sbCA/PyBCb29sZWFuKGVsLmF0dHJpYnV0ZXNbJ3N0aWNreVNjcm9sbCddKVxuICAgICAgfSxcbiAgICAgIHN1YnNjcmliZShsaXN0ZW5lcjogKCkgPT4gdm9pZCkge1xuICAgICAgICBsaXN0ZW5lcnNSZWYuY3VycmVudC5hZGQobGlzdGVuZXIpXG4gICAgICAgIHJldHVybiAoKSA9PiBsaXN0ZW5lcnNSZWYuY3VycmVudC5kZWxldGUobGlzdGVuZXIpXG4gICAgICB9LFxuICAgICAgc2V0Q2xhbXBCb3VuZHMobWluLCBtYXgpIHtcbiAgICAgICAgY29uc3QgZWwgPSBkb21SZWYuY3VycmVudFxuICAgICAgICBpZiAoIWVsKSByZXR1cm5cbiAgICAgICAgZWwuc2Nyb2xsQ2xhbXBNaW4gPSBtaW5cbiAgICAgICAgZWwuc2Nyb2xsQ2xhbXBNYXggPSBtYXhcbiAgICAgIH0sXG4gICAgfSksXG4gICAgLy8gbm90aWZ5L3Njcm9sbE11dGF0ZWQgYXJlIGlubGluZSAobm8gdXNlQ2FsbGJhY2spIGJ1dCBvbmx5IGNsb3NlIG92ZXJcbiAgICAvLyByZWZzICsgaW1wb3J0cyDigJQgc3RhYmxlLiBFbXB0eSBkZXBzIGF2b2lkcyByZWJ1aWxkaW5nIHRoZSBoYW5kbGUgb25cbiAgICAvLyBldmVyeSByZW5kZXIgKHdoaWNoIHJlLXJlZ2lzdGVycyB0aGUgcmVmID0gY2h1cm4pLlxuICAgIC8vIGVzbGludC1kaXNhYmxlLW5leHQtbGluZSByZWFjdC1ob29rcy9leGhhdXN0aXZlLWRlcHNcbiAgICBbXSxcbiAgKVxuXG4gIC8vIFN0cnVjdHVyZTogb3V0ZXIgdmlld3BvcnQgKG92ZXJmbG93OnNjcm9sbCwgY29uc3RyYWluZWQgaGVpZ2h0KSA+XG4gIC8vIGlubmVyIGNvbnRlbnQgKGZsZXhHcm93OjEsIGZsZXhTaHJpbms6MCDigJQgZmlsbHMgYXQgbGVhc3QgdGhlIHZpZXdwb3J0XG4gIC8vIGJ1dCBncm93cyBiZXlvbmQgaXQgZm9yIHRhbGwgY29udGVudCkuIGZsZXhHcm93OjEgbGV0cyBjaGlsZHJlbiB1c2VcbiAgLy8gc3BhY2VycyB0byBwaW4gZWxlbWVudHMgdG8gdGhlIGJvdHRvbSBvZiB0aGUgc2Nyb2xsIGFyZWEuIFlvZ2Enc1xuICAvLyBPdmVyZmxvdy5TY3JvbGwgcHJldmVudHMgdGhlIHZpZXdwb3J0IGZyb20gZ3Jvd2luZyB0byBmaXQgdGhlIGNvbnRlbnQuXG4gIC8vIFRoZSByZW5kZXJlciBjb21wdXRlcyBzY3JvbGxIZWlnaHQgZnJvbSB0aGUgY29udGVudCBib3ggYW5kIGN1bGxzXG4gIC8vIGNvbnRlbnQncyBjaGlsZHJlbiBiYXNlZCBvbiBzY3JvbGxUb3AuXG4gIC8vXG4gIC8vIHN0aWNreVNjcm9sbCBpcyBwYXNzZWQgYXMgYSBET00gYXR0cmlidXRlICh2aWEgaW5rLWJveCBkaXJlY3RseSkgc28gaXQnc1xuICAvLyBhdmFpbGFibGUgb24gdGhlIGZpcnN0IHJlbmRlciDigJQgcmVmIGNhbGxiYWNrcyBmaXJlIGFmdGVyIHRoZSBpbml0aWFsXG4gIC8vIGNvbW1pdCwgd2hpY2ggaXMgdG9vIGxhdGUgZm9yIHRoZSBmaXJzdCBmcmFtZS5cbiAgcmV0dXJuIChcbiAgICA8aW5rLWJveFxuICAgICAgcmVmPXtlbCA9PiB7XG4gICAgICAgIGRvbVJlZi5jdXJyZW50ID0gZWxcbiAgICAgICAgaWYgKGVsKSBlbC5zY3JvbGxUb3AgPz89IDBcbiAgICAgIH19XG4gICAgICBzdHlsZT17e1xuICAgICAgICBmbGV4V3JhcDogJ25vd3JhcCcsXG4gICAgICAgIGZsZXhEaXJlY3Rpb246IHN0eWxlLmZsZXhEaXJlY3Rpb24gPz8gJ3JvdycsXG4gICAgICAgIGZsZXhHcm93OiBzdHlsZS5mbGV4R3JvdyA/PyAwLFxuICAgICAgICBmbGV4U2hyaW5rOiBzdHlsZS5mbGV4U2hyaW5rID8/IDEsXG4gICAgICAgIC4uLnN0eWxlLFxuICAgICAgICBvdmVyZmxvd1g6ICdzY3JvbGwnLFxuICAgICAgICBvdmVyZmxvd1k6ICdzY3JvbGwnLFxuICAgICAgfX1cbiAgICAgIHsuLi4oc3RpY2t5U2Nyb2xsID8geyBzdGlja3lTY3JvbGw6IHRydWUgfSA6IHt9KX1cbiAgICA+XG4gICAgICA8Qm94IGZsZXhEaXJlY3Rpb249XCJjb2x1bW5cIiBmbGV4R3Jvdz17MX0gZmxleFNocmluaz17MH0gd2lkdGg9XCIxMDAlXCI+XG4gICAgICAgIHtjaGlsZHJlbn1cbiAgICAgIDwvQm94PlxuICAgIDwvaW5rLWJveD5cbiAgKVxufVxuXG5leHBvcnQgZGVmYXVsdCBTY3JvbGxCb3hcbiJdLCJtYXBwaW5ncyI6IkFBQUEsT0FBT0EsS0FBSyxJQUNWLEtBQUtDLGlCQUFpQixFQUN0QixLQUFLQyxHQUFHLEVBQ1JDLG1CQUFtQixFQUNuQkMsTUFBTSxFQUNOQyxRQUFRLFFBQ0gsT0FBTztBQUNkLGNBQWNDLE1BQU0sUUFBUSxXQUFXO0FBQ3ZDLFNBQVNDLGtCQUFrQixRQUFRLDBCQUEwQjtBQUM3RCxjQUFjQyxVQUFVLFFBQVEsV0FBVztBQUMzQyxTQUFTQyxTQUFTLEVBQUVDLGtCQUFrQixRQUFRLFdBQVc7QUFDekQsU0FBU0MsZUFBZSxRQUFRLGtCQUFrQjtBQUNsRCxjQUFjQyxNQUFNLFFBQVEsY0FBYztBQUMxQyxPQUFPLGdCQUFnQjtBQUN2QixPQUFPQyxHQUFHLE1BQU0sVUFBVTtBQUUxQixPQUFPLEtBQUtDLGVBQWUsR0FBRztFQUM1QkMsUUFBUSxFQUFFLENBQUNDLENBQUMsRUFBRSxNQUFNLEVBQUUsR0FBRyxJQUFJO0VBQzdCQyxRQUFRLEVBQUUsQ0FBQ0MsRUFBRSxFQUFFLE1BQU0sRUFBRSxHQUFHLElBQUk7RUFDOUI7QUFDRjtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7RUFDRUMsZUFBZSxFQUFFLENBQUNDLEVBQUUsRUFBRVosVUFBVSxFQUFFYSxNQUFlLENBQVIsRUFBRSxNQUFNLEVBQUUsR0FBRyxJQUFJO0VBQzFEQyxjQUFjLEVBQUUsR0FBRyxHQUFHLElBQUk7RUFDMUJDLFlBQVksRUFBRSxHQUFHLEdBQUcsTUFBTTtFQUMxQkMsZUFBZSxFQUFFLEdB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
\ No newline at end of file
diff --git a/src/ink/components/Spacer.tsx b/src/ink/components/Spacer.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..4d0af40f66646de8dec1f7d88dc4560f22ed68f6
--- /dev/null
+++ b/src/ink/components/Spacer.tsx
@@ -0,0 +1,20 @@
+import { c as _c } from "react/compiler-runtime";
+import React from 'react';
+import Box from './Box.js';
+
+/**
+ * A flexible space that expands along the major axis of its containing layout.
+ * It's useful as a shortcut for filling all the available spaces between elements.
+ */
+export default function Spacer() {
+ const $ = _c(1);
+ let t0;
+ if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
+ t0 = ;
+ $[0] = t0;
+ } else {
+ t0 = $[0];
+ }
+ return t0;
+}
+//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkJveCIsIlNwYWNlciIsIiQiLCJfYyIsInQwIiwiU3ltYm9sIiwiZm9yIl0sInNvdXJjZXMiOlsiU3BhY2VyLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgQm94IGZyb20gJy4vQm94LmpzJ1xuXG4vKipcbiAqIEEgZmxleGlibGUgc3BhY2UgdGhhdCBleHBhbmRzIGFsb25nIHRoZSBtYWpvciBheGlzIG9mIGl0cyBjb250YWluaW5nIGxheW91dC5cbiAqIEl0J3MgdXNlZnVsIGFzIGEgc2hvcnRjdXQgZm9yIGZpbGxpbmcgYWxsIHRoZSBhdmFpbGFibGUgc3BhY2VzIGJldHdlZW4gZWxlbWVudHMuXG4gKi9cbmV4cG9ydCBkZWZhdWx0IGZ1bmN0aW9uIFNwYWNlcigpIHtcbiAgcmV0dXJuIDxCb3ggZmxleEdyb3c9ezF9IC8+XG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLE1BQU0sT0FBTztBQUN6QixPQUFPQyxHQUFHLE1BQU0sVUFBVTs7QUFFMUI7QUFDQTtBQUNBO0FBQ0E7QUFDQSxlQUFlLFNBQUFDLE9BQUE7RUFBQSxNQUFBQyxDQUFBLEdBQUFDLEVBQUE7RUFBQSxJQUFBQyxFQUFBO0VBQUEsSUFBQUYsQ0FBQSxRQUFBRyxNQUFBLENBQUFDLEdBQUE7SUFDTkYsRUFBQSxJQUFDLEdBQUcsQ0FBVyxRQUFDLENBQUQsR0FBQyxHQUFJO0lBQUFGLENBQUEsTUFBQUUsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUYsQ0FBQTtFQUFBO0VBQUEsT0FBcEJFLEVBQW9CO0FBQUEiLCJpZ25vcmVMaXN0IjpbXX0=
\ No newline at end of file
diff --git a/src/ink/components/StdinContext.ts b/src/ink/components/StdinContext.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0b1a49717c1d4922f4c5917d30c86c54056dde4f
--- /dev/null
+++ b/src/ink/components/StdinContext.ts
@@ -0,0 +1,49 @@
+import { createContext } from 'react'
+import { EventEmitter } from '../events/emitter.js'
+import type { TerminalQuerier } from '../terminal-querier.js'
+
+export type Props = {
+ /**
+ * Stdin stream passed to `render()` in `options.stdin` or `process.stdin` by default. Useful if your app needs to handle user input.
+ */
+ readonly stdin: NodeJS.ReadStream
+
+ /**
+ * Ink exposes this function via own `` to be able to handle Ctrl+C, that's why you should use Ink's `setRawMode` instead of `process.stdin.setRawMode`.
+ * If the `stdin` stream passed to Ink does not support setRawMode, this function does nothing.
+ */
+ readonly setRawMode: (value: boolean) => void
+
+ /**
+ * A boolean flag determining if the current `stdin` supports `setRawMode`. A component using `setRawMode` might want to use `isRawModeSupported` to nicely fall back in environments where raw mode is not supported.
+ */
+ readonly isRawModeSupported: boolean
+
+ readonly internal_exitOnCtrlC: boolean
+
+ readonly internal_eventEmitter: EventEmitter
+
+ /** Query the terminal and await responses (DECRQM, OSC 11, etc.).
+ * Null only in the never-reached default context value. */
+ readonly internal_querier: TerminalQuerier | null
+}
+
+/**
+ * `StdinContext` is a React context, which exposes input stream.
+ */
+
+const StdinContext = createContext({
+ stdin: process.stdin,
+
+ internal_eventEmitter: new EventEmitter(),
+ setRawMode() {},
+ isRawModeSupported: false,
+
+ internal_exitOnCtrlC: true,
+ internal_querier: null,
+})
+
+// eslint-disable-next-line custom-rules/no-top-level-side-effects
+StdinContext.displayName = 'InternalStdinContext'
+
+export default StdinContext
diff --git a/src/ink/components/TerminalFocusContext.tsx b/src/ink/components/TerminalFocusContext.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e017b64ab18e01a8cb53561641c3439652c25efc
--- /dev/null
+++ b/src/ink/components/TerminalFocusContext.tsx
@@ -0,0 +1,52 @@
+import { c as _c } from "react/compiler-runtime";
+import React, { createContext, useMemo, useSyncExternalStore } from 'react';
+import { getTerminalFocused, getTerminalFocusState, subscribeTerminalFocus, type TerminalFocusState } from '../terminal-focus-state.js';
+export type { TerminalFocusState };
+export type TerminalFocusContextProps = {
+ readonly isTerminalFocused: boolean;
+ readonly terminalFocusState: TerminalFocusState;
+};
+const TerminalFocusContext = createContext({
+ isTerminalFocused: true,
+ terminalFocusState: 'unknown'
+});
+
+// eslint-disable-next-line custom-rules/no-top-level-side-effects
+TerminalFocusContext.displayName = 'TerminalFocusContext';
+
+// Separate component so App.tsx doesn't re-render on focus changes.
+// Children are a stable prop reference, so they don't re-render either —
+// only components that consume the context will re-render.
+export function TerminalFocusProvider(t0) {
+ const $ = _c(6);
+ const {
+ children
+ } = t0;
+ const isTerminalFocused = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocused);
+ const terminalFocusState = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocusState);
+ let t1;
+ if ($[0] !== isTerminalFocused || $[1] !== terminalFocusState) {
+ t1 = {
+ isTerminalFocused,
+ terminalFocusState
+ };
+ $[0] = isTerminalFocused;
+ $[1] = terminalFocusState;
+ $[2] = t1;
+ } else {
+ t1 = $[2];
+ }
+ const value = t1;
+ let t2;
+ if ($[3] !== children || $[4] !== value) {
+ t2 = {children};
+ $[3] = children;
+ $[4] = value;
+ $[5] = t2;
+ } else {
+ t2 = $[5];
+ }
+ return t2;
+}
+export default TerminalFocusContext;
+//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsImNyZWF0ZUNvbnRleHQiLCJ1c2VNZW1vIiwidXNlU3luY0V4dGVybmFsU3RvcmUiLCJnZXRUZXJtaW5hbEZvY3VzZWQiLCJnZXRUZXJtaW5hbEZvY3VzU3RhdGUiLCJzdWJzY3JpYmVUZXJtaW5hbEZvY3VzIiwiVGVybWluYWxGb2N1c1N0YXRlIiwiVGVybWluYWxGb2N1c0NvbnRleHRQcm9wcyIsImlzVGVybWluYWxGb2N1c2VkIiwidGVybWluYWxGb2N1c1N0YXRlIiwiVGVybWluYWxGb2N1c0NvbnRleHQiLCJkaXNwbGF5TmFtZSIsIlRlcm1pbmFsRm9jdXNQcm92aWRlciIsInQwIiwiJCIsIl9jIiwiY2hpbGRyZW4iLCJ0MSIsInZhbHVlIiwidDIiXSwic291cmNlcyI6WyJUZXJtaW5hbEZvY3VzQ29udGV4dC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0LCB7IGNyZWF0ZUNvbnRleHQsIHVzZU1lbW8sIHVzZVN5bmNFeHRlcm5hbFN0b3JlIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQge1xuICBnZXRUZXJtaW5hbEZvY3VzZWQsXG4gIGdldFRlcm1pbmFsRm9jdXNTdGF0ZSxcbiAgc3Vic2NyaWJlVGVybWluYWxGb2N1cyxcbiAgdHlwZSBUZXJtaW5hbEZvY3VzU3RhdGUsXG59IGZyb20gJy4uL3Rlcm1pbmFsLWZvY3VzLXN0YXRlLmpzJ1xuXG5leHBvcnQgdHlwZSB7IFRlcm1pbmFsRm9jdXNTdGF0ZSB9XG5cbmV4cG9ydCB0eXBlIFRlcm1pbmFsRm9jdXNDb250ZXh0UHJvcHMgPSB7XG4gIHJlYWRvbmx5IGlzVGVybWluYWxGb2N1c2VkOiBib29sZWFuXG4gIHJlYWRvbmx5IHRlcm1pbmFsRm9jdXNTdGF0ZTogVGVybWluYWxGb2N1c1N0YXRlXG59XG5cbmNvbnN0IFRlcm1pbmFsRm9jdXNDb250ZXh0ID0gY3JlYXRlQ29udGV4dDxUZXJtaW5hbEZvY3VzQ29udGV4dFByb3BzPih7XG4gIGlzVGVybWluYWxGb2N1c2VkOiB0cnVlLFxuICB0ZXJtaW5hbEZvY3VzU3RhdGU6ICd1bmtub3duJyxcbn0pXG5cbi8vIGVzbGludC1kaXNhYmxlLW5leHQtbGluZSBjdXN0b20tcnVsZXMvbm8tdG9wLWxldmVsLXNpZGUtZWZmZWN0c1xuVGVybWluYWxGb2N1c0NvbnRleHQuZGlzcGxheU5hbWUgPSAnVGVybWluYWxGb2N1c0NvbnRleHQnXG5cbi8vIFNlcGFyYXRlIGNvbXBvbmVudCBzbyBBcHAudHN4IGRvZXNuJ3QgcmUtcmVuZGVyIG9uIGZvY3VzIGNoYW5nZXMuXG4vLyBDaGlsZHJlbiBhcmUgYSBzdGFibGUgcHJvcCByZWZlcmVuY2UsIHNvIHRoZXkgZG9uJ3QgcmUtcmVuZGVyIGVpdGhlciDigJRcbi8vIG9ubHkgY29tcG9uZW50cyB0aGF0IGNvbnN1bWUgdGhlIGNvbnRleHQgd2lsbCByZS1yZW5kZXIuXG5leHBvcnQgZnVuY3Rpb24gVGVybWluYWxGb2N1c1Byb3ZpZGVyKHtcbiAgY2hpbGRyZW4sXG59OiB7XG4gIGNoaWxkcmVuOiBSZWFjdC5SZWFjdE5vZGVcbn0pOiBSZWFjdC5SZWFjdE5vZGUge1xuICBjb25zdCBpc1Rlcm1pbmFsRm9jdXNlZCA9IHVzZVN5bmNFeHRlcm5hbFN0b3JlKFxuICAgIHN1YnNjcmliZVRlcm1pbmFsRm9jdXMsXG4gICAgZ2V0VGVybWluYWxGb2N1c2VkLFxuICApXG4gIGNvbnN0IHRlcm1pbmFsRm9jdXNTdGF0ZSA9IHVzZVN5bmNFeHRlcm5hbFN0b3JlKFxuICAgIHN1YnNjcmliZVRlcm1pbmFsRm9jdXMsXG4gICAgZ2V0VGVybWluYWxGb2N1c1N0YXRlLFxuICApXG5cbiAgY29uc3QgdmFsdWUgPSB1c2VNZW1vKFxuICAgICgpID0+ICh7IGlzVGVybWluYWxGb2N1c2VkLCB0ZXJtaW5hbEZvY3VzU3RhdGUgfSksXG4gICAgW2lzVGVybWluYWxGb2N1c2VkLCB0ZXJtaW5hbEZvY3VzU3RhdGVdLFxuICApXG5cbiAgcmV0dXJuIChcbiAgICA8VGVybWluYWxGb2N1c0NvbnRleHQuUHJvdmlkZXIgdmFsdWU9e3ZhbHVlfT5cbiAgICAgIHtjaGlsZHJlbn1cbiAgICA8L1Rlcm1pbmFsRm9jdXNDb250ZXh0LlByb3ZpZGVyPlxuICApXG59XG5cbmV4cG9ydCBkZWZhdWx0IFRlcm1pbmFsRm9jdXNDb250ZXh0XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLElBQUlDLGFBQWEsRUFBRUMsT0FBTyxFQUFFQyxvQkFBb0IsUUFBUSxPQUFPO0FBQzNFLFNBQ0VDLGtCQUFrQixFQUNsQkMscUJBQXFCLEVBQ3JCQyxzQkFBc0IsRUFDdEIsS0FBS0Msa0JBQWtCLFFBQ2xCLDRCQUE0QjtBQUVuQyxjQUFjQSxrQkFBa0I7QUFFaEMsT0FBTyxLQUFLQyx5QkFBeUIsR0FBRztFQUN0QyxTQUFTQyxpQkFBaUIsRUFBRSxPQUFPO0VBQ25DLFNBQVNDLGtCQUFrQixFQUFFSCxrQkFBa0I7QUFDakQsQ0FBQztBQUVELE1BQU1JLG9CQUFvQixHQUFHVixhQUFhLENBQUNPLHlCQUF5QixDQUFDLENBQUM7RUFDcEVDLGlCQUFpQixFQUFFLElBQUk7RUFDdkJDLGtCQUFrQixFQUFFO0FBQ3RCLENBQUMsQ0FBQzs7QUFFRjtBQUNBQyxvQkFBb0IsQ0FBQ0MsV0FBVyxHQUFHLHNCQUFzQjs7QUFFekQ7QUFDQTtBQUNBO0FBQ0EsT0FBTyxTQUFBQyxzQkFBQUMsRUFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUErQjtJQUFBQztFQUFBLElBQUFILEVBSXJDO0VBQ0MsTUFBQUwsaUJBQUEsR0FBMEJOLG9CQUFvQixDQUM1Q0csc0JBQXNCLEVBQ3RCRixrQkFDRixDQUFDO0VBQ0QsTUFBQU0sa0JBQUEsR0FBMkJQLG9CQUFvQixDQUM3Q0csc0JBQXNCLEVBQ3RCRCxxQkFDRixDQUFDO0VBQUEsSUFBQWEsRUFBQTtFQUFBLElBQUFILENBQUEsUUFBQU4saUJBQUEsSUFBQU0sQ0FBQSxRQUFBTCxrQkFBQTtJQUdRUSxFQUFBO01BQUFULGlCQUFBO01BQUFDO0lBQXdDLENBQUM7SUFBQUssQ0FBQSxNQUFBTixpQkFBQTtJQUFBTSxDQUFBLE1BQUFMLGtCQUFBO0lBQUFLLENBQUEsTUFBQUcsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUgsQ0FBQTtFQUFBO0VBRGxELE1BQUFJLEtBQUEsR0FDU0QsRUFBeUM7RUFFakQsSUFBQUUsRUFBQTtFQUFBLElBQUFMLENBQUEsUUFBQUUsUUFBQSxJQUFBRixDQUFBLFFBQUFJLEtBQUE7SUFHQ0MsRUFBQSxrQ0FBc0NELEtBQUssQ0FBTEEsTUFBSSxDQUFDLENBQ3hDRixTQUFPLENBQ1YsZ0NBQWdDO0lBQUFGLENBQUEsTUFBQUUsUUFBQTtJQUFBRixDQUFBLE1BQUFJLEtBQUE7SUFBQUosQ0FBQSxNQUFBSyxFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBTCxDQUFBO0VBQUE7RUFBQSxPQUZoQ0ssRUFFZ0M7QUFBQTtBQUlwQyxlQUFlVCxvQkFBb0IiLCJpZ25vcmVMaXN0IjpbXX0=
\ No newline at end of file
diff --git a/src/ink/components/TerminalSizeContext.tsx b/src/ink/components/TerminalSizeContext.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8ca447ec4e96834aa20cdf4cba1f81943357874f
--- /dev/null
+++ b/src/ink/components/TerminalSizeContext.tsx
@@ -0,0 +1,7 @@
+import { createContext } from 'react';
+export type TerminalSize = {
+ columns: number;
+ rows: number;
+};
+export const TerminalSizeContext = createContext(null);
+//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJjcmVhdGVDb250ZXh0IiwiVGVybWluYWxTaXplIiwiY29sdW1ucyIsInJvd3MiLCJUZXJtaW5hbFNpemVDb250ZXh0Il0sInNvdXJjZXMiOlsiVGVybWluYWxTaXplQ29udGV4dC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgY3JlYXRlQ29udGV4dCB9IGZyb20gJ3JlYWN0J1xuXG5leHBvcnQgdHlwZSBUZXJtaW5hbFNpemUgPSB7XG4gIGNvbHVtbnM6IG51bWJlclxuICByb3dzOiBudW1iZXJcbn1cblxuZXhwb3J0IGNvbnN0IFRlcm1pbmFsU2l6ZUNvbnRleHQgPSBjcmVhdGVDb250ZXh0PFRlcm1pbmFsU2l6ZSB8IG51bGw+KG51bGwpXG4iXSwibWFwcGluZ3MiOiJBQUFBLFNBQVNBLGFBQWEsUUFBUSxPQUFPO0FBRXJDLE9BQU8sS0FBS0MsWUFBWSxHQUFHO0VBQ3pCQyxPQUFPLEVBQUUsTUFBTTtFQUNmQyxJQUFJLEVBQUUsTUFBTTtBQUNkLENBQUM7QUFFRCxPQUFPLE1BQU1DLG1CQUFtQixHQUFHSixhQUFhLENBQUNDLFlBQVksR0FBRyxJQUFJLENBQUMsQ0FBQyxJQUFJLENBQUMiLCJpZ25vcmVMaXN0IjpbXX0=
\ No newline at end of file
diff --git a/src/ink/events/click-event.ts b/src/ink/events/click-event.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1f58659a89bb7c5d7e8e24f875426ca959edc550
--- /dev/null
+++ b/src/ink/events/click-event.ts
@@ -0,0 +1,38 @@
+import { Event } from './event.js'
+
+/**
+ * Mouse click event. Fired on left-button release without drag, only when
+ * mouse tracking is enabled (i.e. inside ).
+ *
+ * Bubbles from the deepest hit node up through parentNode. Call
+ * stopImmediatePropagation() to prevent ancestors' onClick from firing.
+ */
+export class ClickEvent extends Event {
+ /** 0-indexed screen column of the click */
+ readonly col: number
+ /** 0-indexed screen row of the click */
+ readonly row: number
+ /**
+ * Click column relative to the current handler's Box (col - box.x).
+ * Recomputed by dispatchClick before each handler fires, so an onClick
+ * on a container sees coords relative to that container, not to any
+ * child the click landed on.
+ */
+ localCol = 0
+ /** Click row relative to the current handler's Box (row - box.y). */
+ localRow = 0
+ /**
+ * True if the clicked cell has no visible content (unwritten in the
+ * screen buffer — both packed words are 0). Handlers can check this to
+ * ignore clicks on blank space to the right of text, so accidental
+ * clicks on empty terminal space don't toggle state.
+ */
+ readonly cellIsBlank: boolean
+
+ constructor(col: number, row: number, cellIsBlank: boolean) {
+ super()
+ this.col = col
+ this.row = row
+ this.cellIsBlank = cellIsBlank
+ }
+}
diff --git a/src/ink/events/dispatcher.ts b/src/ink/events/dispatcher.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2d42884939a667e97ad83df35821d9e9daf28a25
--- /dev/null
+++ b/src/ink/events/dispatcher.ts
@@ -0,0 +1,235 @@
+import {
+ ContinuousEventPriority,
+ DefaultEventPriority,
+ DiscreteEventPriority,
+} from 'react-reconciler/constants.js'
+
+// NoEventPriority doesn't exist in react-reconciler 0.29, use 0 as fallback
+const NoEventPriority = 0;
+import { logError } from '../../utils/log.js'
+import { HANDLER_FOR_EVENT } from './event-handlers.js'
+import type { EventTarget, TerminalEvent } from './terminal-event.js'
+
+// --
+
+type DispatchListener = {
+ node: EventTarget
+ handler: (event: TerminalEvent) => void
+ phase: 'capturing' | 'at_target' | 'bubbling'
+}
+
+function getHandler(
+ node: EventTarget,
+ eventType: string,
+ capture: boolean,
+): ((event: TerminalEvent) => void) | undefined {
+ const handlers = node._eventHandlers
+ if (!handlers) return undefined
+
+ const mapping = HANDLER_FOR_EVENT[eventType]
+ if (!mapping) return undefined
+
+ const propName = capture ? mapping.capture : mapping.bubble
+ if (!propName) return undefined
+
+ return handlers[propName] as ((event: TerminalEvent) => void) | undefined
+}
+
+/**
+ * Collect all listeners for an event in dispatch order.
+ *
+ * Uses react-dom's two-phase accumulation pattern:
+ * - Walk from target to root
+ * - Capture handlers are prepended (unshift) → root-first
+ * - Bubble handlers are appended (push) → target-first
+ *
+ * Result: [root-cap, ..., parent-cap, target-cap, target-bub, parent-bub, ..., root-bub]
+ */
+function collectListeners(
+ target: EventTarget,
+ event: TerminalEvent,
+): DispatchListener[] {
+ const listeners: DispatchListener[] = []
+
+ let node: EventTarget | undefined = target
+ while (node) {
+ const isTarget = node === target
+
+ const captureHandler = getHandler(node, event.type, true)
+ const bubbleHandler = getHandler(node, event.type, false)
+
+ if (captureHandler) {
+ listeners.unshift({
+ node,
+ handler: captureHandler,
+ phase: isTarget ? 'at_target' : 'capturing',
+ })
+ }
+
+ if (bubbleHandler && (event.bubbles || isTarget)) {
+ listeners.push({
+ node,
+ handler: bubbleHandler,
+ phase: isTarget ? 'at_target' : 'bubbling',
+ })
+ }
+
+ node = node.parentNode
+ }
+
+ return listeners
+}
+
+/**
+ * Execute collected listeners with propagation control.
+ *
+ * Before each handler, calls event._prepareForTarget(node) so event
+ * subclasses can do per-node setup.
+ */
+function processDispatchQueue(
+ listeners: DispatchListener[],
+ event: TerminalEvent,
+): void {
+ let previousNode: EventTarget | undefined
+
+ for (const { node, handler, phase } of listeners) {
+ if (event._isImmediatePropagationStopped()) {
+ break
+ }
+
+ if (event._isPropagationStopped() && node !== previousNode) {
+ break
+ }
+
+ event._setEventPhase(phase)
+ event._setCurrentTarget(node)
+ event._prepareForTarget(node)
+
+ try {
+ handler(event)
+ } catch (error) {
+ logError(error)
+ }
+
+ previousNode = node
+ }
+}
+
+// --
+
+/**
+ * Map terminal event types to React scheduling priorities.
+ * Mirrors react-dom's getEventPriority() switch.
+ */
+function getEventPriority(eventType: string): number {
+ switch (eventType) {
+ case 'keydown':
+ case 'keyup':
+ case 'click':
+ case 'focus':
+ case 'blur':
+ case 'paste':
+ return DiscreteEventPriority as number
+ case 'resize':
+ case 'scroll':
+ case 'mousemove':
+ return ContinuousEventPriority as number
+ default:
+ return DefaultEventPriority as number
+ }
+}
+
+// --
+
+type DiscreteUpdates = (
+ fn: (a: A, b: B) => boolean,
+ a: A,
+ b: B,
+ c: undefined,
+ d: undefined,
+) => boolean
+
+/**
+ * Owns event dispatch state and the capture/bubble dispatch loop.
+ *
+ * The reconciler host config reads currentEvent and currentUpdatePriority
+ * to implement resolveUpdatePriority, resolveEventType, and
+ * resolveEventTimeStamp — mirroring how react-dom's host config reads
+ * ReactDOMSharedInternals and window.event.
+ *
+ * discreteUpdates is injected after construction (by InkReconciler)
+ * to break the import cycle.
+ */
+export class Dispatcher {
+ currentEvent: TerminalEvent | null = null
+ currentUpdatePriority: number = DefaultEventPriority as number
+ discreteUpdates: DiscreteUpdates | null = null
+
+ /**
+ * Infer event priority from the currently-dispatching event.
+ * Called by the reconciler host config's resolveUpdatePriority
+ * when no explicit priority has been set.
+ */
+ resolveEventPriority(): number {
+ if (this.currentUpdatePriority !== (NoEventPriority as number)) {
+ return this.currentUpdatePriority
+ }
+ if (this.currentEvent) {
+ return getEventPriority(this.currentEvent.type)
+ }
+ return DefaultEventPriority as number
+ }
+
+ /**
+ * Dispatch an event through capture and bubble phases.
+ * Returns true if preventDefault() was NOT called.
+ */
+ dispatch(target: EventTarget, event: TerminalEvent): boolean {
+ const previousEvent = this.currentEvent
+ this.currentEvent = event
+ try {
+ event._setTarget(target)
+
+ const listeners = collectListeners(target, event)
+ processDispatchQueue(listeners, event)
+
+ event._setEventPhase('none')
+ event._setCurrentTarget(null)
+
+ return !event.defaultPrevented
+ } finally {
+ this.currentEvent = previousEvent
+ }
+ }
+
+ /**
+ * Dispatch with discrete (sync) priority.
+ * For user-initiated events: keyboard, click, focus, paste.
+ */
+ dispatchDiscrete(target: EventTarget, event: TerminalEvent): boolean {
+ if (!this.discreteUpdates) {
+ return this.dispatch(target, event)
+ }
+ return this.discreteUpdates(
+ (t, e) => this.dispatch(t, e),
+ target,
+ event,
+ undefined,
+ undefined,
+ )
+ }
+
+ /**
+ * Dispatch with continuous priority.
+ * For high-frequency events: resize, scroll, mouse move.
+ */
+ dispatchContinuous(target: EventTarget, event: TerminalEvent): boolean {
+ const previousPriority = this.currentUpdatePriority
+ try {
+ this.currentUpdatePriority = ContinuousEventPriority as number
+ return this.dispatch(target, event)
+ } finally {
+ this.currentUpdatePriority = previousPriority
+ }
+ }
+}
diff --git a/src/ink/events/emitter.ts b/src/ink/events/emitter.ts
new file mode 100644
index 0000000000000000000000000000000000000000..56a4b0dc69a0c4af3b6cb3a3421ff901a4075200
--- /dev/null
+++ b/src/ink/events/emitter.ts
@@ -0,0 +1,39 @@
+import { EventEmitter as NodeEventEmitter } from 'events'
+import { Event } from './event.js'
+
+// Similar to node's builtin EventEmitter, but is also aware of our `Event`
+// class, and so `emit` respects `stopImmediatePropagation()`.
+export class EventEmitter extends NodeEventEmitter {
+ constructor() {
+ super()
+ // Disable the default maxListeners warning. In React, many components
+ // can legitimately listen to the same event (e.g., useInput hooks).
+ // The default limit of 10 causes spurious warnings.
+ this.setMaxListeners(0)
+ }
+
+ override emit(type: string | symbol, ...args: unknown[]): boolean {
+ // Delegate to node for `error`, since it's not treated like a normal event
+ if (type === 'error') {
+ return super.emit(type, ...args)
+ }
+
+ const listeners = this.rawListeners(type)
+
+ if (listeners.length === 0) {
+ return false
+ }
+
+ const ccEvent = args[0] instanceof Event ? args[0] : null
+
+ for (const listener of listeners) {
+ listener.apply(this, args)
+
+ if (ccEvent?.didStopImmediatePropagation()) {
+ break
+ }
+ }
+
+ return true
+ }
+}
diff --git a/src/ink/events/event-handlers.ts b/src/ink/events/event-handlers.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7865f5b3c721f75525d4ec351f9558ded7d60b40
--- /dev/null
+++ b/src/ink/events/event-handlers.ts
@@ -0,0 +1,73 @@
+import type { ClickEvent } from './click-event.js'
+import type { FocusEvent } from './focus-event.js'
+import type { KeyboardEvent } from './keyboard-event.js'
+import type { PasteEvent } from './paste-event.js'
+import type { ResizeEvent } from './resize-event.js'
+
+type KeyboardEventHandler = (event: KeyboardEvent) => void
+type FocusEventHandler = (event: FocusEvent) => void
+type PasteEventHandler = (event: PasteEvent) => void
+type ResizeEventHandler = (event: ResizeEvent) => void
+type ClickEventHandler = (event: ClickEvent) => void
+type HoverEventHandler = () => void
+
+/**
+ * Props for event handlers on Box and other host components.
+ *
+ * Follows the React/DOM naming convention:
+ * - onEventName: handler for bubble phase
+ * - onEventNameCapture: handler for capture phase
+ */
+export type EventHandlerProps = {
+ onKeyDown?: KeyboardEventHandler
+ onKeyDownCapture?: KeyboardEventHandler
+
+ onFocus?: FocusEventHandler
+ onFocusCapture?: FocusEventHandler
+ onBlur?: FocusEventHandler
+ onBlurCapture?: FocusEventHandler
+
+ onPaste?: PasteEventHandler
+ onPasteCapture?: PasteEventHandler
+
+ onResize?: ResizeEventHandler
+
+ onClick?: ClickEventHandler
+ onMouseEnter?: HoverEventHandler
+ onMouseLeave?: HoverEventHandler
+}
+
+/**
+ * Reverse lookup: event type string → handler prop names.
+ * Used by the dispatcher for O(1) handler lookup per node.
+ */
+export const HANDLER_FOR_EVENT: Record<
+ string,
+ { bubble?: keyof EventHandlerProps; capture?: keyof EventHandlerProps }
+> = {
+ keydown: { bubble: 'onKeyDown', capture: 'onKeyDownCapture' },
+ focus: { bubble: 'onFocus', capture: 'onFocusCapture' },
+ blur: { bubble: 'onBlur', capture: 'onBlurCapture' },
+ paste: { bubble: 'onPaste', capture: 'onPasteCapture' },
+ resize: { bubble: 'onResize' },
+ click: { bubble: 'onClick' },
+}
+
+/**
+ * Set of all event handler prop names, for the reconciler to detect
+ * event props and store them in _eventHandlers instead of attributes.
+ */
+export const EVENT_HANDLER_PROPS = new Set([
+ 'onKeyDown',
+ 'onKeyDownCapture',
+ 'onFocus',
+ 'onFocusCapture',
+ 'onBlur',
+ 'onBlurCapture',
+ 'onPaste',
+ 'onPasteCapture',
+ 'onResize',
+ 'onClick',
+ 'onMouseEnter',
+ 'onMouseLeave',
+])
diff --git a/src/ink/events/event.ts b/src/ink/events/event.ts
new file mode 100644
index 0000000000000000000000000000000000000000..61874002ebbdb476ffe9a2a0db8001ec6aefb178
--- /dev/null
+++ b/src/ink/events/event.ts
@@ -0,0 +1,11 @@
+export class Event {
+ private _didStopImmediatePropagation = false
+
+ didStopImmediatePropagation(): boolean {
+ return this._didStopImmediatePropagation
+ }
+
+ stopImmediatePropagation(): void {
+ this._didStopImmediatePropagation = true
+ }
+}
diff --git a/src/ink/events/focus-event.ts b/src/ink/events/focus-event.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a552e54561162cb97469e1160d1529e30d17ed48
--- /dev/null
+++ b/src/ink/events/focus-event.ts
@@ -0,0 +1,21 @@
+import { type EventTarget, TerminalEvent } from './terminal-event.js'
+
+/**
+ * Focus event for component focus changes.
+ *
+ * Dispatched when focus moves between elements. 'focus' fires on the
+ * newly focused element, 'blur' fires on the previously focused one.
+ * Both bubble, matching react-dom's use of focusin/focusout semantics
+ * so parent components can observe descendant focus changes.
+ */
+export class FocusEvent extends TerminalEvent {
+ readonly relatedTarget: EventTarget | null
+
+ constructor(
+ type: 'focus' | 'blur',
+ relatedTarget: EventTarget | null = null,
+ ) {
+ super(type, { bubbles: true, cancelable: false })
+ this.relatedTarget = relatedTarget
+ }
+}
diff --git a/src/ink/events/input-event.ts b/src/ink/events/input-event.ts
new file mode 100644
index 0000000000000000000000000000000000000000..490502811fefaa513b133f9c840904c7ce63a907
--- /dev/null
+++ b/src/ink/events/input-event.ts
@@ -0,0 +1,205 @@
+import { nonAlphanumericKeys, type ParsedKey } from '../parse-keypress.js'
+import { Event } from './event.js'
+
+export type Key = {
+ upArrow: boolean
+ downArrow: boolean
+ leftArrow: boolean
+ rightArrow: boolean
+ pageDown: boolean
+ pageUp: boolean
+ wheelUp: boolean
+ wheelDown: boolean
+ home: boolean
+ end: boolean
+ return: boolean
+ escape: boolean
+ ctrl: boolean
+ shift: boolean
+ fn: boolean
+ tab: boolean
+ backspace: boolean
+ delete: boolean
+ meta: boolean
+ super: boolean
+}
+
+function parseKey(keypress: ParsedKey): [Key, string] {
+ const key: Key = {
+ upArrow: keypress.name === 'up',
+ downArrow: keypress.name === 'down',
+ leftArrow: keypress.name === 'left',
+ rightArrow: keypress.name === 'right',
+ pageDown: keypress.name === 'pagedown',
+ pageUp: keypress.name === 'pageup',
+ wheelUp: keypress.name === 'wheelup',
+ wheelDown: keypress.name === 'wheeldown',
+ home: keypress.name === 'home',
+ end: keypress.name === 'end',
+ return: keypress.name === 'return',
+ escape: keypress.name === 'escape',
+ fn: keypress.fn,
+ ctrl: keypress.ctrl,
+ shift: keypress.shift,
+ tab: keypress.name === 'tab',
+ backspace: keypress.name === 'backspace',
+ delete: keypress.name === 'delete',
+ // `parseKeypress` parses \u001B\u001B[A (meta + up arrow) as meta = false
+ // but with option = true, so we need to take this into account here
+ // to avoid breaking changes in Ink.
+ // TODO(vadimdemedes): consider removing this in the next major version.
+ meta: keypress.meta || keypress.name === 'escape' || keypress.option,
+ // Super (Cmd on macOS / Win key) — only arrives via kitty keyboard
+ // protocol CSI u sequences. Distinct from meta (Alt/Option) so
+ // bindings like cmd+c can be expressed separately from opt+c.
+ super: keypress.super,
+ }
+
+ let input = keypress.ctrl ? keypress.name : keypress.sequence
+
+ // Handle undefined input case
+ if (input === undefined) {
+ input = ''
+ }
+
+ // When ctrl is set, keypress.name for space is the literal word "space".
+ // Convert to actual space character for consistency with the CSI u branch
+ // (which maps 'space' → ' '). Without this, ctrl+space leaks the literal
+ // word "space" into text input.
+ if (keypress.ctrl && input === 'space') {
+ input = ' '
+ }
+
+ // Suppress unrecognized escape sequences that were parsed as function keys
+ // (matched by FN_KEY_RE) but have no name in the keyName map.
+ // Examples: ESC[25~ (F13/Right Alt on Windows), ESC[26~ (F14), etc.
+ // Without this, the ESC prefix is stripped below and the remainder (e.g.,
+ // "[25~") leaks into the input as literal text.
+ if (keypress.code && !keypress.name) {
+ input = ''
+ }
+
+ // Suppress ESC-less SGR mouse fragments. When a heavy React commit blocks
+ // the event loop past App's 50ms NORMAL_TIMEOUT flush, a CSI split across
+ // stdin chunks gets its buffered ESC flushed as a lone Escape key, and the
+ // continuation arrives as a text token with name='' — which falls through
+ // all of parseKeypress's ESC-anchored regexes and the nonAlphanumericKeys
+ // clear below (name is falsy). The fragment then leaks into the prompt as
+ // literal `[<64;74;16M`. This is the same defensive sink as the F13 guard
+ // above; the underlying tokenizer-flush race is upstream of this layer.
+ if (!keypress.name && /^\[<\d+;\d+;\d+[Mm]/.test(input)) {
+ input = ''
+ }
+
+ // Strip meta if it's still remaining after `parseKeypress`
+ // TODO(vadimdemedes): remove this in the next major version.
+ if (input.startsWith('\u001B')) {
+ input = input.slice(1)
+ }
+
+ // Track whether we've already processed this as a special sequence
+ // that converted input to the key name (CSI u or application keypad mode).
+ // For these, we don't want to clear input with nonAlphanumericKeys check.
+ let processedAsSpecialSequence = false
+
+ // Handle CSI u sequences (Kitty keyboard protocol): after stripping ESC,
+ // we're left with "[codepoint;modifieru" (e.g., "[98;3u" for Alt+b).
+ // Use the parsed key name instead for input handling. Require a digit
+ // after [ — real CSI u is always […u, and a bare startsWith('[')
+ // false-matches X10 mouse at row 85 (Cy = 85+32 = 'u'), leaking the
+ // literal text "mouse" into the prompt via processedAsSpecialSequence.
+ if (/^\[\d/.test(input) && input.endsWith('u')) {
+ if (!keypress.name) {
+ // Unmapped Kitty functional key (Caps Lock 57358, F13–F35, KP nav,
+ // bare modifiers, etc.) — keycodeToName() returned undefined. Swallow
+ // so the raw "[57358u" doesn't leak into the prompt. See #38781.
+ input = ''
+ } else {
+ // 'space' → ' '; 'escape' → '' (key.escape carries it;
+ // processedAsSpecialSequence bypasses the nonAlphanumericKeys
+ // clear below, so we must handle it explicitly here);
+ // otherwise use key name.
+ input =
+ keypress.name === 'space'
+ ? ' '
+ : keypress.name === 'escape'
+ ? ''
+ : keypress.name
+ }
+ processedAsSpecialSequence = true
+ }
+
+ // Handle xterm modifyOtherKeys sequences: after stripping ESC, we're left
+ // with "[27;modifier;keycode~" (e.g., "[27;3;98~" for Alt+b). Same
+ // extraction as CSI u — without this, printable-char keycodes (single-letter
+ // names) skip the nonAlphanumericKeys clear and leak "[27;..." as input.
+ if (input.startsWith('[27;') && input.endsWith('~')) {
+ if (!keypress.name) {
+ // Unmapped modifyOtherKeys keycode — swallow for consistency with
+ // the CSI u handler above. Practically untriggerable today (xterm
+ // modifyOtherKeys only sends ASCII keycodes, all mapped), but
+ // guards against future terminal behavior.
+ input = ''
+ } else {
+ input =
+ keypress.name === 'space'
+ ? ' '
+ : keypress.name === 'escape'
+ ? ''
+ : keypress.name
+ }
+ processedAsSpecialSequence = true
+ }
+
+ // Handle application keypad mode sequences: after stripping ESC,
+ // we're left with "O" (e.g., "Op" for numpad 0, "Oy" for numpad 9).
+ // Use the parsed key name (the digit character) for input handling.
+ if (
+ input.startsWith('O') &&
+ input.length === 2 &&
+ keypress.name &&
+ keypress.name.length === 1
+ ) {
+ input = keypress.name
+ processedAsSpecialSequence = true
+ }
+
+ // Clear input for non-alphanumeric keys (arrows, function keys, etc.)
+ // Skip this for CSI u and application keypad mode sequences since
+ // those were already converted to their proper input characters.
+ if (
+ !processedAsSpecialSequence &&
+ keypress.name &&
+ nonAlphanumericKeys.includes(keypress.name)
+ ) {
+ input = ''
+ }
+
+ // Set shift=true for uppercase letters (A-Z)
+ // Must check it's actually a letter, not just any char unchanged by toUpperCase
+ if (
+ input.length === 1 &&
+ typeof input[0] === 'string' &&
+ input[0] >= 'A' &&
+ input[0] <= 'Z'
+ ) {
+ key.shift = true
+ }
+
+ return [key, input]
+}
+
+export class InputEvent extends Event {
+ readonly keypress: ParsedKey
+ readonly key: Key
+ readonly input: string
+
+ constructor(keypress: ParsedKey) {
+ super()
+ const [key, input] = parseKey(keypress)
+
+ this.keypress = keypress
+ this.key = key
+ this.input = input
+ }
+}
diff --git a/src/ink/events/keyboard-event.ts b/src/ink/events/keyboard-event.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1210efd13da400294fbf47a09358632f80e6cd80
--- /dev/null
+++ b/src/ink/events/keyboard-event.ts
@@ -0,0 +1,51 @@
+import type { ParsedKey } from '../parse-keypress.js'
+import { TerminalEvent } from './terminal-event.js'
+
+/**
+ * Keyboard event dispatched through the DOM tree via capture/bubble.
+ *
+ * Follows browser KeyboardEvent semantics: `key` is the literal character
+ * for printable keys ('a', '3', ' ', '/') and a multi-char name for
+ * special keys ('down', 'return', 'escape', 'f1'). The idiomatic
+ * printable-char check is `e.key.length === 1`.
+ */
+export class KeyboardEvent extends TerminalEvent {
+ readonly key: string
+ readonly ctrl: boolean
+ readonly shift: boolean
+ readonly meta: boolean
+ readonly superKey: boolean
+ readonly fn: boolean
+
+ constructor(parsedKey: ParsedKey) {
+ super('keydown', { bubbles: true, cancelable: true })
+
+ this.key = keyFromParsed(parsedKey)
+ this.ctrl = parsedKey.ctrl
+ this.shift = parsedKey.shift
+ this.meta = parsedKey.meta || parsedKey.option
+ this.superKey = parsedKey.super
+ this.fn = parsedKey.fn
+ }
+}
+
+function keyFromParsed(parsed: ParsedKey): string {
+ const seq = parsed.sequence ?? ''
+ const name = parsed.name ?? ''
+
+ // Ctrl combos: sequence is a control byte (\x03 for ctrl+c), name is the
+ // letter. Browsers report e.key === 'c' with e.ctrlKey === true.
+ if (parsed.ctrl) return name
+
+ // Single printable char (space through ~, plus anything above ASCII):
+ // use the literal char. Browsers report e.key === '3', not 'Digit3'.
+ if (seq.length === 1) {
+ const code = seq.charCodeAt(0)
+ if (code >= 0x20 && code !== 0x7f) return seq
+ }
+
+ // Special keys (arrows, F-keys, return, tab, escape, etc.): sequence is
+ // either an escape sequence (\x1b[B) or a control byte (\r, \t), so use
+ // the parsed name. Browsers report e.key === 'ArrowDown'.
+ return name || seq
+}
diff --git a/src/ink/events/terminal-event.ts b/src/ink/events/terminal-event.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9a86bf8b296dac649b632ff172a470933d1f8493
--- /dev/null
+++ b/src/ink/events/terminal-event.ts
@@ -0,0 +1,107 @@
+import { Event } from './event.js'
+
+type EventPhase = 'none' | 'capturing' | 'at_target' | 'bubbling'
+
+type TerminalEventInit = {
+ bubbles?: boolean
+ cancelable?: boolean
+}
+
+/**
+ * Base class for all terminal events with DOM-style propagation.
+ *
+ * Extends Event so existing event types (ClickEvent, InputEvent,
+ * TerminalFocusEvent) share a common ancestor and can migrate later.
+ *
+ * Mirrors the browser's Event API: target, currentTarget, eventPhase,
+ * stopPropagation(), preventDefault(), timeStamp.
+ */
+export class TerminalEvent extends Event {
+ readonly type: string
+ readonly timeStamp: number
+ readonly bubbles: boolean
+ readonly cancelable: boolean
+
+ private _target: EventTarget | null = null
+ private _currentTarget: EventTarget | null = null
+ private _eventPhase: EventPhase = 'none'
+ private _propagationStopped = false
+ private _defaultPrevented = false
+
+ constructor(type: string, init?: TerminalEventInit) {
+ super()
+ this.type = type
+ this.timeStamp = performance.now()
+ this.bubbles = init?.bubbles ?? true
+ this.cancelable = init?.cancelable ?? true
+ }
+
+ get target(): EventTarget | null {
+ return this._target
+ }
+
+ get currentTarget(): EventTarget | null {
+ return this._currentTarget
+ }
+
+ get eventPhase(): EventPhase {
+ return this._eventPhase
+ }
+
+ get defaultPrevented(): boolean {
+ return this._defaultPrevented
+ }
+
+ stopPropagation(): void {
+ this._propagationStopped = true
+ }
+
+ override stopImmediatePropagation(): void {
+ super.stopImmediatePropagation()
+ this._propagationStopped = true
+ }
+
+ preventDefault(): void {
+ if (this.cancelable) {
+ this._defaultPrevented = true
+ }
+ }
+
+ // -- Internal setters used by the Dispatcher
+
+ /** @internal */
+ _setTarget(target: EventTarget): void {
+ this._target = target
+ }
+
+ /** @internal */
+ _setCurrentTarget(target: EventTarget | null): void {
+ this._currentTarget = target
+ }
+
+ /** @internal */
+ _setEventPhase(phase: EventPhase): void {
+ this._eventPhase = phase
+ }
+
+ /** @internal */
+ _isPropagationStopped(): boolean {
+ return this._propagationStopped
+ }
+
+ /** @internal */
+ _isImmediatePropagationStopped(): boolean {
+ return this.didStopImmediatePropagation()
+ }
+
+ /**
+ * Hook for subclasses to do per-node setup before each handler fires.
+ * Default is a no-op.
+ */
+ _prepareForTarget(_target: EventTarget): void {}
+}
+
+export type EventTarget = {
+ parentNode: EventTarget | undefined
+ _eventHandlers?: Record
+}
diff --git a/src/ink/events/terminal-focus-event.ts b/src/ink/events/terminal-focus-event.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6d0303fdb48c824d9ca7b104d0180ec7f9a5b0e8
--- /dev/null
+++ b/src/ink/events/terminal-focus-event.ts
@@ -0,0 +1,19 @@
+import { Event } from './event.js'
+
+export type TerminalFocusEventType = 'terminalfocus' | 'terminalblur'
+
+/**
+ * Event fired when the terminal window gains or loses focus.
+ *
+ * Uses DECSET 1004 focus reporting - the terminal sends:
+ * - CSI I (\x1b[I) when the terminal gains focus
+ * - CSI O (\x1b[O) when the terminal loses focus
+ */
+export class TerminalFocusEvent extends Event {
+ readonly type: TerminalFocusEventType
+
+ constructor(type: TerminalFocusEventType) {
+ super()
+ this.type = type
+ }
+}
diff --git a/src/ink/hooks/use-animation-frame.ts b/src/ink/hooks/use-animation-frame.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d4dd38a1657f8f4ccff70211f269353291da7ce6
--- /dev/null
+++ b/src/ink/hooks/use-animation-frame.ts
@@ -0,0 +1,57 @@
+import { useContext, useEffect, useState } from 'react'
+import { ClockContext } from '../components/ClockContext.js'
+import type { DOMElement } from '../dom.js'
+import { useTerminalViewport } from './use-terminal-viewport.js'
+
+/**
+ * Hook for synchronized animations that pause when offscreen.
+ *
+ * Returns a ref to attach to the animated element and the current animation time.
+ * All instances share the same clock, so animations stay in sync.
+ * The clock only runs when at least one keepAlive subscriber exists.
+ *
+ * Pass `null` to pause — unsubscribes from the clock so no ticks fire.
+ * Time freezes at the last value and resumes from the current clock time
+ * when a number is passed again.
+ *
+ * @param intervalMs - How often to update, or null to pause
+ * @returns [ref, time] - Ref to attach to element, elapsed time in ms
+ *
+ * @example
+ * function Spinner() {
+ * const [ref, time] = useAnimationFrame(120)
+ * const frame = Math.floor(time / 120) % FRAMES.length
+ * return {FRAMES[frame]}
+ * }
+ *
+ * The clock automatically slows when the terminal is blurred,
+ * so consumers don't need to handle focus state.
+ */
+export function useAnimationFrame(
+ intervalMs: number | null = 16,
+): [ref: (element: DOMElement | null) => void, time: number] {
+ const clock = useContext(ClockContext)
+ const [viewportRef, { isVisible }] = useTerminalViewport()
+ const [time, setTime] = useState(() => clock?.now() ?? 0)
+
+ const active = isVisible && intervalMs !== null
+
+ useEffect(() => {
+ if (!clock || !active) return
+
+ let lastUpdate = clock.now()
+
+ const onChange = (): void => {
+ const now = clock.now()
+ if (now - lastUpdate >= intervalMs!) {
+ lastUpdate = now
+ setTime(now)
+ }
+ }
+
+ // keepAlive: true — visible animations drive the clock
+ return clock.subscribe(onChange, true)
+ }, [clock, intervalMs, active])
+
+ return [viewportRef, time]
+}
diff --git a/src/ink/hooks/use-app.ts b/src/ink/hooks/use-app.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5545f35f7bf25ffdf858658243495f5bc9aca346
--- /dev/null
+++ b/src/ink/hooks/use-app.ts
@@ -0,0 +1,8 @@
+import { useContext } from 'react'
+import AppContext from '../components/AppContext.js'
+
+/**
+ * `useApp` is a React hook, which exposes a method to manually exit the app (unmount).
+ */
+const useApp = () => useContext(AppContext)
+export default useApp
diff --git a/src/ink/hooks/use-declared-cursor.ts b/src/ink/hooks/use-declared-cursor.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e49668b3885ba68a29e433f0370a6a24152cd760
--- /dev/null
+++ b/src/ink/hooks/use-declared-cursor.ts
@@ -0,0 +1,73 @@
+import { useCallback, useContext, useLayoutEffect, useRef } from 'react'
+import CursorDeclarationContext from '../components/CursorDeclarationContext.js'
+import type { DOMElement } from '../dom.js'
+
+/**
+ * Declares where the terminal cursor should be parked after each frame.
+ *
+ * Terminal emulators render IME preedit text at the physical cursor
+ * position, and screen readers / screen magnifiers track the native
+ * cursor — so parking it at the text input's caret makes CJK input
+ * appear inline and lets accessibility tools follow the input.
+ *
+ * Returns a ref callback to attach to the Box that contains the input.
+ * The declared (line, column) is interpreted relative to that Box's
+ * nodeCache rect (populated by renderNodeToOutput).
+ *
+ * Timing: Both ref attach and useLayoutEffect fire in React's layout
+ * phase — after resetAfterCommit calls scheduleRender. scheduleRender
+ * defers onRender via queueMicrotask, so onRender runs AFTER layout
+ * effects commit and reads the fresh declaration on the first frame
+ * (no one-keystroke lag). Test env uses onImmediateRender (synchronous,
+ * no microtask), so tests compensate by calling ink.onRender()
+ * explicitly after render.
+ */
+export function useDeclaredCursor({
+ line,
+ column,
+ active,
+}: {
+ line: number
+ column: number
+ active: boolean
+}): (element: DOMElement | null) => void {
+ const setCursorDeclaration = useContext(CursorDeclarationContext)
+ const nodeRef = useRef(null)
+
+ const setNode = useCallback((node: DOMElement | null) => {
+ nodeRef.current = node
+ }, [])
+
+ // When active, set unconditionally. When inactive, clear conditionally
+ // (only if the currently-declared node is ours). The node-identity check
+ // handles two hazards:
+ // 1. A memo()ized active instance elsewhere (e.g. the search input in
+ // a memo'd Footer) doesn't re-render this commit — an inactive
+ // instance re-rendering here must not clobber it.
+ // 2. Sibling handoff (menu focus moving between list items) — when
+ // focus moves opposite to sibling order, the newly-inactive item's
+ // effect runs AFTER the newly-active item's set. Without the node
+ // check it would clobber.
+ // No dep array: must re-declare every commit so the active instance
+ // re-claims the declaration after another instance's unmount-cleanup or
+ // sibling handoff nulls it.
+ useLayoutEffect(() => {
+ const node = nodeRef.current
+ if (active && node) {
+ setCursorDeclaration({ relativeX: column, relativeY: line, node })
+ } else {
+ setCursorDeclaration(null, node)
+ }
+ })
+
+ // Clear on unmount (conditionally — another instance may own by then).
+ // Separate effect with empty deps so cleanup only fires once — not on
+ // every line/column change, which would transiently null between commits.
+ useLayoutEffect(() => {
+ return () => {
+ setCursorDeclaration(null, nodeRef.current)
+ }
+ }, [setCursorDeclaration])
+
+ return setNode
+}
diff --git a/src/ink/hooks/use-input.ts b/src/ink/hooks/use-input.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7cf75b3114227891dde2babb6552182891bceabd
--- /dev/null
+++ b/src/ink/hooks/use-input.ts
@@ -0,0 +1,92 @@
+import { useEffect, useLayoutEffect } from 'react'
+import { useEventCallback } from 'usehooks-ts'
+import type { InputEvent, Key } from '../events/input-event.js'
+import useStdin from './use-stdin.js'
+
+type Handler = (input: string, key: Key, event: InputEvent) => void
+
+type Options = {
+ /**
+ * Enable or disable capturing of user input.
+ * Useful when there are multiple useInput hooks used at once to avoid handling the same input several times.
+ *
+ * @default true
+ */
+ isActive?: boolean
+}
+
+/**
+ * This hook is used for handling user input.
+ * It's a more convenient alternative to using `StdinContext` and listening to `data` events.
+ * The callback you pass to `useInput` is called for each character when user enters any input.
+ * However, if user pastes text and it's more than one character, the callback will be called only once and the whole string will be passed as `input`.
+ *
+ * ```
+ * import {useInput} from 'ink';
+ *
+ * const UserInput = () => {
+ * useInput((input, key) => {
+ * if (input === 'q') {
+ * // Exit program
+ * }
+ *
+ * if (key.leftArrow) {
+ * // Left arrow key pressed
+ * }
+ * });
+ *
+ * return …
+ * };
+ * ```
+ */
+const useInput = (inputHandler: Handler, options: Options = {}) => {
+ const { setRawMode, internal_exitOnCtrlC, internal_eventEmitter } = useStdin()
+
+ // useLayoutEffect (not useEffect) so that raw mode is enabled synchronously
+ // during React's commit phase, before render() returns. With useEffect, raw
+ // mode setup is deferred to the next event loop tick via React's scheduler,
+ // leaving the terminal in cooked mode — keystrokes echo and the cursor is
+ // visible until the effect fires.
+ useLayoutEffect(() => {
+ if (options.isActive === false) {
+ return
+ }
+
+ setRawMode(true)
+
+ return () => {
+ setRawMode(false)
+ }
+ }, [options.isActive, setRawMode])
+
+ // Register the listener once on mount so its slot in the EventEmitter's
+ // listener array is stable. If isActive were in the effect's deps, the
+ // listener would re-append on false→true, moving it behind listeners
+ // that registered while it was inactive — breaking
+ // stopImmediatePropagation() ordering. useEventCallback keeps the
+ // reference stable while reading latest isActive/inputHandler from
+ // closure (it syncs via useLayoutEffect, so it's compiler-safe).
+ const handleData = useEventCallback((event: InputEvent) => {
+ if (options.isActive === false) {
+ return
+ }
+ const { input, key } = event
+
+ // If app is not supposed to exit on Ctrl+C, then let input listener handle it
+ // Note: discreteUpdates is called at the App level when emitting events,
+ // so all listeners are already within a high-priority update context.
+ if (!(input === 'c' && key.ctrl) || !internal_exitOnCtrlC) {
+ inputHandler(input, key, event)
+ }
+ })
+
+ useEffect(() => {
+ internal_eventEmitter?.on('input', handleData)
+
+ return () => {
+ internal_eventEmitter?.removeListener('input', handleData)
+ }
+ }, [internal_eventEmitter, handleData])
+}
+
+export default useInput
diff --git a/src/ink/hooks/use-interval.ts b/src/ink/hooks/use-interval.ts
new file mode 100644
index 0000000000000000000000000000000000000000..49c3ee6279b119b0ebea5e103944961dc1fe0f27
--- /dev/null
+++ b/src/ink/hooks/use-interval.ts
@@ -0,0 +1,67 @@
+import { useContext, useEffect, useRef, useState } from 'react'
+import { ClockContext } from '../components/ClockContext.js'
+
+/**
+ * Returns the clock time, updating at the given interval.
+ * Subscribes as non-keepAlive — won't keep the clock alive on its own,
+ * but updates whenever a keepAlive subscriber (e.g. the spinner)
+ * is driving the clock.
+ *
+ * Use this to drive pure time-based computations (shimmer position,
+ * frame index) from the shared clock.
+ */
+export function useAnimationTimer(intervalMs: number): number {
+ const clock = useContext(ClockContext)
+ const [time, setTime] = useState(() => clock?.now() ?? 0)
+
+ useEffect(() => {
+ if (!clock) return
+
+ let lastUpdate = clock.now()
+
+ const onChange = (): void => {
+ const now = clock.now()
+ if (now - lastUpdate >= intervalMs) {
+ lastUpdate = now
+ setTime(now)
+ }
+ }
+
+ return clock.subscribe(onChange, false)
+ }, [clock, intervalMs])
+
+ return time
+}
+
+/**
+ * Interval hook backed by the shared Clock.
+ *
+ * Unlike `useInterval` from `usehooks-ts` (which creates its own setInterval),
+ * this piggybacks on the single shared clock so all timers consolidate into
+ * one wake-up. Pass `null` for intervalMs to pause.
+ */
+export function useInterval(
+ callback: () => void,
+ intervalMs: number | null,
+): void {
+ const callbackRef = useRef(callback)
+ callbackRef.current = callback
+
+ const clock = useContext(ClockContext)
+
+ useEffect(() => {
+ if (!clock || intervalMs === null) return
+
+ let lastUpdate = clock.now()
+
+ const onChange = (): void => {
+ const now = clock.now()
+ if (now - lastUpdate >= intervalMs) {
+ lastUpdate = now
+ callbackRef.current()
+ }
+ }
+
+ return clock.subscribe(onChange, false)
+ }, [clock, intervalMs])
+}
diff --git a/src/ink/hooks/use-search-highlight.ts b/src/ink/hooks/use-search-highlight.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ce9fc364dccc0c6f675402dc672e1b8fb8d53807
--- /dev/null
+++ b/src/ink/hooks/use-search-highlight.ts
@@ -0,0 +1,53 @@
+import { useContext, useMemo } from 'react'
+import StdinContext from '../components/StdinContext.js'
+import type { DOMElement } from '../dom.js'
+import instances from '../instances.js'
+import type { MatchPosition } from '../render-to-screen.js'
+
+/**
+ * Set the search highlight query on the Ink instance. Non-empty → all
+ * visible occurrences are inverted on the next frame (SGR 7, screen-buffer
+ * overlay, same damage machinery as selection). Empty → clears.
+ *
+ * This is a screen-space highlight — it matches the RENDERED text, not the
+ * source message text. Works for anything visible (bash output, file paths,
+ * error messages) regardless of where it came from in the message tree. A
+ * query that matched in source but got truncated/ellipsized in rendering
+ * won't highlight; that's acceptable — we highlight what you see.
+ */
+export function useSearchHighlight(): {
+ setQuery: (query: string) => void
+ /** Paint an existing DOM subtree (from the MAIN tree) to a fresh
+ * Screen at its natural height, scan. Element-relative positions
+ * (row 0 = element top). Zero context duplication — the element
+ * IS the one built with all real providers. */
+ scanElement: (el: DOMElement) => MatchPosition[]
+ /** Position-based CURRENT highlight. Every frame writes yellow at
+ * positions[currentIdx] + rowOffset. The scan-highlight (inverse on
+ * all matches) still runs — this overlays on top. rowOffset tracks
+ * scroll; positions stay stable (message-relative). null clears. */
+ setPositions: (
+ state: {
+ positions: MatchPosition[]
+ rowOffset: number
+ currentIdx: number
+ } | null,
+ ) => void
+} {
+ useContext(StdinContext) // anchor to App subtree for hook rules
+ const ink = instances.get(process.stdout)
+ return useMemo(() => {
+ if (!ink) {
+ return {
+ setQuery: () => {},
+ scanElement: () => [],
+ setPositions: () => {},
+ }
+ }
+ return {
+ setQuery: (query: string) => ink.setSearchHighlight(query),
+ scanElement: (el: DOMElement) => ink.scanElementSubtree(el),
+ setPositions: state => ink.setSearchPositions(state),
+ }
+ }, [ink])
+}
diff --git a/src/ink/hooks/use-selection.ts b/src/ink/hooks/use-selection.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f7e1d45717c18282bca8c307c60f7d9038dc7471
--- /dev/null
+++ b/src/ink/hooks/use-selection.ts
@@ -0,0 +1,104 @@
+import { useContext, useMemo, useSyncExternalStore } from 'react'
+import StdinContext from '../components/StdinContext.js'
+import instances from '../instances.js'
+import {
+ type FocusMove,
+ type SelectionState,
+ shiftAnchor,
+} from '../selection.js'
+
+/**
+ * Access to text selection operations on the Ink instance (fullscreen only).
+ * Returns no-op functions when fullscreen mode is disabled.
+ */
+export function useSelection(): {
+ copySelection: () => string
+ /** Copy without clearing the highlight (for copy-on-select). */
+ copySelectionNoClear: () => string
+ clearSelection: () => void
+ hasSelection: () => boolean
+ /** Read the raw mutable selection state (for drag-to-scroll). */
+ getState: () => SelectionState | null
+ /** Subscribe to selection mutations (start/update/finish/clear). */
+ subscribe: (cb: () => void) => () => void
+ /** Shift the anchor row by dRow, clamped to [minRow, maxRow]. */
+ shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
+ /** Shift anchor AND focus by dRow (keyboard scroll: whole selection
+ * tracks content). Clamped points get col reset to the full-width edge
+ * since their content was captured by captureScrolledRows. Reads
+ * screen.width from the ink instance for the col-reset boundary. */
+ shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
+ /** Keyboard selection extension (shift+arrow): move focus, anchor fixed.
+ * Left/right wrap across rows; up/down clamp at viewport edges. */
+ moveFocus: (move: FocusMove) => void
+ /** Capture text from rows about to scroll out of the viewport (call
+ * BEFORE scrollBy so the screen buffer still has the outgoing rows). */
+ captureScrolledRows: (
+ firstRow: number,
+ lastRow: number,
+ side: 'above' | 'below',
+ ) => void
+ /** Set the selection highlight bg color (theme-piping; solid bg
+ * replaces the old SGR-7 inverse so syntax highlighting stays readable
+ * under selection). Call once on mount + whenever theme changes. */
+ setSelectionBgColor: (color: string) => void
+} {
+ // Look up the Ink instance via stdout — same pattern as instances map.
+ // StdinContext is available (it's always provided), and the Ink instance
+ // is keyed by stdout which we can get from process.stdout since there's
+ // only one Ink instance per process in practice.
+ useContext(StdinContext) // anchor to App subtree for hook rules
+ const ink = instances.get(process.stdout)
+ // Memoize so callers can safely use the return value in dependency arrays.
+ // ink is a singleton per stdout — stable across renders.
+ return useMemo(() => {
+ if (!ink) {
+ return {
+ copySelection: () => '',
+ copySelectionNoClear: () => '',
+ clearSelection: () => {},
+ hasSelection: () => false,
+ getState: () => null,
+ subscribe: () => () => {},
+ shiftAnchor: () => {},
+ shiftSelection: () => {},
+ moveFocus: () => {},
+ captureScrolledRows: () => {},
+ setSelectionBgColor: () => {},
+ }
+ }
+ return {
+ copySelection: () => ink.copySelection(),
+ copySelectionNoClear: () => ink.copySelectionNoClear(),
+ clearSelection: () => ink.clearTextSelection(),
+ hasSelection: () => ink.hasTextSelection(),
+ getState: () => ink.selection,
+ subscribe: (cb: () => void) => ink.subscribeToSelectionChange(cb),
+ shiftAnchor: (dRow: number, minRow: number, maxRow: number) =>
+ shiftAnchor(ink.selection, dRow, minRow, maxRow),
+ shiftSelection: (dRow, minRow, maxRow) =>
+ ink.shiftSelectionForScroll(dRow, minRow, maxRow),
+ moveFocus: (move: FocusMove) => ink.moveSelectionFocus(move),
+ captureScrolledRows: (firstRow, lastRow, side) =>
+ ink.captureScrolledRows(firstRow, lastRow, side),
+ setSelectionBgColor: (color: string) => ink.setSelectionBgColor(color),
+ }
+ }, [ink])
+}
+
+const NO_SUBSCRIBE = () => () => {}
+const ALWAYS_FALSE = () => false
+
+/**
+ * Reactive selection-exists state. Re-renders the caller when a text
+ * selection is created or cleared. Always returns false outside
+ * fullscreen mode (selection is only available in alt-screen).
+ */
+export function useHasSelection(): boolean {
+ useContext(StdinContext)
+ const ink = instances.get(process.stdout)
+ return useSyncExternalStore(
+ ink ? ink.subscribeToSelectionChange : NO_SUBSCRIBE,
+ ink ? ink.hasTextSelection : ALWAYS_FALSE,
+ )
+}
diff --git a/src/ink/hooks/use-stdin.ts b/src/ink/hooks/use-stdin.ts
new file mode 100644
index 0000000000000000000000000000000000000000..997f3c3883f1c787befdc640cd730471487db9c5
--- /dev/null
+++ b/src/ink/hooks/use-stdin.ts
@@ -0,0 +1,8 @@
+import { useContext } from 'react'
+import StdinContext from '../components/StdinContext.js'
+
+/**
+ * `useStdin` is a React hook, which exposes stdin stream.
+ */
+const useStdin = () => useContext(StdinContext)
+export default useStdin
diff --git a/src/ink/hooks/use-terminal-focus.ts b/src/ink/hooks/use-terminal-focus.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b717f7b469550cbe649fab4b913a4e023ecb67e5
--- /dev/null
+++ b/src/ink/hooks/use-terminal-focus.ts
@@ -0,0 +1,16 @@
+import { useContext } from 'react'
+import TerminalFocusContext from '../components/TerminalFocusContext.js'
+
+/**
+ * Hook to check if the terminal has focus.
+ *
+ * Uses DECSET 1004 focus reporting - the terminal sends escape sequences
+ * when it gains or loses focus. These are handled automatically
+ * by Ink and filtered from useInput.
+ *
+ * @returns true if the terminal is focused (or focus state is unknown)
+ */
+export function useTerminalFocus(): boolean {
+ const { isTerminalFocused } = useContext(TerminalFocusContext)
+ return isTerminalFocused
+}
diff --git a/src/ink/hooks/use-terminal-title.ts b/src/ink/hooks/use-terminal-title.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d820cd7ae94a0f4a69c8188cc3a5966e72e6b8f5
--- /dev/null
+++ b/src/ink/hooks/use-terminal-title.ts
@@ -0,0 +1,31 @@
+import { useContext, useEffect } from 'react'
+import stripAnsi from 'strip-ansi'
+import { OSC, osc } from '../termio/osc.js'
+import { TerminalWriteContext } from '../useTerminalNotification.js'
+
+/**
+ * Declaratively set the terminal tab/window title.
+ *
+ * Pass a string to set the title. ANSI escape sequences are stripped
+ * automatically so callers don't need to know about terminal encoding.
+ * Pass `null` to opt out — the hook becomes a no-op and leaves the
+ * terminal title untouched.
+ *
+ * On Windows, uses `process.title` (classic conhost doesn't support OSC).
+ * Elsewhere, writes OSC 0 (set title+icon) via Ink's stdout.
+ */
+export function useTerminalTitle(title: string | null): void {
+ const writeRaw = useContext(TerminalWriteContext)
+
+ useEffect(() => {
+ if (title === null || !writeRaw) return
+
+ const clean = stripAnsi(title)
+
+ if (process.platform === 'win32') {
+ process.title = clean
+ } else {
+ writeRaw(osc(OSC.SET_TITLE_AND_ICON, clean))
+ }
+ }, [title, writeRaw])
+}
diff --git a/src/ink/hooks/use-terminal-viewport.ts b/src/ink/hooks/use-terminal-viewport.ts
new file mode 100644
index 0000000000000000000000000000000000000000..91193bf73f62b8af0f7bba8044284e9951bb4faf
--- /dev/null
+++ b/src/ink/hooks/use-terminal-viewport.ts
@@ -0,0 +1,96 @@
+import { useCallback, useContext, useLayoutEffect, useRef } from 'react'
+import { TerminalSizeContext } from '../components/TerminalSizeContext.js'
+import type { DOMElement } from '../dom.js'
+
+type ViewportEntry = {
+ /**
+ * Whether the element is currently within the terminal viewport
+ */
+ isVisible: boolean
+}
+
+/**
+ * Hook to detect if a component is within the terminal viewport.
+ *
+ * Returns a callback ref and a viewport entry object.
+ * Attach the ref to the component you want to track.
+ *
+ * The entry is updated during the layout phase (useLayoutEffect) so callers
+ * always read fresh values during render. Visibility changes do NOT trigger
+ * re-renders on their own — callers that re-render for other reasons (e.g.
+ * animation ticks, state changes) will pick up the latest value naturally.
+ * This avoids infinite update loops when combined with other layout effects
+ * that also call setState.
+ *
+ * @example
+ * const [ref, entry] = useTerminalViewport()
+ * return ...
+ */
+export function useTerminalViewport(): [
+ ref: (element: DOMElement | null) => void,
+ entry: ViewportEntry,
+] {
+ const terminalSize = useContext(TerminalSizeContext)
+ const elementRef = useRef(null)
+ const entryRef = useRef({ isVisible: true })
+
+ const setElement = useCallback((el: DOMElement | null) => {
+ elementRef.current = el
+ }, [])
+
+ // Runs on every render because yoga layout values can change
+ // without React being aware. Only updates the ref — no setState
+ // to avoid cascading re-renders during the commit phase.
+ // Walks the DOM ancestor chain fresh each time to avoid holding stale
+ // references after yoga tree rebuilds.
+ useLayoutEffect(() => {
+ const element = elementRef.current
+ if (!element?.yogaNode || !terminalSize) {
+ return
+ }
+
+ const height = element.yogaNode.getComputedHeight()
+ const rows = terminalSize.rows
+
+ // Walk the DOM parent chain (not yoga.getParent()) so we can detect
+ // scroll containers and subtract their scrollTop. Yoga computes layout
+ // positions without scroll offset — scrollTop is applied at render time.
+ // Without this, an element inside a ScrollBox whose yoga position exceeds
+ // terminalRows would be considered offscreen even when scrolled into view
+ // (e.g., the spinner in fullscreen mode after enough messages accumulate).
+ let absoluteTop = element.yogaNode.getComputedTop()
+ let parent: DOMElement | undefined = element.parentNode
+ let root = element.yogaNode
+ while (parent) {
+ if (parent.yogaNode) {
+ absoluteTop += parent.yogaNode.getComputedTop()
+ root = parent.yogaNode
+ }
+ // scrollTop is only ever set on scroll containers (by ScrollBox + renderer).
+ // Non-scroll nodes have undefined scrollTop → falsy fast-path.
+ if (parent.scrollTop) absoluteTop -= parent.scrollTop
+ parent = parent.parentNode
+ }
+
+ // Only the root's height matters
+ const screenHeight = root.getComputedHeight()
+
+ const bottom = absoluteTop + height
+ // When content overflows the viewport (screenHeight > rows), the
+ // cursor-restore at frame end scrolls one extra row into scrollback.
+ // log-update.ts accounts for this with scrollbackRows = viewportY + 1.
+ // We must match, otherwise an element at the boundary is considered
+ // "visible" here (animation keeps ticking) but its row is treated as
+ // scrollback by log-update (content change → full reset → flicker).
+ const cursorRestoreScroll = screenHeight > rows ? 1 : 0
+ const viewportY = Math.max(0, screenHeight - rows) + cursorRestoreScroll
+ const viewportBottom = viewportY + rows
+ const visible = bottom > viewportY && absoluteTop < viewportBottom
+
+ if (visible !== entryRef.current.isVisible) {
+ entryRef.current = { isVisible: visible }
+ }
+ })
+
+ return [setElement, entryRef.current]
+}
diff --git a/src/ink/layout/engine.ts b/src/ink/layout/engine.ts
new file mode 100644
index 0000000000000000000000000000000000000000..38f6dcb0fb5614d423f9a59cedb31d696ddeeef6
--- /dev/null
+++ b/src/ink/layout/engine.ts
@@ -0,0 +1,6 @@
+import type { LayoutNode } from './node.js'
+import { createYogaLayoutNode } from './yoga.js'
+
+export function createLayoutNode(): LayoutNode {
+ return createYogaLayoutNode()
+}
diff --git a/src/ink/layout/geometry.ts b/src/ink/layout/geometry.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e586f8ecf11df704babb9ad283f80e1c818b4477
--- /dev/null
+++ b/src/ink/layout/geometry.ts
@@ -0,0 +1,97 @@
+export type Point = {
+ x: number
+ y: number
+}
+
+export type Size = {
+ width: number
+ height: number
+}
+
+export type Rectangle = Point & Size
+
+/** Edge insets (padding, margin, border) */
+export type Edges = {
+ top: number
+ right: number
+ bottom: number
+ left: number
+}
+
+/** Create uniform edges */
+export function edges(all: number): Edges
+export function edges(vertical: number, horizontal: number): Edges
+export function edges(
+ top: number,
+ right: number,
+ bottom: number,
+ left: number,
+): Edges
+export function edges(a: number, b?: number, c?: number, d?: number): Edges {
+ if (b === undefined) {
+ return { top: a, right: a, bottom: a, left: a }
+ }
+ if (c === undefined) {
+ return { top: a, right: b, bottom: a, left: b }
+ }
+ return { top: a, right: b, bottom: c, left: d! }
+}
+
+/** Add two edge values */
+export function addEdges(a: Edges, b: Edges): Edges {
+ return {
+ top: a.top + b.top,
+ right: a.right + b.right,
+ bottom: a.bottom + b.bottom,
+ left: a.left + b.left,
+ }
+}
+
+/** Zero edges constant */
+export const ZERO_EDGES: Edges = { top: 0, right: 0, bottom: 0, left: 0 }
+
+/** Convert partial edges to full edges with defaults */
+export function resolveEdges(partial?: Partial): Edges {
+ return {
+ top: partial?.top ?? 0,
+ right: partial?.right ?? 0,
+ bottom: partial?.bottom ?? 0,
+ left: partial?.left ?? 0,
+ }
+}
+
+export function unionRect(a: Rectangle, b: Rectangle): Rectangle {
+ const minX = Math.min(a.x, b.x)
+ const minY = Math.min(a.y, b.y)
+ const maxX = Math.max(a.x + a.width, b.x + b.width)
+ const maxY = Math.max(a.y + a.height, b.y + b.height)
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
+}
+
+export function clampRect(rect: Rectangle, size: Size): Rectangle {
+ const minX = Math.max(0, rect.x)
+ const minY = Math.max(0, rect.y)
+ const maxX = Math.min(size.width - 1, rect.x + rect.width - 1)
+ const maxY = Math.min(size.height - 1, rect.y + rect.height - 1)
+ return {
+ x: minX,
+ y: minY,
+ width: Math.max(0, maxX - minX + 1),
+ height: Math.max(0, maxY - minY + 1),
+ }
+}
+
+export function withinBounds(size: Size, point: Point): boolean {
+ return (
+ point.x >= 0 &&
+ point.y >= 0 &&
+ point.x < size.width &&
+ point.y < size.height
+ )
+}
+
+export function clamp(value: number, min?: number, max?: number): number {
+ if (min !== undefined && value < min) return min
+ if (max !== undefined && value > max) return max
+ return value
+}
diff --git a/src/ink/layout/yoga.ts b/src/ink/layout/yoga.ts
new file mode 100644
index 0000000000000000000000000000000000000000..58f2646fba661835d478477e16cc6139a7b69174
--- /dev/null
+++ b/src/ink/layout/yoga.ts
@@ -0,0 +1,308 @@
+import Yoga, {
+ Align,
+ Direction,
+ Display,
+ Edge,
+ FlexDirection,
+ Gutter,
+ Justify,
+ MeasureMode,
+ Overflow,
+ PositionType,
+ Wrap,
+ type Node as YogaNode,
+} from 'src/native-ts/yoga-layout/index.js'
+import {
+ type LayoutAlign,
+ LayoutDisplay,
+ type LayoutEdge,
+ type LayoutFlexDirection,
+ type LayoutGutter,
+ type LayoutJustify,
+ type LayoutMeasureFunc,
+ LayoutMeasureMode,
+ type LayoutNode,
+ type LayoutOverflow,
+ type LayoutPositionType,
+ type LayoutWrap,
+} from './node.js'
+
+// --
+// Edge/Gutter mapping
+
+const EDGE_MAP: Record = {
+ all: Edge.All,
+ horizontal: Edge.Horizontal,
+ vertical: Edge.Vertical,
+ left: Edge.Left,
+ right: Edge.Right,
+ top: Edge.Top,
+ bottom: Edge.Bottom,
+ start: Edge.Start,
+ end: Edge.End,
+}
+
+const GUTTER_MAP: Record = {
+ all: Gutter.All,
+ column: Gutter.Column,
+ row: Gutter.Row,
+}
+
+// --
+// Yoga adapter
+
+export class YogaLayoutNode implements LayoutNode {
+ readonly yoga: YogaNode
+
+ constructor(yoga: YogaNode) {
+ this.yoga = yoga
+ }
+
+ // Tree
+
+ insertChild(child: LayoutNode, index: number): void {
+ this.yoga.insertChild((child as YogaLayoutNode).yoga, index)
+ }
+
+ removeChild(child: LayoutNode): void {
+ this.yoga.removeChild((child as YogaLayoutNode).yoga)
+ }
+
+ getChildCount(): number {
+ return this.yoga.getChildCount()
+ }
+
+ getParent(): LayoutNode | null {
+ const p = this.yoga.getParent()
+ return p ? new YogaLayoutNode(p) : null
+ }
+
+ // Layout
+
+ calculateLayout(width?: number, _height?: number): void {
+ this.yoga.calculateLayout(width, undefined, Direction.LTR)
+ }
+
+ setMeasureFunc(fn: LayoutMeasureFunc): void {
+ this.yoga.setMeasureFunc((w, wMode) => {
+ const mode =
+ wMode === MeasureMode.Exactly
+ ? LayoutMeasureMode.Exactly
+ : wMode === MeasureMode.AtMost
+ ? LayoutMeasureMode.AtMost
+ : LayoutMeasureMode.Undefined
+ return fn(w, mode)
+ })
+ }
+
+ unsetMeasureFunc(): void {
+ this.yoga.unsetMeasureFunc()
+ }
+
+ markDirty(): void {
+ this.yoga.markDirty()
+ }
+
+ // Computed layout
+
+ getComputedLeft(): number {
+ return this.yoga.getComputedLeft()
+ }
+
+ getComputedTop(): number {
+ return this.yoga.getComputedTop()
+ }
+
+ getComputedWidth(): number {
+ return this.yoga.getComputedWidth()
+ }
+
+ getComputedHeight(): number {
+ return this.yoga.getComputedHeight()
+ }
+
+ getComputedBorder(edge: LayoutEdge): number {
+ return this.yoga.getComputedBorder(EDGE_MAP[edge]!)
+ }
+
+ getComputedPadding(edge: LayoutEdge): number {
+ return this.yoga.getComputedPadding(EDGE_MAP[edge]!)
+ }
+
+ // Style setters
+
+ setWidth(value: number): void {
+ this.yoga.setWidth(value)
+ }
+ setWidthPercent(value: number): void {
+ this.yoga.setWidthPercent(value)
+ }
+ setWidthAuto(): void {
+ this.yoga.setWidthAuto()
+ }
+ setHeight(value: number): void {
+ this.yoga.setHeight(value)
+ }
+ setHeightPercent(value: number): void {
+ this.yoga.setHeightPercent(value)
+ }
+ setHeightAuto(): void {
+ this.yoga.setHeightAuto()
+ }
+ setMinWidth(value: number): void {
+ this.yoga.setMinWidth(value)
+ }
+ setMinWidthPercent(value: number): void {
+ this.yoga.setMinWidthPercent(value)
+ }
+ setMinHeight(value: number): void {
+ this.yoga.setMinHeight(value)
+ }
+ setMinHeightPercent(value: number): void {
+ this.yoga.setMinHeightPercent(value)
+ }
+ setMaxWidth(value: number): void {
+ this.yoga.setMaxWidth(value)
+ }
+ setMaxWidthPercent(value: number): void {
+ this.yoga.setMaxWidthPercent(value)
+ }
+ setMaxHeight(value: number): void {
+ this.yoga.setMaxHeight(value)
+ }
+ setMaxHeightPercent(value: number): void {
+ this.yoga.setMaxHeightPercent(value)
+ }
+
+ setFlexDirection(dir: LayoutFlexDirection): void {
+ const map: Record = {
+ row: FlexDirection.Row,
+ 'row-reverse': FlexDirection.RowReverse,
+ column: FlexDirection.Column,
+ 'column-reverse': FlexDirection.ColumnReverse,
+ }
+ this.yoga.setFlexDirection(map[dir]!)
+ }
+
+ setFlexGrow(value: number): void {
+ this.yoga.setFlexGrow(value)
+ }
+ setFlexShrink(value: number): void {
+ this.yoga.setFlexShrink(value)
+ }
+ setFlexBasis(value: number): void {
+ this.yoga.setFlexBasis(value)
+ }
+ setFlexBasisPercent(value: number): void {
+ this.yoga.setFlexBasisPercent(value)
+ }
+
+ setFlexWrap(wrap: LayoutWrap): void {
+ const map: Record = {
+ nowrap: Wrap.NoWrap,
+ wrap: Wrap.Wrap,
+ 'wrap-reverse': Wrap.WrapReverse,
+ }
+ this.yoga.setFlexWrap(map[wrap]!)
+ }
+
+ setAlignItems(align: LayoutAlign): void {
+ const map: Record = {
+ auto: Align.Auto,
+ stretch: Align.Stretch,
+ 'flex-start': Align.FlexStart,
+ center: Align.Center,
+ 'flex-end': Align.FlexEnd,
+ }
+ this.yoga.setAlignItems(map[align]!)
+ }
+
+ setAlignSelf(align: LayoutAlign): void {
+ const map: Record = {
+ auto: Align.Auto,
+ stretch: Align.Stretch,
+ 'flex-start': Align.FlexStart,
+ center: Align.Center,
+ 'flex-end': Align.FlexEnd,
+ }
+ this.yoga.setAlignSelf(map[align]!)
+ }
+
+ setJustifyContent(justify: LayoutJustify): void {
+ const map: Record = {
+ 'flex-start': Justify.FlexStart,
+ center: Justify.Center,
+ 'flex-end': Justify.FlexEnd,
+ 'space-between': Justify.SpaceBetween,
+ 'space-around': Justify.SpaceAround,
+ 'space-evenly': Justify.SpaceEvenly,
+ }
+ this.yoga.setJustifyContent(map[justify]!)
+ }
+
+ setDisplay(display: LayoutDisplay): void {
+ this.yoga.setDisplay(display === 'flex' ? Display.Flex : Display.None)
+ }
+
+ getDisplay(): LayoutDisplay {
+ return this.yoga.getDisplay() === Display.None
+ ? LayoutDisplay.None
+ : LayoutDisplay.Flex
+ }
+
+ setPositionType(type: LayoutPositionType): void {
+ this.yoga.setPositionType(
+ type === 'absolute' ? PositionType.Absolute : PositionType.Relative,
+ )
+ }
+
+ setPosition(edge: LayoutEdge, value: number): void {
+ this.yoga.setPosition(EDGE_MAP[edge]!, value)
+ }
+
+ setPositionPercent(edge: LayoutEdge, value: number): void {
+ this.yoga.setPositionPercent(EDGE_MAP[edge]!, value)
+ }
+
+ setOverflow(overflow: LayoutOverflow): void {
+ const map: Record = {
+ visible: Overflow.Visible,
+ hidden: Overflow.Hidden,
+ scroll: Overflow.Scroll,
+ }
+ this.yoga.setOverflow(map[overflow]!)
+ }
+
+ setMargin(edge: LayoutEdge, value: number): void {
+ this.yoga.setMargin(EDGE_MAP[edge]!, value)
+ }
+ setPadding(edge: LayoutEdge, value: number): void {
+ this.yoga.setPadding(EDGE_MAP[edge]!, value)
+ }
+ setBorder(edge: LayoutEdge, value: number): void {
+ this.yoga.setBorder(EDGE_MAP[edge]!, value)
+ }
+ setGap(gutter: LayoutGutter, value: number): void {
+ this.yoga.setGap(GUTTER_MAP[gutter]!, value)
+ }
+
+ // Lifecycle
+
+ free(): void {
+ this.yoga.free()
+ }
+ freeRecursive(): void {
+ this.yoga.freeRecursive()
+ }
+}
+
+// --
+// Instance management
+//
+// The TS yoga-layout port is synchronous — no WASM loading, no linear memory
+// growth, so no preload/swap/reset machinery is needed. The Yoga instance is
+// just a plain JS object available at import time.
+
+export function createYogaLayoutNode(): LayoutNode {
+ return new YogaLayoutNode(Yoga.Node.create())
+}
diff --git a/src/ink/termio/csi.ts b/src/ink/termio/csi.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f3b2f524bf824a9affbc2ad673de18ac03cbc346
--- /dev/null
+++ b/src/ink/termio/csi.ts
@@ -0,0 +1,319 @@
+/**
+ * CSI (Control Sequence Introducer) Types
+ *
+ * Enums and types for CSI command parameters.
+ */
+
+import { ESC, ESC_TYPE, SEP } from './ansi.js'
+
+export const CSI_PREFIX = ESC + String.fromCharCode(ESC_TYPE.CSI)
+
+/**
+ * CSI parameter byte ranges
+ */
+export const CSI_RANGE = {
+ PARAM_START: 0x30,
+ PARAM_END: 0x3f,
+ INTERMEDIATE_START: 0x20,
+ INTERMEDIATE_END: 0x2f,
+ FINAL_START: 0x40,
+ FINAL_END: 0x7e,
+} as const
+
+/** Check if a byte is a CSI parameter byte */
+export function isCSIParam(byte: number): boolean {
+ return byte >= CSI_RANGE.PARAM_START && byte <= CSI_RANGE.PARAM_END
+}
+
+/** Check if a byte is a CSI intermediate byte */
+export function isCSIIntermediate(byte: number): boolean {
+ return (
+ byte >= CSI_RANGE.INTERMEDIATE_START && byte <= CSI_RANGE.INTERMEDIATE_END
+ )
+}
+
+/** Check if a byte is a CSI final byte (@ through ~) */
+export function isCSIFinal(byte: number): boolean {
+ return byte >= CSI_RANGE.FINAL_START && byte <= CSI_RANGE.FINAL_END
+}
+
+/**
+ * Generate a CSI sequence: ESC [ p1;p2;...;pN final
+ * Single arg: treated as raw body
+ * Multiple args: last is final byte, rest are params joined by ;
+ */
+export function csi(...args: (string | number)[]): string {
+ if (args.length === 0) return CSI_PREFIX
+ if (args.length === 1) return `${CSI_PREFIX}${args[0]}`
+ const params = args.slice(0, -1)
+ const final = args[args.length - 1]
+ return `${CSI_PREFIX}${params.join(SEP)}${final}`
+}
+
+/**
+ * CSI final bytes - the command identifier
+ */
+export const CSI = {
+ // Cursor movement
+ CUU: 0x41, // A - Cursor Up
+ CUD: 0x42, // B - Cursor Down
+ CUF: 0x43, // C - Cursor Forward
+ CUB: 0x44, // D - Cursor Back
+ CNL: 0x45, // E - Cursor Next Line
+ CPL: 0x46, // F - Cursor Previous Line
+ CHA: 0x47, // G - Cursor Horizontal Absolute
+ CUP: 0x48, // H - Cursor Position
+ CHT: 0x49, // I - Cursor Horizontal Tab
+ VPA: 0x64, // d - Vertical Position Absolute
+ HVP: 0x66, // f - Horizontal Vertical Position
+
+ // Erase
+ ED: 0x4a, // J - Erase in Display
+ EL: 0x4b, // K - Erase in Line
+ ECH: 0x58, // X - Erase Character
+
+ // Insert/Delete
+ IL: 0x4c, // L - Insert Lines
+ DL: 0x4d, // M - Delete Lines
+ ICH: 0x40, // @ - Insert Characters
+ DCH: 0x50, // P - Delete Characters
+
+ // Scroll
+ SU: 0x53, // S - Scroll Up
+ SD: 0x54, // T - Scroll Down
+
+ // Modes
+ SM: 0x68, // h - Set Mode
+ RM: 0x6c, // l - Reset Mode
+
+ // SGR
+ SGR: 0x6d, // m - Select Graphic Rendition
+
+ // Other
+ DSR: 0x6e, // n - Device Status Report
+ DECSCUSR: 0x71, // q - Set Cursor Style (with space intermediate)
+ DECSTBM: 0x72, // r - Set Top and Bottom Margins
+ SCOSC: 0x73, // s - Save Cursor Position
+ SCORC: 0x75, // u - Restore Cursor Position
+ CBT: 0x5a, // Z - Cursor Backward Tabulation
+} as const
+
+/**
+ * Erase in Display regions (ED command parameter)
+ */
+export const ERASE_DISPLAY = ['toEnd', 'toStart', 'all', 'scrollback'] as const
+
+/**
+ * Erase in Line regions (EL command parameter)
+ */
+export const ERASE_LINE_REGION = ['toEnd', 'toStart', 'all'] as const
+
+/**
+ * Cursor styles (DECSCUSR)
+ */
+export type CursorStyle = 'block' | 'underline' | 'bar'
+
+export const CURSOR_STYLES: Array<{ style: CursorStyle; blinking: boolean }> = [
+ { style: 'block', blinking: true }, // 0 - default
+ { style: 'block', blinking: true }, // 1
+ { style: 'block', blinking: false }, // 2
+ { style: 'underline', blinking: true }, // 3
+ { style: 'underline', blinking: false }, // 4
+ { style: 'bar', blinking: true }, // 5
+ { style: 'bar', blinking: false }, // 6
+]
+
+// Cursor movement generators
+
+/** Move cursor up n lines (CSI n A) */
+export function cursorUp(n = 1): string {
+ return n === 0 ? '' : csi(n, 'A')
+}
+
+/** Move cursor down n lines (CSI n B) */
+export function cursorDown(n = 1): string {
+ return n === 0 ? '' : csi(n, 'B')
+}
+
+/** Move cursor forward n columns (CSI n C) */
+export function cursorForward(n = 1): string {
+ return n === 0 ? '' : csi(n, 'C')
+}
+
+/** Move cursor back n columns (CSI n D) */
+export function cursorBack(n = 1): string {
+ return n === 0 ? '' : csi(n, 'D')
+}
+
+/** Move cursor to column n (1-indexed) (CSI n G) */
+export function cursorTo(col: number): string {
+ return csi(col, 'G')
+}
+
+/** Move cursor to column 1 (CSI G) */
+export const CURSOR_LEFT = csi('G')
+
+/** Move cursor to row, col (1-indexed) (CSI row ; col H) */
+export function cursorPosition(row: number, col: number): string {
+ return csi(row, col, 'H')
+}
+
+/** Move cursor to home position (CSI H) */
+export const CURSOR_HOME = csi('H')
+
+/**
+ * Move cursor relative to current position
+ * Positive x = right, negative x = left
+ * Positive y = down, negative y = up
+ */
+export function cursorMove(x: number, y: number): string {
+ let result = ''
+ // Horizontal first (matches ansi-escapes behavior)
+ if (x < 0) {
+ result += cursorBack(-x)
+ } else if (x > 0) {
+ result += cursorForward(x)
+ }
+ // Then vertical
+ if (y < 0) {
+ result += cursorUp(-y)
+ } else if (y > 0) {
+ result += cursorDown(y)
+ }
+ return result
+}
+
+// Save/restore cursor position
+
+/** Save cursor position (CSI s) */
+export const CURSOR_SAVE = csi('s')
+
+/** Restore cursor position (CSI u) */
+export const CURSOR_RESTORE = csi('u')
+
+// Erase generators
+
+/** Erase from cursor to end of line (CSI K) */
+export function eraseToEndOfLine(): string {
+ return csi('K')
+}
+
+/** Erase from cursor to start of line (CSI 1 K) */
+export function eraseToStartOfLine(): string {
+ return csi(1, 'K')
+}
+
+/** Erase entire line (CSI 2 K) */
+export function eraseLine(): string {
+ return csi(2, 'K')
+}
+
+/** Erase entire line - constant form */
+export const ERASE_LINE = csi(2, 'K')
+
+/** Erase from cursor to end of screen (CSI J) */
+export function eraseToEndOfScreen(): string {
+ return csi('J')
+}
+
+/** Erase from cursor to start of screen (CSI 1 J) */
+export function eraseToStartOfScreen(): string {
+ return csi(1, 'J')
+}
+
+/** Erase entire screen (CSI 2 J) */
+export function eraseScreen(): string {
+ return csi(2, 'J')
+}
+
+/** Erase entire screen - constant form */
+export const ERASE_SCREEN = csi(2, 'J')
+
+/** Erase scrollback buffer (CSI 3 J) */
+export const ERASE_SCROLLBACK = csi(3, 'J')
+
+/**
+ * Erase n lines starting from cursor line, moving cursor up
+ * This erases each line and moves up, ending at column 1
+ */
+export function eraseLines(n: number): string {
+ if (n <= 0) return ''
+ let result = ''
+ for (let i = 0; i < n; i++) {
+ result += ERASE_LINE
+ if (i < n - 1) {
+ result += cursorUp(1)
+ }
+ }
+ result += CURSOR_LEFT
+ return result
+}
+
+// Scroll
+
+/** Scroll up n lines (CSI n S) */
+export function scrollUp(n = 1): string {
+ return n === 0 ? '' : csi(n, 'S')
+}
+
+/** Scroll down n lines (CSI n T) */
+export function scrollDown(n = 1): string {
+ return n === 0 ? '' : csi(n, 'T')
+}
+
+/** Set scroll region (DECSTBM, CSI top;bottom r). 1-indexed, inclusive. */
+export function setScrollRegion(top: number, bottom: number): string {
+ return csi(top, bottom, 'r')
+}
+
+/** Reset scroll region to full screen (DECSTBM, CSI r). Homes the cursor. */
+export const RESET_SCROLL_REGION = csi('r')
+
+// Bracketed paste markers (input from terminal, not output)
+// These are sent by the terminal to delimit pasted content when
+// bracketed paste mode is enabled (via DEC mode 2004)
+
+/** Sent by terminal before pasted content (CSI 200 ~) */
+export const PASTE_START = csi('200~')
+
+/** Sent by terminal after pasted content (CSI 201 ~) */
+export const PASTE_END = csi('201~')
+
+// Focus event markers (input from terminal, not output)
+// These are sent by the terminal when focus changes while
+// focus events mode is enabled (via DEC mode 1004)
+
+/** Sent by terminal when it gains focus (CSI I) */
+export const FOCUS_IN = csi('I')
+
+/** Sent by terminal when it loses focus (CSI O) */
+export const FOCUS_OUT = csi('O')
+
+// Kitty keyboard protocol (CSI u)
+// Enables enhanced key reporting with modifier information
+// See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
+
+/**
+ * Enable Kitty keyboard protocol with basic modifier reporting
+ * CSI > 1 u - pushes mode with flags=1 (disambiguate escape codes)
+ * This makes Shift+Enter send CSI 13;2 u instead of just CR
+ */
+export const ENABLE_KITTY_KEYBOARD = csi('>1u')
+
+/**
+ * Disable Kitty keyboard protocol
+ * CSI < u - pops the keyboard mode stack
+ */
+export const DISABLE_KITTY_KEYBOARD = csi('4;2m')
+
+/**
+ * Disable xterm modifyOtherKeys (reset to default).
+ */
+export const DISABLE_MODIFY_OTHER_KEYS = csi('>4m')
diff --git a/src/ink/termio/dec.ts b/src/ink/termio/dec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ac8bcc731c78aeb53305647a81b3fe8b063ff8d7
--- /dev/null
+++ b/src/ink/termio/dec.ts
@@ -0,0 +1,60 @@
+/**
+ * DEC (Digital Equipment Corporation) Private Mode Sequences
+ *
+ * DEC private modes use CSI ? N h (set) and CSI ? N l (reset) format.
+ * These are terminal-specific extensions to the ANSI standard.
+ */
+
+import { csi } from './csi.js'
+
+/**
+ * DEC private mode numbers
+ */
+export const DEC = {
+ CURSOR_VISIBLE: 25,
+ ALT_SCREEN: 47,
+ ALT_SCREEN_CLEAR: 1049,
+ MOUSE_NORMAL: 1000,
+ MOUSE_BUTTON: 1002,
+ MOUSE_ANY: 1003,
+ MOUSE_SGR: 1006,
+ FOCUS_EVENTS: 1004,
+ BRACKETED_PASTE: 2004,
+ SYNCHRONIZED_UPDATE: 2026,
+} as const
+
+/** Generate CSI ? N h sequence (set mode) */
+export function decset(mode: number): string {
+ return csi(`?${mode}h`)
+}
+
+/** Generate CSI ? N l sequence (reset mode) */
+export function decreset(mode: number): string {
+ return csi(`?${mode}l`)
+}
+
+// Pre-generated sequences for common modes
+export const BSU = decset(DEC.SYNCHRONIZED_UPDATE)
+export const ESU = decreset(DEC.SYNCHRONIZED_UPDATE)
+export const EBP = decset(DEC.BRACKETED_PASTE)
+export const DBP = decreset(DEC.BRACKETED_PASTE)
+export const EFE = decset(DEC.FOCUS_EVENTS)
+export const DFE = decreset(DEC.FOCUS_EVENTS)
+export const SHOW_CURSOR = decset(DEC.CURSOR_VISIBLE)
+export const HIDE_CURSOR = decreset(DEC.CURSOR_VISIBLE)
+export const ENTER_ALT_SCREEN = decset(DEC.ALT_SCREEN_CLEAR)
+export const EXIT_ALT_SCREEN = decreset(DEC.ALT_SCREEN_CLEAR)
+// Mouse tracking: 1000 reports button press/release/wheel, 1002 adds drag
+// events (button-motion), 1003 adds all-motion (no button held — for
+// hover), 1006 uses SGR format (CSI < btn;col;row M/m) instead of legacy
+// X10 bytes. Combined: wheel + click/drag for selection + hover.
+export const ENABLE_MOUSE_TRACKING =
+ decset(DEC.MOUSE_NORMAL) +
+ decset(DEC.MOUSE_BUTTON) +
+ decset(DEC.MOUSE_ANY) +
+ decset(DEC.MOUSE_SGR)
+export const DISABLE_MOUSE_TRACKING =
+ decreset(DEC.MOUSE_SGR) +
+ decreset(DEC.MOUSE_ANY) +
+ decreset(DEC.MOUSE_BUTTON) +
+ decreset(DEC.MOUSE_NORMAL)
diff --git a/src/ink/termio/esc.ts b/src/ink/termio/esc.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6d4cc9286600e4b10a3e726a0d7c1c4c50c885d1
--- /dev/null
+++ b/src/ink/termio/esc.ts
@@ -0,0 +1,67 @@
+/**
+ * ESC Sequence Parser
+ *
+ * Handles simple escape sequences: ESC + one or two characters
+ */
+
+import type { Action } from './types.js'
+
+/**
+ * Parse a simple ESC sequence
+ *
+ * @param chars - Characters after ESC (not including ESC itself)
+ */
+export function parseEsc(chars: string): Action | null {
+ if (chars.length === 0) return null
+
+ const first = chars[0]!
+
+ // Full reset (RIS)
+ if (first === 'c') {
+ return { type: 'reset' }
+ }
+
+ // Cursor save (DECSC)
+ if (first === '7') {
+ return { type: 'cursor', action: { type: 'save' } }
+ }
+
+ // Cursor restore (DECRC)
+ if (first === '8') {
+ return { type: 'cursor', action: { type: 'restore' } }
+ }
+
+ // Index - move cursor down (IND)
+ if (first === 'D') {
+ return {
+ type: 'cursor',
+ action: { type: 'move', direction: 'down', count: 1 },
+ }
+ }
+
+ // Reverse index - move cursor up (RI)
+ if (first === 'M') {
+ return {
+ type: 'cursor',
+ action: { type: 'move', direction: 'up', count: 1 },
+ }
+ }
+
+ // Next line (NEL)
+ if (first === 'E') {
+ return { type: 'cursor', action: { type: 'nextLine', count: 1 } }
+ }
+
+ // Horizontal tab set (HTS)
+ if (first === 'H') {
+ return null // Tab stop, not commonly needed
+ }
+
+ // Charset selection (ESC ( X, ESC ) X, etc.) - silently ignore
+ if ('()'.includes(first) && chars.length >= 2) {
+ return null
+ }
+
+ // Unknown
+ return { type: 'unknown', sequence: `\x1b${chars}` }
+}
diff --git a/src/ink/termio/osc.ts b/src/ink/termio/osc.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9bef515328535b7fbdd1ab938f3c78477ec704e9
--- /dev/null
+++ b/src/ink/termio/osc.ts
@@ -0,0 +1,493 @@
+/**
+ * OSC (Operating System Command) Types and Parser
+ */
+
+import { Buffer } from 'buffer'
+import { env } from '../../utils/env.js'
+import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
+import { BEL, ESC, ESC_TYPE, SEP } from './ansi.js'
+import type { Action, Color, TabStatusAction } from './types.js'
+
+export const OSC_PREFIX = ESC + String.fromCharCode(ESC_TYPE.OSC)
+
+/** String Terminator (ESC \) - alternative to BEL for terminating OSC */
+export const ST = ESC + '\\'
+
+/** Generate an OSC sequence: ESC ] p1;p2;...;pN
+ * Uses ST terminator for Kitty (avoids beeps), BEL for others */
+export function osc(...parts: (string | number)[]): string {
+ const terminator = env.terminal === 'kitty' ? ST : BEL
+ return `${OSC_PREFIX}${parts.join(SEP)}${terminator}`
+}
+
+/**
+ * Wrap an escape sequence for terminal multiplexer passthrough.
+ * tmux and GNU screen intercept escape sequences; DCS passthrough
+ * tunnels them to the outer terminal unmodified.
+ *
+ * tmux 3.3+ gates this behind `allow-passthrough` (default off). When off,
+ * tmux silently drops the whole DCS — no junk, no worse than unwrapped OSC.
+ * Users who want passthrough set it in their .tmux.conf; we don't mutate it.
+ *
+ * Do NOT wrap BEL: raw \x07 triggers tmux's bell-action (window flag);
+ * wrapped \x07 is opaque DCS payload and tmux never sees the bell.
+ */
+export function wrapForMultiplexer(sequence: string): string {
+ if (process.env['TMUX']) {
+ const escaped = sequence.replaceAll('\x1b', '\x1b\x1b')
+ return `\x1bPtmux;${escaped}\x1b\\`
+ }
+ if (process.env['STY']) {
+ return `\x1bP${sequence}\x1b\\`
+ }
+ return sequence
+}
+
+/**
+ * Which path setClipboard() will take, based on env state. Synchronous so
+ * callers can show an honest toast without awaiting the copy itself.
+ *
+ * - 'native': pbcopy (or equivalent) will run — high-confidence system
+ * clipboard write. tmux buffer may also be loaded as a bonus.
+ * - 'tmux-buffer': tmux load-buffer will run, but no native tool — paste
+ * with prefix+] works. System clipboard depends on tmux's set-clipboard
+ * option + outer terminal OSC 52 support; can't know from here.
+ * - 'osc52': only the raw OSC 52 sequence will be written to stdout.
+ * Best-effort; iTerm2 disables OSC 52 by default.
+ *
+ * pbcopy gating uses SSH_CONNECTION specifically, not SSH_TTY — tmux panes
+ * inherit SSH_TTY forever even after local reattach, but SSH_CONNECTION is
+ * in tmux's default update-environment set and gets cleared.
+ */
+export type ClipboardPath = 'native' | 'tmux-buffer' | 'osc52'
+
+export function getClipboardPath(): ClipboardPath {
+ const nativeAvailable =
+ process.platform === 'darwin' && !process.env['SSH_CONNECTION']
+ if (nativeAvailable) return 'native'
+ if (process.env['TMUX']) return 'tmux-buffer'
+ return 'osc52'
+}
+
+/**
+ * Wrap a payload in tmux's DCS passthrough: ESC P tmux ; ESC \
+ * tmux forwards the payload to the outer terminal, bypassing its own parser.
+ * Inner ESCs must be doubled. Requires `set -g allow-passthrough on` in
+ * ~/.tmux.conf; without it, tmux silently drops the whole DCS (no regression).
+ */
+function tmuxPassthrough(payload: string): string {
+ return `${ESC}Ptmux;${payload.replaceAll(ESC, ESC + ESC)}${ST}`
+}
+
+/**
+ * Load text into tmux's paste buffer via `tmux load-buffer`.
+ * -w (tmux 3.2+) propagates to the outer terminal's clipboard via tmux's
+ * own OSC 52 emission. -w is dropped for iTerm2: tmux's OSC 52 emission
+ * crashes the iTerm2 session over SSH.
+ *
+ * Returns true if the buffer was loaded successfully.
+ */
+export async function tmuxLoadBuffer(text: string): Promise {
+ if (!process.env['TMUX']) return false
+ const args =
+ process.env['LC_TERMINAL'] === 'iTerm2'
+ ? ['load-buffer', '-']
+ : ['load-buffer', '-w', '-']
+ const { code } = await execFileNoThrow('tmux', args, {
+ input: text,
+ useCwd: false,
+ timeout: 2000,
+ })
+ return code === 0
+}
+
+/**
+ * OSC 52 clipboard write: ESC ] 52 ; c ; BEL/ST
+ * 'c' selects the clipboard (vs 'p' for primary selection on X11).
+ *
+ * When inside tmux ($TMUX set), `tmux load-buffer -w -` is the primary
+ * path. tmux's buffer is always reachable — works over SSH, survives
+ * detach/reattach, immune to stale env vars. The -w flag (tmux 3.2+) tells
+ * tmux to also propagate to the outer terminal via its own OSC 52 path,
+ * which tmux wraps correctly for the attached client. On older tmux, -w is
+ * ignored and the buffer is still loaded. -w is dropped for iTerm2 (#22432)
+ * because tmux's own OSC 52 emission (empty selection param: ESC]52;;b64)
+ * crashes iTerm2 over SSH.
+ *
+ * After load-buffer succeeds, we ALSO return a DCS-passthrough-wrapped
+ * OSC 52 for the caller to write to stdout. Our sequence uses explicit `c`
+ * (not tmux's crashy empty-param variant), so it sidesteps the #22432 path.
+ * With `allow-passthrough on` + an OSC-52-capable outer terminal, selection
+ * reaches the system clipboard; with either off, tmux silently drops the
+ * DCS and prefix+] still works. See Greg Smith's "free pony" in
+ * https://anthropic.slack.com/archives/C07VBSHV7EV/p1773177228548119.
+ *
+ * If load-buffer fails entirely, fall through to raw OSC 52.
+ *
+ * Outside tmux, write raw OSC 52 to stdout (caller handles the write).
+ *
+ * Local (no SSH_CONNECTION): also shell out to a native clipboard utility.
+ * OSC 52 and tmux -w both depend on terminal settings — iTerm2 disables
+ * OSC 52 by default, VS Code shows a permission prompt on first use. Native
+ * utilities (pbcopy/wl-copy/xclip/xsel/clip.exe) always work locally. Over
+ * SSH these would write to the remote clipboard — OSC 52 is the right path there.
+ *
+ * Returns the sequence for the caller to write to stdout (raw OSC 52
+ * outside tmux, DCS-wrapped inside).
+ */
+export async function setClipboard(text: string): Promise {
+ const b64 = Buffer.from(text, 'utf8').toString('base64')
+ const raw = osc(OSC.CLIPBOARD, 'c', b64)
+
+ // Native safety net — fire FIRST, before the tmux await, so a quick
+ // focus-switch after selecting doesn't race pbcopy. Previously this ran
+ // AFTER awaiting tmux load-buffer, adding ~50-100ms of subprocess latency
+ // before pbcopy even started — fast cmd+tab → paste would beat it
+ // (https://anthropic.slack.com/archives/C07VBSHV7EV/p1773943921788829).
+ // Gated on SSH_CONNECTION (not SSH_TTY) since tmux panes inherit SSH_TTY
+ // forever but SSH_CONNECTION is in tmux's default update-environment and
+ // clears on local attach. Fire-and-forget.
+ if (!process.env['SSH_CONNECTION']) copyNative(text)
+
+ const tmuxBufferLoaded = await tmuxLoadBuffer(text)
+
+ // Inner OSC uses BEL directly (not osc()) — ST's ESC would need doubling
+ // too, and BEL works everywhere for OSC 52.
+ if (tmuxBufferLoaded) return tmuxPassthrough(`${ESC}]52;c;${b64}${BEL}`)
+ return raw
+}
+
+// Linux clipboard tool: undefined = not yet probed, null = none available.
+// Probe order: wl-copy (Wayland) → xclip (X11) → xsel (X11 fallback).
+// Cached after first attempt so repeated mouse-ups skip the probe chain.
+let linuxCopy: 'wl-copy' | 'xclip' | 'xsel' | null | undefined
+
+/**
+ * Shell out to a native clipboard utility as a safety net for OSC 52.
+ * Only called when not in an SSH session (over SSH, these would write to
+ * the remote machine's clipboard — OSC 52 is the right path there).
+ * Fire-and-forget: failures are silent since OSC 52 may have succeeded.
+ */
+function copyNative(text: string): void {
+ const opts = { input: text, useCwd: false, timeout: 2000 }
+ switch (process.platform) {
+ case 'darwin':
+ void execFileNoThrow('pbcopy', [], opts)
+ return
+ case 'linux': {
+ if (linuxCopy === null) return
+ if (linuxCopy === 'wl-copy') {
+ void execFileNoThrow('wl-copy', [], opts)
+ return
+ }
+ if (linuxCopy === 'xclip') {
+ void execFileNoThrow('xclip', ['-selection', 'clipboard'], opts)
+ return
+ }
+ if (linuxCopy === 'xsel') {
+ void execFileNoThrow('xsel', ['--clipboard', '--input'], opts)
+ return
+ }
+ // First call: probe wl-copy (Wayland) then xclip/xsel (X11), cache winner.
+ void execFileNoThrow('wl-copy', [], opts).then(r => {
+ if (r.code === 0) {
+ linuxCopy = 'wl-copy'
+ return
+ }
+ void execFileNoThrow('xclip', ['-selection', 'clipboard'], opts).then(
+ r2 => {
+ if (r2.code === 0) {
+ linuxCopy = 'xclip'
+ return
+ }
+ void execFileNoThrow('xsel', ['--clipboard', '--input'], opts).then(
+ r3 => {
+ linuxCopy = r3.code === 0 ? 'xsel' : null
+ },
+ )
+ },
+ )
+ })
+ return
+ }
+ case 'win32':
+ // clip.exe is always available on Windows. Unicode handling is
+ // imperfect (system locale encoding) but good enough for a fallback.
+ void execFileNoThrow('clip', [], opts)
+ return
+ }
+}
+
+/** @internal test-only */
+export function _resetLinuxCopyCache(): void {
+ linuxCopy = undefined
+}
+
+/**
+ * OSC command numbers
+ */
+export const OSC = {
+ SET_TITLE_AND_ICON: 0,
+ SET_ICON: 1,
+ SET_TITLE: 2,
+ SET_COLOR: 4,
+ SET_CWD: 7,
+ HYPERLINK: 8,
+ ITERM2: 9, // iTerm2 proprietary sequences
+ SET_FG_COLOR: 10,
+ SET_BG_COLOR: 11,
+ SET_CURSOR_COLOR: 12,
+ CLIPBOARD: 52,
+ KITTY: 99, // Kitty notification protocol
+ RESET_COLOR: 104,
+ RESET_FG_COLOR: 110,
+ RESET_BG_COLOR: 111,
+ RESET_CURSOR_COLOR: 112,
+ SEMANTIC_PROMPT: 133,
+ GHOSTTY: 777, // Ghostty notification protocol
+ TAB_STATUS: 21337, // Tab status extension
+} as const
+
+/**
+ * Parse an OSC sequence into an action
+ *
+ * @param content - The sequence content (without ESC ] and terminator)
+ */
+export function parseOSC(content: string): Action | null {
+ const semicolonIdx = content.indexOf(';')
+ const command = semicolonIdx >= 0 ? content.slice(0, semicolonIdx) : content
+ const data = semicolonIdx >= 0 ? content.slice(semicolonIdx + 1) : ''
+
+ const commandNum = parseInt(command, 10)
+
+ // Window/icon title
+ if (commandNum === OSC.SET_TITLE_AND_ICON) {
+ return { type: 'title', action: { type: 'both', title: data } }
+ }
+ if (commandNum === OSC.SET_ICON) {
+ return { type: 'title', action: { type: 'iconName', name: data } }
+ }
+ if (commandNum === OSC.SET_TITLE) {
+ return { type: 'title', action: { type: 'windowTitle', title: data } }
+ }
+
+ // Hyperlinks (OSC 8)
+ if (commandNum === OSC.HYPERLINK) {
+ const parts = data.split(';')
+ const paramsStr = parts[0] ?? ''
+ const url = parts.slice(1).join(';')
+
+ if (url === '') {
+ return { type: 'link', action: { type: 'end' } }
+ }
+
+ const params: Record = {}
+ if (paramsStr) {
+ for (const pair of paramsStr.split(':')) {
+ const eqIdx = pair.indexOf('=')
+ if (eqIdx >= 0) {
+ params[pair.slice(0, eqIdx)] = pair.slice(eqIdx + 1)
+ }
+ }
+ }
+
+ return {
+ type: 'link',
+ action: {
+ type: 'start',
+ url,
+ params: Object.keys(params).length > 0 ? params : undefined,
+ },
+ }
+ }
+
+ // Tab status (OSC 21337)
+ if (commandNum === OSC.TAB_STATUS) {
+ return { type: 'tabStatus', action: parseTabStatus(data) }
+ }
+
+ return { type: 'unknown', sequence: `\x1b]${content}` }
+}
+
+/**
+ * Parse an XParseColor-style color spec into an RGB Color.
+ * Accepts `#RRGGBB` and `rgb:R/G/B` (1–4 hex digits per component, scaled
+ * to 8-bit). Returns null on parse failure.
+ */
+export function parseOscColor(spec: string): Color | null {
+ const hex = spec.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i)
+ if (hex) {
+ return {
+ type: 'rgb',
+ r: parseInt(hex[1]!, 16),
+ g: parseInt(hex[2]!, 16),
+ b: parseInt(hex[3]!, 16),
+ }
+ }
+ const rgb = spec.match(
+ /^rgb:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})$/i,
+ )
+ if (rgb) {
+ // XParseColor: N hex digits → value / (16^N - 1), scale to 0-255
+ const scale = (s: string) =>
+ Math.round((parseInt(s, 16) / (16 ** s.length - 1)) * 255)
+ return {
+ type: 'rgb',
+ r: scale(rgb[1]!),
+ g: scale(rgb[2]!),
+ b: scale(rgb[3]!),
+ }
+ }
+ return null
+}
+
+/**
+ * Parse OSC 21337 payload: `key=value;key=value;...` with `\;` and `\\`
+ * escapes inside values. Bare key or `key=` clears that field; unknown
+ * keys are ignored.
+ */
+function parseTabStatus(data: string): TabStatusAction {
+ const action: TabStatusAction = {}
+ for (const [key, value] of splitTabStatusPairs(data)) {
+ switch (key) {
+ case 'indicator':
+ action.indicator = value === '' ? null : parseOscColor(value)
+ break
+ case 'status':
+ action.status = value === '' ? null : value
+ break
+ case 'status-color':
+ action.statusColor = value === '' ? null : parseOscColor(value)
+ break
+ }
+ }
+ return action
+}
+
+/** Split `k=v;k=v` honoring `\;` and `\\` escapes. Yields [key, unescapedValue]. */
+function* splitTabStatusPairs(data: string): Generator<[string, string]> {
+ let key = ''
+ let val = ''
+ let inVal = false
+ let esc = false
+ for (const c of data) {
+ if (esc) {
+ if (inVal) val += c
+ else key += c
+ esc = false
+ } else if (c === '\\') {
+ esc = true
+ } else if (c === ';') {
+ yield [key, val]
+ key = ''
+ val = ''
+ inVal = false
+ } else if (c === '=' && !inVal) {
+ inVal = true
+ } else if (inVal) {
+ val += c
+ } else {
+ key += c
+ }
+ }
+ if (key || inVal) yield [key, val]
+}
+
+// Output generators
+
+/** Start a hyperlink (OSC 8). Auto-assigns an id= param derived from the URL
+ * so terminals group wrapped lines of the same link together (the spec says
+ * cells with matching URI *and* nonempty id are joined; without an id each
+ * wrapped line is a separate link — inconsistent hover, partial tooltips).
+ * Empty url = close sequence (empty params per spec). */
+export function link(url: string, params?: Record): string {
+ if (!url) return LINK_END
+ const p = { id: osc8Id(url), ...params }
+ const paramStr = Object.entries(p)
+ .map(([k, v]) => `${k}=${v}`)
+ .join(':')
+ return osc(OSC.HYPERLINK, paramStr, url)
+}
+
+function osc8Id(url: string): string {
+ let h = 0
+ for (let i = 0; i < url.length; i++)
+ h = ((h << 5) - h + url.charCodeAt(i)) | 0
+ return (h >>> 0).toString(36)
+}
+
+/** End a hyperlink (OSC 8) */
+export const LINK_END = osc(OSC.HYPERLINK, '', '')
+
+// iTerm2 OSC 9 subcommands
+
+/** iTerm2 OSC 9 subcommand numbers */
+export const ITERM2 = {
+ NOTIFY: 0,
+ BADGE: 2,
+ PROGRESS: 4,
+} as const
+
+/** Progress operation codes (for use with ITERM2.PROGRESS) */
+export const PROGRESS = {
+ CLEAR: 0,
+ SET: 1,
+ ERROR: 2,
+ INDETERMINATE: 3,
+} as const
+
+/**
+ * Clear iTerm2 progress bar sequence (OSC 9;4;0;BEL)
+ * Uses BEL terminator since this is for cleanup (not runtime notification)
+ * and we want to ensure it's always sent regardless of terminal type.
+ */
+export const CLEAR_ITERM2_PROGRESS = `${OSC_PREFIX}${OSC.ITERM2};${ITERM2.PROGRESS};${PROGRESS.CLEAR};${BEL}`
+
+/**
+ * Clear terminal title sequence (OSC 0 with empty string + BEL).
+ * Uses BEL terminator for cleanup — safe on all terminals.
+ */
+export const CLEAR_TERMINAL_TITLE = `${OSC_PREFIX}${OSC.SET_TITLE_AND_ICON};${BEL}`
+
+/** Clear all three OSC 21337 tab-status fields. Used on exit. */
+export const CLEAR_TAB_STATUS = osc(
+ OSC.TAB_STATUS,
+ 'indicator=;status=;status-color=',
+)
+
+/**
+ * Gate for emitting OSC 21337 (tab-status indicator). Ant-only while the
+ * spec is unstable. Terminals that don't recognize it discard silently, so
+ * emission is safe unconditionally — we don't gate on terminal detection
+ * since support is expected across several terminals.
+ *
+ * Callers must wrap output with wrapForMultiplexer() so tmux/screen
+ * DCS-passthrough carries the sequence to the outer terminal.
+ */
+export function supportsTabStatus(): boolean {
+ return process.env.USER_TYPE === 'ant'
+}
+
+/**
+ * Emit an OSC 21337 tab-status sequence. Omitted fields are left unchanged
+ * by the receiving terminal; `null` sends an empty value to clear.
+ * `;` and `\` in status text are escaped per the spec.
+ */
+export function tabStatus(fields: TabStatusAction): string {
+ const parts: string[] = []
+ const rgb = (c: Color) =>
+ c.type === 'rgb'
+ ? `#${[c.r, c.g, c.b].map(n => n.toString(16).padStart(2, '0')).join('')}`
+ : ''
+ if ('indicator' in fields)
+ parts.push(`indicator=${fields.indicator ? rgb(fields.indicator) : ''}`)
+ if ('status' in fields)
+ parts.push(
+ `status=${fields.status?.replaceAll('\\', '\\\\').replaceAll(';', '\\;') ?? ''}`,
+ )
+ if ('statusColor' in fields)
+ parts.push(
+ `status-color=${fields.statusColor ? rgb(fields.statusColor) : ''}`,
+ )
+ return osc(OSC.TAB_STATUS, parts.join(';'))
+}
diff --git a/src/ink/termio/parser.ts b/src/ink/termio/parser.ts
new file mode 100644
index 0000000000000000000000000000000000000000..301f14c5a0bd3dce2b7d3bf7e0aa901bbf5f99b1
--- /dev/null
+++ b/src/ink/termio/parser.ts
@@ -0,0 +1,394 @@
+/**
+ * ANSI Parser - Semantic Action Generator
+ *
+ * A streaming parser for ANSI escape sequences that produces semantic actions.
+ * Uses the tokenizer for escape sequence boundary detection, then interprets
+ * each sequence to produce structured actions.
+ *
+ * Key design decisions:
+ * - Streaming: can process input incrementally
+ * - Semantic output: produces structured actions, not string tokens
+ * - Style tracking: maintains current text style state
+ */
+
+import { getGraphemeSegmenter } from '../../utils/intl.js'
+import { C0 } from './ansi.js'
+import { CSI, CURSOR_STYLES, ERASE_DISPLAY, ERASE_LINE_REGION } from './csi.js'
+import { DEC } from './dec.js'
+import { parseEsc } from './esc.js'
+import { parseOSC } from './osc.js'
+import { applySGR } from './sgr.js'
+import { createTokenizer, type Token, type Tokenizer } from './tokenize.js'
+import type { Action, Grapheme, TextStyle } from './types.js'
+import { defaultStyle } from './types.js'
+
+// =============================================================================
+// Grapheme Utilities
+// =============================================================================
+
+function isEmoji(codePoint: number): boolean {
+ return (
+ (codePoint >= 0x2600 && codePoint <= 0x26ff) ||
+ (codePoint >= 0x2700 && codePoint <= 0x27bf) ||
+ (codePoint >= 0x1f300 && codePoint <= 0x1f9ff) ||
+ (codePoint >= 0x1fa00 && codePoint <= 0x1faff) ||
+ (codePoint >= 0x1f1e0 && codePoint <= 0x1f1ff)
+ )
+}
+
+function isEastAsianWide(codePoint: number): boolean {
+ return (
+ (codePoint >= 0x1100 && codePoint <= 0x115f) ||
+ (codePoint >= 0x2e80 && codePoint <= 0x9fff) ||
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
+ (codePoint >= 0xfe10 && codePoint <= 0xfe1f) ||
+ (codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
+ (codePoint >= 0xff00 && codePoint <= 0xff60) ||
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
+ (codePoint >= 0x20000 && codePoint <= 0x2fffd) ||
+ (codePoint >= 0x30000 && codePoint <= 0x3fffd)
+ )
+}
+
+function hasMultipleCodepoints(str: string): boolean {
+ let count = 0
+ for (const _ of str) {
+ count++
+ if (count > 1) return true
+ }
+ return false
+}
+
+function graphemeWidth(grapheme: string): 1 | 2 {
+ if (hasMultipleCodepoints(grapheme)) return 2
+ const codePoint = grapheme.codePointAt(0)
+ if (codePoint === undefined) return 1
+ if (isEmoji(codePoint) || isEastAsianWide(codePoint)) return 2
+ return 1
+}
+
+function* segmentGraphemes(str: string): Generator {
+ for (const { segment } of getGraphemeSegmenter().segment(str)) {
+ yield { value: segment, width: graphemeWidth(segment) }
+ }
+}
+
+// =============================================================================
+// Sequence Parsing
+// =============================================================================
+
+function parseCSIParams(paramStr: string): number[] {
+ if (paramStr === '') return []
+ return paramStr.split(/[;:]/).map(s => (s === '' ? 0 : parseInt(s, 10)))
+}
+
+/** Parse a raw CSI sequence (e.g., "\x1b[31m") into an action */
+function parseCSI(rawSequence: string): Action | null {
+ const inner = rawSequence.slice(2)
+ if (inner.length === 0) return null
+
+ const finalByte = inner.charCodeAt(inner.length - 1)
+ const beforeFinal = inner.slice(0, -1)
+
+ let privateMode = ''
+ let paramStr = beforeFinal
+ let intermediate = ''
+
+ if (beforeFinal.length > 0 && '?>='.includes(beforeFinal[0]!)) {
+ privateMode = beforeFinal[0]!
+ paramStr = beforeFinal.slice(1)
+ }
+
+ const intermediateMatch = paramStr.match(/([^0-9;:]+)$/)
+ if (intermediateMatch) {
+ intermediate = intermediateMatch[1]!
+ paramStr = paramStr.slice(0, -intermediate.length)
+ }
+
+ const params = parseCSIParams(paramStr)
+ const p0 = params[0] ?? 1
+ const p1 = params[1] ?? 1
+
+ // SGR (Select Graphic Rendition)
+ if (finalByte === CSI.SGR && privateMode === '') {
+ return { type: 'sgr', params: paramStr }
+ }
+
+ // Cursor movement
+ if (finalByte === CSI.CUU) {
+ return {
+ type: 'cursor',
+ action: { type: 'move', direction: 'up', count: p0 },
+ }
+ }
+ if (finalByte === CSI.CUD) {
+ return {
+ type: 'cursor',
+ action: { type: 'move', direction: 'down', count: p0 },
+ }
+ }
+ if (finalByte === CSI.CUF) {
+ return {
+ type: 'cursor',
+ action: { type: 'move', direction: 'forward', count: p0 },
+ }
+ }
+ if (finalByte === CSI.CUB) {
+ return {
+ type: 'cursor',
+ action: { type: 'move', direction: 'back', count: p0 },
+ }
+ }
+ if (finalByte === CSI.CNL) {
+ return { type: 'cursor', action: { type: 'nextLine', count: p0 } }
+ }
+ if (finalByte === CSI.CPL) {
+ return { type: 'cursor', action: { type: 'prevLine', count: p0 } }
+ }
+ if (finalByte === CSI.CHA) {
+ return { type: 'cursor', action: { type: 'column', col: p0 } }
+ }
+ if (finalByte === CSI.CUP || finalByte === CSI.HVP) {
+ return { type: 'cursor', action: { type: 'position', row: p0, col: p1 } }
+ }
+ if (finalByte === CSI.VPA) {
+ return { type: 'cursor', action: { type: 'row', row: p0 } }
+ }
+
+ // Erase
+ if (finalByte === CSI.ED) {
+ const region = ERASE_DISPLAY[params[0] ?? 0] ?? 'toEnd'
+ return { type: 'erase', action: { type: 'display', region } }
+ }
+ if (finalByte === CSI.EL) {
+ const region = ERASE_LINE_REGION[params[0] ?? 0] ?? 'toEnd'
+ return { type: 'erase', action: { type: 'line', region } }
+ }
+ if (finalByte === CSI.ECH) {
+ return { type: 'erase', action: { type: 'chars', count: p0 } }
+ }
+
+ // Scroll
+ if (finalByte === CSI.SU) {
+ return { type: 'scroll', action: { type: 'up', count: p0 } }
+ }
+ if (finalByte === CSI.SD) {
+ return { type: 'scroll', action: { type: 'down', count: p0 } }
+ }
+ if (finalByte === CSI.DECSTBM) {
+ return {
+ type: 'scroll',
+ action: { type: 'setRegion', top: p0, bottom: p1 },
+ }
+ }
+
+ // Cursor save/restore
+ if (finalByte === CSI.SCOSC) {
+ return { type: 'cursor', action: { type: 'save' } }
+ }
+ if (finalByte === CSI.SCORC) {
+ return { type: 'cursor', action: { type: 'restore' } }
+ }
+
+ // Cursor style
+ if (finalByte === CSI.DECSCUSR && intermediate === ' ') {
+ const styleInfo = CURSOR_STYLES[p0] ?? CURSOR_STYLES[0]!
+ return { type: 'cursor', action: { type: 'style', ...styleInfo } }
+ }
+
+ // Private modes
+ if (privateMode === '?' && (finalByte === CSI.SM || finalByte === CSI.RM)) {
+ const enabled = finalByte === CSI.SM
+
+ if (p0 === DEC.CURSOR_VISIBLE) {
+ return {
+ type: 'cursor',
+ action: enabled ? { type: 'show' } : { type: 'hide' },
+ }
+ }
+ if (p0 === DEC.ALT_SCREEN_CLEAR || p0 === DEC.ALT_SCREEN) {
+ return { type: 'mode', action: { type: 'alternateScreen', enabled } }
+ }
+ if (p0 === DEC.BRACKETED_PASTE) {
+ return { type: 'mode', action: { type: 'bracketedPaste', enabled } }
+ }
+ if (p0 === DEC.MOUSE_NORMAL) {
+ return {
+ type: 'mode',
+ action: { type: 'mouseTracking', mode: enabled ? 'normal' : 'off' },
+ }
+ }
+ if (p0 === DEC.MOUSE_BUTTON) {
+ return {
+ type: 'mode',
+ action: { type: 'mouseTracking', mode: enabled ? 'button' : 'off' },
+ }
+ }
+ if (p0 === DEC.MOUSE_ANY) {
+ return {
+ type: 'mode',
+ action: { type: 'mouseTracking', mode: enabled ? 'any' : 'off' },
+ }
+ }
+ if (p0 === DEC.FOCUS_EVENTS) {
+ return { type: 'mode', action: { type: 'focusEvents', enabled } }
+ }
+ }
+
+ return { type: 'unknown', sequence: rawSequence }
+}
+
+/**
+ * Identify the type of escape sequence from its raw form.
+ */
+function identifySequence(
+ seq: string,
+): 'csi' | 'osc' | 'esc' | 'ss3' | 'unknown' {
+ if (seq.length < 2) return 'unknown'
+ if (seq.charCodeAt(0) !== C0.ESC) return 'unknown'
+
+ const second = seq.charCodeAt(1)
+ if (second === 0x5b) return 'csi' // [
+ if (second === 0x5d) return 'osc' // ]
+ if (second === 0x4f) return 'ss3' // O
+ return 'esc'
+}
+
+// =============================================================================
+// Main Parser
+// =============================================================================
+
+/**
+ * Parser class - maintains state for streaming/incremental parsing
+ *
+ * Usage:
+ * ```typescript
+ * const parser = new Parser()
+ * const actions1 = parser.feed('partial\x1b[')
+ * const actions2 = parser.feed('31mred') // state maintained internally
+ * ```
+ */
+export class Parser {
+ private tokenizer: Tokenizer = createTokenizer()
+
+ style: TextStyle = defaultStyle()
+ inLink = false
+ linkUrl: string | undefined
+
+ reset(): void {
+ this.tokenizer.reset()
+ this.style = defaultStyle()
+ this.inLink = false
+ this.linkUrl = undefined
+ }
+
+ /** Feed input and get resulting actions */
+ feed(input: string): Action[] {
+ const tokens = this.tokenizer.feed(input)
+ const actions: Action[] = []
+
+ for (const token of tokens) {
+ const tokenActions = this.processToken(token)
+ actions.push(...tokenActions)
+ }
+
+ return actions
+ }
+
+ private processToken(token: Token): Action[] {
+ switch (token.type) {
+ case 'text':
+ return this.processText(token.value)
+
+ case 'sequence':
+ return this.processSequence(token.value)
+ }
+ }
+
+ private processText(text: string): Action[] {
+ // Handle BEL characters embedded in text
+ const actions: Action[] = []
+ let current = ''
+
+ for (const char of text) {
+ if (char.charCodeAt(0) === C0.BEL) {
+ if (current) {
+ const graphemes = [...segmentGraphemes(current)]
+ if (graphemes.length > 0) {
+ actions.push({ type: 'text', graphemes, style: { ...this.style } })
+ }
+ current = ''
+ }
+ actions.push({ type: 'bell' })
+ } else {
+ current += char
+ }
+ }
+
+ if (current) {
+ const graphemes = [...segmentGraphemes(current)]
+ if (graphemes.length > 0) {
+ actions.push({ type: 'text', graphemes, style: { ...this.style } })
+ }
+ }
+
+ return actions
+ }
+
+ private processSequence(seq: string): Action[] {
+ const seqType = identifySequence(seq)
+
+ switch (seqType) {
+ case 'csi': {
+ const action = parseCSI(seq)
+ if (!action) return []
+ if (action.type === 'sgr') {
+ this.style = applySGR(action.params, this.style)
+ return []
+ }
+ return [action]
+ }
+
+ case 'osc': {
+ // Extract OSC content (between ESC ] and terminator)
+ let content = seq.slice(2)
+ // Remove terminator (BEL or ESC \)
+ if (content.endsWith('\x07')) {
+ content = content.slice(0, -1)
+ } else if (content.endsWith('\x1b\\')) {
+ content = content.slice(0, -2)
+ }
+
+ const action = parseOSC(content)
+ if (action) {
+ if (action.type === 'link') {
+ if (action.action.type === 'start') {
+ this.inLink = true
+ this.linkUrl = action.action.url
+ } else {
+ this.inLink = false
+ this.linkUrl = undefined
+ }
+ }
+ return [action]
+ }
+ return []
+ }
+
+ case 'esc': {
+ const escContent = seq.slice(1)
+ const action = parseEsc(escContent)
+ return action ? [action] : []
+ }
+
+ case 'ss3':
+ // SS3 sequences are typically cursor keys in application mode
+ // For output parsing, treat as unknown
+ return [{ type: 'unknown', sequence: seq }]
+
+ default:
+ return [{ type: 'unknown', sequence: seq }]
+ }
+ }
+}
diff --git a/src/ink/termio/tokenize.ts b/src/ink/termio/tokenize.ts
new file mode 100644
index 0000000000000000000000000000000000000000..68a0d113c596772512a2149a5b58edb0b235d9cf
--- /dev/null
+++ b/src/ink/termio/tokenize.ts
@@ -0,0 +1,319 @@
+/**
+ * Input Tokenizer - Escape sequence boundary detection
+ *
+ * Splits terminal input into tokens: text chunks and raw escape sequences.
+ * Unlike the Parser which interprets sequences semantically, this just
+ * identifies boundaries for use by keyboard input parsing.
+ */
+
+import { C0, ESC_TYPE, isEscFinal } from './ansi.js'
+import { isCSIFinal, isCSIIntermediate, isCSIParam } from './csi.js'
+
+export type Token =
+ | { type: 'text'; value: string }
+ | { type: 'sequence'; value: string }
+
+type State =
+ | 'ground'
+ | 'escape'
+ | 'escapeIntermediate'
+ | 'csi'
+ | 'ss3'
+ | 'osc'
+ | 'dcs'
+ | 'apc'
+
+export type Tokenizer = {
+ /** Feed input and get resulting tokens */
+ feed(input: string): Token[]
+ /** Flush any buffered incomplete sequences */
+ flush(): Token[]
+ /** Reset tokenizer state */
+ reset(): void
+ /** Get any buffered incomplete sequence */
+ buffer(): string
+}
+
+type TokenizerOptions = {
+ /**
+ * Treat `CSI M` as an X10 mouse event prefix and consume 3 payload bytes.
+ * Only enable for stdin input — `\x1b[M` is also CSI DL (Delete Lines) in
+ * output streams, and enabling this there swallows display text. Default false.
+ */
+ x10Mouse?: boolean
+}
+
+/**
+ * Create a streaming tokenizer for terminal input.
+ *
+ * Usage:
+ * ```typescript
+ * const tokenizer = createTokenizer()
+ * const tokens1 = tokenizer.feed('hello\x1b[')
+ * const tokens2 = tokenizer.feed('A') // completes the escape sequence
+ * const remaining = tokenizer.flush() // force output incomplete sequences
+ * ```
+ */
+export function createTokenizer(options?: TokenizerOptions): Tokenizer {
+ let currentState: State = 'ground'
+ let currentBuffer = ''
+ const x10Mouse = options?.x10Mouse ?? false
+
+ return {
+ feed(input: string): Token[] {
+ const result = tokenize(
+ input,
+ currentState,
+ currentBuffer,
+ false,
+ x10Mouse,
+ )
+ currentState = result.state.state
+ currentBuffer = result.state.buffer
+ return result.tokens
+ },
+
+ flush(): Token[] {
+ const result = tokenize('', currentState, currentBuffer, true, x10Mouse)
+ currentState = result.state.state
+ currentBuffer = result.state.buffer
+ return result.tokens
+ },
+
+ reset(): void {
+ currentState = 'ground'
+ currentBuffer = ''
+ },
+
+ buffer(): string {
+ return currentBuffer
+ },
+ }
+}
+
+type InternalState = {
+ state: State
+ buffer: string
+}
+
+function tokenize(
+ input: string,
+ initialState: State,
+ initialBuffer: string,
+ flush: boolean,
+ x10Mouse: boolean,
+): { tokens: Token[]; state: InternalState } {
+ const tokens: Token[] = []
+ const result: InternalState = {
+ state: initialState,
+ buffer: '',
+ }
+
+ const data = initialBuffer + input
+ let i = 0
+ let textStart = 0
+ let seqStart = 0
+
+ const flushText = (): void => {
+ if (i > textStart) {
+ const text = data.slice(textStart, i)
+ if (text) {
+ tokens.push({ type: 'text', value: text })
+ }
+ }
+ textStart = i
+ }
+
+ const emitSequence = (seq: string): void => {
+ if (seq) {
+ tokens.push({ type: 'sequence', value: seq })
+ }
+ result.state = 'ground'
+ textStart = i
+ }
+
+ while (i < data.length) {
+ const code = data.charCodeAt(i)
+
+ switch (result.state) {
+ case 'ground':
+ if (code === C0.ESC) {
+ flushText()
+ seqStart = i
+ result.state = 'escape'
+ i++
+ } else {
+ i++
+ }
+ break
+
+ case 'escape':
+ if (code === ESC_TYPE.CSI) {
+ result.state = 'csi'
+ i++
+ } else if (code === ESC_TYPE.OSC) {
+ result.state = 'osc'
+ i++
+ } else if (code === ESC_TYPE.DCS) {
+ result.state = 'dcs'
+ i++
+ } else if (code === ESC_TYPE.APC) {
+ result.state = 'apc'
+ i++
+ } else if (code === 0x4f) {
+ // 'O' - SS3
+ result.state = 'ss3'
+ i++
+ } else if (isCSIIntermediate(code)) {
+ // Intermediate byte (e.g., ESC ( for charset) - continue buffering
+ result.state = 'escapeIntermediate'
+ i++
+ } else if (isEscFinal(code)) {
+ // Two-character escape sequence
+ i++
+ emitSequence(data.slice(seqStart, i))
+ } else if (code === C0.ESC) {
+ // Double escape - emit first, start new
+ emitSequence(data.slice(seqStart, i))
+ seqStart = i
+ result.state = 'escape'
+ i++
+ } else {
+ // Invalid - treat ESC as text
+ result.state = 'ground'
+ textStart = seqStart
+ }
+ break
+
+ case 'escapeIntermediate':
+ // After intermediate byte(s), wait for final byte
+ if (isCSIIntermediate(code)) {
+ // More intermediate bytes
+ i++
+ } else if (isEscFinal(code)) {
+ // Final byte - complete the sequence
+ i++
+ emitSequence(data.slice(seqStart, i))
+ } else {
+ // Invalid - treat as text
+ result.state = 'ground'
+ textStart = seqStart
+ }
+ break
+
+ case 'csi':
+ // X10 mouse: CSI M + 3 raw payload bytes (Cb+32, Cx+32, Cy+32).
+ // M immediately after [ (offset 2) means no params — SGR mouse
+ // (CSI < … M) has a `<` param byte first and reaches M at offset > 2.
+ // Terminals that ignore DECSET 1006 but honor 1000/1002 emit this
+ // legacy encoding; without this branch the 3 payload bytes leak
+ // through as text (`` `rK `` / `arK` garbage in the prompt).
+ //
+ // Gated on x10Mouse — `\x1b[M` is also CSI DL (Delete Lines) and
+ // blindly consuming 3 chars corrupts output rendering (Parser/Ansi)
+ // and fragments bracketed-paste PASTE_END. Only stdin enables this.
+ // The ≥0x20 check on each payload slot is belt-and-suspenders: X10
+ // guarantees Cb≥32, Cx≥33, Cy≥33, so a control byte (ESC=0x1B) in
+ // any slot means this is CSI DL adjacent to another sequence, not a
+ // mouse event. Checking all three slots prevents PASTE_END's ESC
+ // from being consumed when paste content ends in `\x1b[M`+0-2 chars.
+ //
+ // Known limitation: this counts JS string chars, but X10 is byte-
+ // oriented and stdin uses utf8 encoding (App.tsx). At col 162-191 ×
+ // row 96-159 the two coord bytes (0xC2-0xDF, 0x80-0xBF) form a valid
+ // UTF-8 2-byte sequence and collapse to one char — the length check
+ // fails and the event buffers until the next keypress absorbs it.
+ // Fixing this requires latin1 stdin; X10's 223-coord cap is exactly
+ // why SGR was invented, and no-SGR terminals at 162+ cols are rare.
+ if (
+ x10Mouse &&
+ code === 0x4d /* M */ &&
+ i - seqStart === 2 &&
+ (i + 1 >= data.length || data.charCodeAt(i + 1) >= 0x20) &&
+ (i + 2 >= data.length || data.charCodeAt(i + 2) >= 0x20) &&
+ (i + 3 >= data.length || data.charCodeAt(i + 3) >= 0x20)
+ ) {
+ if (i + 4 <= data.length) {
+ i += 4
+ emitSequence(data.slice(seqStart, i))
+ } else {
+ // Incomplete — exit loop; end-of-input buffers from seqStart.
+ // Re-entry re-tokenizes from ground via the invalid-CSI fallthrough.
+ i = data.length
+ }
+ break
+ }
+ if (isCSIFinal(code)) {
+ i++
+ emitSequence(data.slice(seqStart, i))
+ } else if (isCSIParam(code) || isCSIIntermediate(code)) {
+ i++
+ } else {
+ // Invalid CSI - abort, treat as text
+ result.state = 'ground'
+ textStart = seqStart
+ }
+ break
+
+ case 'ss3':
+ // SS3 sequences: ESC O followed by a single final byte
+ if (code >= 0x40 && code <= 0x7e) {
+ i++
+ emitSequence(data.slice(seqStart, i))
+ } else {
+ // Invalid - treat as text
+ result.state = 'ground'
+ textStart = seqStart
+ }
+ break
+
+ case 'osc':
+ if (code === C0.BEL) {
+ i++
+ emitSequence(data.slice(seqStart, i))
+ } else if (
+ code === C0.ESC &&
+ i + 1 < data.length &&
+ data.charCodeAt(i + 1) === ESC_TYPE.ST
+ ) {
+ i += 2
+ emitSequence(data.slice(seqStart, i))
+ } else {
+ i++
+ }
+ break
+
+ case 'dcs':
+ case 'apc':
+ if (code === C0.BEL) {
+ i++
+ emitSequence(data.slice(seqStart, i))
+ } else if (
+ code === C0.ESC &&
+ i + 1 < data.length &&
+ data.charCodeAt(i + 1) === ESC_TYPE.ST
+ ) {
+ i += 2
+ emitSequence(data.slice(seqStart, i))
+ } else {
+ i++
+ }
+ break
+ }
+ }
+
+ // Handle end of input
+ if (result.state === 'ground') {
+ flushText()
+ } else if (flush) {
+ // Force output incomplete sequence
+ const remaining = data.slice(seqStart)
+ if (remaining) tokens.push({ type: 'sequence', value: remaining })
+ result.state = 'ground'
+ } else {
+ // Buffer incomplete sequence for next call
+ result.buffer = data.slice(seqStart)
+ }
+
+ return { tokens, state: result }
+}
diff --git a/src/ink/termio/types.ts b/src/ink/termio/types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6c9bf737833153216962006c7e96b1ae64e0d133
--- /dev/null
+++ b/src/ink/termio/types.ts
@@ -0,0 +1,236 @@
+/**
+ * ANSI Parser - Semantic Types
+ *
+ * These types represent the semantic meaning of ANSI escape sequences,
+ * not their string representation. Inspired by ghostty's action-based design.
+ */
+
+// =============================================================================
+// Colors
+// =============================================================================
+
+/** Named colors from the 16-color palette */
+export type NamedColor =
+ | 'black'
+ | 'red'
+ | 'green'
+ | 'yellow'
+ | 'blue'
+ | 'magenta'
+ | 'cyan'
+ | 'white'
+ | 'brightBlack'
+ | 'brightRed'
+ | 'brightGreen'
+ | 'brightYellow'
+ | 'brightBlue'
+ | 'brightMagenta'
+ | 'brightCyan'
+ | 'brightWhite'
+
+/** Color specification - can be named, indexed (256), or RGB */
+export type Color =
+ | { type: 'named'; name: NamedColor }
+ | { type: 'indexed'; index: number } // 0-255
+ | { type: 'rgb'; r: number; g: number; b: number }
+ | { type: 'default' }
+
+// =============================================================================
+// Text Styles
+// =============================================================================
+
+/** Underline style variants */
+export type UnderlineStyle =
+ | 'none'
+ | 'single'
+ | 'double'
+ | 'curly'
+ | 'dotted'
+ | 'dashed'
+
+/** Text style attributes - represents current styling state */
+export type TextStyle = {
+ bold: boolean
+ dim: boolean
+ italic: boolean
+ underline: UnderlineStyle
+ blink: boolean
+ inverse: boolean
+ hidden: boolean
+ strikethrough: boolean
+ overline: boolean
+ fg: Color
+ bg: Color
+ underlineColor: Color
+}
+
+/** Create a default (reset) text style */
+export function defaultStyle(): TextStyle {
+ return {
+ bold: false,
+ dim: false,
+ italic: false,
+ underline: 'none',
+ blink: false,
+ inverse: false,
+ hidden: false,
+ strikethrough: false,
+ overline: false,
+ fg: { type: 'default' },
+ bg: { type: 'default' },
+ underlineColor: { type: 'default' },
+ }
+}
+
+/** Check if two styles are equal */
+export function stylesEqual(a: TextStyle, b: TextStyle): boolean {
+ return (
+ a.bold === b.bold &&
+ a.dim === b.dim &&
+ a.italic === b.italic &&
+ a.underline === b.underline &&
+ a.blink === b.blink &&
+ a.inverse === b.inverse &&
+ a.hidden === b.hidden &&
+ a.strikethrough === b.strikethrough &&
+ a.overline === b.overline &&
+ colorsEqual(a.fg, b.fg) &&
+ colorsEqual(a.bg, b.bg) &&
+ colorsEqual(a.underlineColor, b.underlineColor)
+ )
+}
+
+/** Check if two colors are equal */
+export function colorsEqual(a: Color, b: Color): boolean {
+ if (a.type !== b.type) return false
+ switch (a.type) {
+ case 'named':
+ return a.name === (b as typeof a).name
+ case 'indexed':
+ return a.index === (b as typeof a).index
+ case 'rgb':
+ return (
+ a.r === (b as typeof a).r &&
+ a.g === (b as typeof a).g &&
+ a.b === (b as typeof a).b
+ )
+ case 'default':
+ return true
+ }
+}
+
+// =============================================================================
+// Cursor Actions
+// =============================================================================
+
+export type CursorDirection = 'up' | 'down' | 'forward' | 'back'
+
+export type CursorAction =
+ | { type: 'move'; direction: CursorDirection; count: number }
+ | { type: 'position'; row: number; col: number }
+ | { type: 'column'; col: number }
+ | { type: 'row'; row: number }
+ | { type: 'save' }
+ | { type: 'restore' }
+ | { type: 'show' }
+ | { type: 'hide' }
+ | {
+ type: 'style'
+ style: 'block' | 'underline' | 'bar'
+ blinking: boolean
+ }
+ | { type: 'nextLine'; count: number }
+ | { type: 'prevLine'; count: number }
+
+// =============================================================================
+// Erase Actions
+// =============================================================================
+
+export type EraseAction =
+ | { type: 'display'; region: 'toEnd' | 'toStart' | 'all' | 'scrollback' }
+ | { type: 'line'; region: 'toEnd' | 'toStart' | 'all' }
+ | { type: 'chars'; count: number }
+
+// =============================================================================
+// Scroll Actions
+// =============================================================================
+
+export type ScrollAction =
+ | { type: 'up'; count: number }
+ | { type: 'down'; count: number }
+ | { type: 'setRegion'; top: number; bottom: number }
+
+// =============================================================================
+// Mode Actions
+// =============================================================================
+
+export type ModeAction =
+ | { type: 'alternateScreen'; enabled: boolean }
+ | { type: 'bracketedPaste'; enabled: boolean }
+ | { type: 'mouseTracking'; mode: 'off' | 'normal' | 'button' | 'any' }
+ | { type: 'focusEvents'; enabled: boolean }
+
+// =============================================================================
+// Link Actions (OSC 8)
+// =============================================================================
+
+export type LinkAction =
+ | { type: 'start'; url: string; params?: Record }
+ | { type: 'end' }
+
+// =============================================================================
+// Title Actions (OSC 0/1/2)
+// =============================================================================
+
+export type TitleAction =
+ | { type: 'windowTitle'; title: string }
+ | { type: 'iconName'; name: string }
+ | { type: 'both'; title: string }
+
+// =============================================================================
+// Tab Status Action (OSC 21337)
+// =============================================================================
+
+/**
+ * Per-tab chrome metadata. Tristate for each field:
+ * - property absent → not mentioned in sequence, no change
+ * - null → explicitly cleared (bare key or key= with empty value)
+ * - value → set to this
+ */
+export type TabStatusAction = {
+ indicator?: Color | null
+ status?: string | null
+ statusColor?: Color | null
+}
+
+// =============================================================================
+// Parsed Segments - The output of the parser
+// =============================================================================
+
+/** A segment of styled text */
+export type TextSegment = {
+ type: 'text'
+ text: string
+ style: TextStyle
+}
+
+/** A grapheme (visual character unit) with width info */
+export type Grapheme = {
+ value: string
+ width: 1 | 2 // Display width in columns
+}
+
+/** All possible parsed actions */
+export type Action =
+ | { type: 'text'; graphemes: Grapheme[]; style: TextStyle }
+ | { type: 'cursor'; action: CursorAction }
+ | { type: 'erase'; action: EraseAction }
+ | { type: 'scroll'; action: ScrollAction }
+ | { type: 'mode'; action: ModeAction }
+ | { type: 'link'; action: LinkAction }
+ | { type: 'title'; action: TitleAction }
+ | { type: 'tabStatus'; action: TabStatusAction }
+ | { type: 'sgr'; params: string } // Select Graphic Rendition (style change)
+ | { type: 'bell' }
+ | { type: 'reset' } // Full terminal reset (ESC c)
+ | { type: 'unknown'; sequence: string } // Unrecognized sequence
diff --git a/src/tools/AgentTool/built-in/verificationAgent.ts b/src/tools/AgentTool/built-in/verificationAgent.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3609f97e533c5f38c5eeb37a16e3375e55694877
--- /dev/null
+++ b/src/tools/AgentTool/built-in/verificationAgent.ts
@@ -0,0 +1,152 @@
+import { BASH_TOOL_NAME } from 'src/tools/BashTool/toolName.js'
+import { EXIT_PLAN_MODE_TOOL_NAME } from 'src/tools/ExitPlanModeTool/constants.js'
+import { FILE_EDIT_TOOL_NAME } from 'src/tools/FileEditTool/constants.js'
+import { FILE_WRITE_TOOL_NAME } from 'src/tools/FileWriteTool/prompt.js'
+import { NOTEBOOK_EDIT_TOOL_NAME } from 'src/tools/NotebookEditTool/constants.js'
+import { WEB_FETCH_TOOL_NAME } from 'src/tools/WebFetchTool/prompt.js'
+import { AGENT_TOOL_NAME } from '../constants.js'
+import type { BuiltInAgentDefinition } from '../loadAgentsDir.js'
+
+const VERIFICATION_SYSTEM_PROMPT = `You are a verification specialist. Your job is not to confirm the implementation works — it's to try to break it.
+
+You have two documented failure patterns. First, verification avoidance: when faced with a check, you find reasons not to run it — you read code, narrate what you would test, write "PASS," and move on. Second, being seduced by the first 80%: you see a polished UI or a passing test suite and feel inclined to pass it, not noticing half the buttons do nothing, the state vanishes on refresh, or the backend crashes on bad input. The first 80% is the easy part. Your entire value is in finding the last 20%. The caller may spot-check your commands by re-running them — if a PASS step has no command output, or output that doesn't match re-execution, your report gets rejected.
+
+=== CRITICAL: DO NOT MODIFY THE PROJECT ===
+You are STRICTLY PROHIBITED from:
+- Creating, modifying, or deleting any files IN THE PROJECT DIRECTORY
+- Installing dependencies or packages
+- Running git write operations (add, commit, push)
+
+You MAY write ephemeral test scripts to a temp directory (/tmp or $TMPDIR) via ${BASH_TOOL_NAME} redirection when inline commands aren't sufficient — e.g., a multi-step race harness or a Playwright test. Clean up after yourself.
+
+Check your ACTUAL available tools rather than assuming from this prompt. You may have browser automation (mcp__claude-in-chrome__*, mcp__playwright__*), ${WEB_FETCH_TOOL_NAME}, or other MCP tools depending on the session — do not skip capabilities you didn't think to check for.
+
+=== WHAT YOU RECEIVE ===
+You will receive: the original task description, files changed, approach taken, and optionally a plan file path.
+
+=== VERIFICATION STRATEGY ===
+Adapt your strategy based on what was changed:
+
+**Frontend changes**: Start dev server → check your tools for browser automation (mcp__claude-in-chrome__*, mcp__playwright__*) and USE them to navigate, screenshot, click, and read console — do NOT say "needs a real browser" without attempting → curl a sample of page subresources (image-optimizer URLs like /_next/image, same-origin API routes, static assets) since HTML can serve 200 while everything it references fails → run frontend tests
+**Backend/API changes**: Start server → curl/fetch endpoints → verify response shapes against expected values (not just status codes) → test error handling → check edge cases
+**CLI/script changes**: Run with representative inputs → verify stdout/stderr/exit codes → test edge inputs (empty, malformed, boundary) → verify --help / usage output is accurate
+**Infrastructure/config changes**: Validate syntax → dry-run where possible (terraform plan, kubectl apply --dry-run=server, docker build, nginx -t) → check env vars / secrets are actually referenced, not just defined
+**Library/package changes**: Build → full test suite → import the library from a fresh context and exercise the public API as a consumer would → verify exported types match README/docs examples
+**Bug fixes**: Reproduce the original bug → verify fix → run regression tests → check related functionality for side effects
+**Mobile (iOS/Android)**: Clean build → install on simulator/emulator → dump accessibility/UI tree (idb ui describe-all / uiautomator dump), find elements by label, tap by tree coords, re-dump to verify; screenshots secondary → kill and relaunch to test persistence → check crash logs (logcat / device console)
+**Data/ML pipeline**: Run with sample input → verify output shape/schema/types → test empty input, single row, NaN/null handling → check for silent data loss (row counts in vs out)
+**Database migrations**: Run migration up → verify schema matches intent → run migration down (reversibility) → test against existing data, not just empty DB
+**Refactoring (no behavior change)**: Existing test suite MUST pass unchanged → diff the public API surface (no new/removed exports) → spot-check observable behavior is identical (same inputs → same outputs)
+**Other change types**: The pattern is always the same — (a) figure out how to exercise this change directly (run/call/invoke/deploy it), (b) check outputs against expectations, (c) try to break it with inputs/conditions the implementer didn't test. The strategies above are worked examples for common cases.
+
+=== REQUIRED STEPS (universal baseline) ===
+1. Read the project's CLAUDE.md / README for build/test commands and conventions. Check package.json / Makefile / pyproject.toml for script names. If the implementer pointed you to a plan or spec file, read it — that's the success criteria.
+2. Run the build (if applicable). A broken build is an automatic FAIL.
+3. Run the project's test suite (if it has one). Failing tests are an automatic FAIL.
+4. Run linters/type-checkers if configured (eslint, tsc, mypy, etc.).
+5. Check for regressions in related code.
+
+Then apply the type-specific strategy above. Match rigor to stakes: a one-off script doesn't need race-condition probes; production payments code needs everything.
+
+Test suite results are context, not evidence. Run the suite, note pass/fail, then move on to your real verification. The implementer is an LLM too — its tests may be heavy on mocks, circular assertions, or happy-path coverage that proves nothing about whether the system actually works end-to-end.
+
+=== RECOGNIZE YOUR OWN RATIONALIZATIONS ===
+You will feel the urge to skip checks. These are the exact excuses you reach for — recognize them and do the opposite:
+- "The code looks correct based on my reading" — reading is not verification. Run it.
+- "The implementer's tests already pass" — the implementer is an LLM. Verify independently.
+- "This is probably fine" — probably is not verified. Run it.
+- "Let me start the server and check the code" — no. Start the server and hit the endpoint.
+- "I don't have a browser" — did you actually check for mcp__claude-in-chrome__* / mcp__playwright__*? If present, use them. If an MCP tool fails, troubleshoot (server running? selector right?). The fallback exists so you don't invent your own "can't do this" story.
+- "This would take too long" — not your call.
+If you catch yourself writing an explanation instead of a command, stop. Run the command.
+
+=== ADVERSARIAL PROBES (adapt to the change type) ===
+Functional tests confirm the happy path. Also try to break it:
+- **Concurrency** (servers/APIs): parallel requests to create-if-not-exists paths — duplicate sessions? lost writes?
+- **Boundary values**: 0, -1, empty string, very long strings, unicode, MAX_INT
+- **Idempotency**: same mutating request twice — duplicate created? error? correct no-op?
+- **Orphan operations**: delete/reference IDs that don't exist
+These are seeds, not a checklist — pick the ones that fit what you're verifying.
+
+=== BEFORE ISSUING PASS ===
+Your report must include at least one adversarial probe you ran (concurrency, boundary, idempotency, orphan op, or similar) and its result — even if the result was "handled correctly." If all your checks are "returns 200" or "test suite passes," you have confirmed the happy path, not verified correctness. Go back and try to break something.
+
+=== BEFORE ISSUING FAIL ===
+You found something that looks broken. Before reporting FAIL, check you haven't missed why it's actually fine:
+- **Already handled**: is there defensive code elsewhere (validation upstream, error recovery downstream) that prevents this?
+- **Intentional**: does CLAUDE.md / comments / commit message explain this as deliberate?
+- **Not actionable**: is this a real limitation but unfixable without breaking an external contract (stable API, protocol spec, backwards compat)? If so, note it as an observation, not a FAIL — a "bug" that can't be fixed isn't actionable.
+Don't use these as excuses to wave away real issues — but don't FAIL on intentional behavior either.
+
+=== OUTPUT FORMAT (REQUIRED) ===
+Every check MUST follow this structure. A check without a Command run block is not a PASS — it's a skip.
+
+\`\`\`
+### Check: [what you're verifying]
+**Command run:**
+ [exact command you executed]
+**Output observed:**
+ [actual terminal output — copy-paste, not paraphrased. Truncate if very long but keep the relevant part.]
+**Result: PASS** (or FAIL — with Expected vs Actual)
+\`\`\`
+
+Bad (rejected):
+\`\`\`
+### Check: POST /api/register validation
+**Result: PASS**
+Evidence: Reviewed the route handler in routes/auth.py. The logic correctly validates
+email format and password length before DB insert.
+\`\`\`
+(No command run. Reading code is not verification.)
+
+Good:
+\`\`\`
+### Check: POST /api/register rejects short password
+**Command run:**
+ curl -s -X POST localhost:8000/api/register -H 'Content-Type: application/json' \\
+ -d '{"email":"t@t.co","password":"short"}' | python3 -m json.tool
+**Output observed:**
+ {
+ "error": "password must be at least 8 characters"
+ }
+ (HTTP 400)
+**Expected vs Actual:** Expected 400 with password-length error. Got exactly that.
+**Result: PASS**
+\`\`\`
+
+End with exactly this line (parsed by caller):
+
+VERDICT: PASS
+or
+VERDICT: FAIL
+or
+VERDICT: PARTIAL
+
+PARTIAL is for environmental limitations only (no test framework, tool unavailable, server can't start) — not for "I'm unsure whether this is a bug." If you can run the check, you must decide PASS or FAIL.
+
+Use the literal string \`VERDICT: \` followed by exactly one of \`PASS\`, \`FAIL\`, \`PARTIAL\`. No markdown bold, no punctuation, no variation.
+- **FAIL**: include what failed, exact error output, reproduction steps.
+- **PARTIAL**: what was verified, what could not be and why (missing tool/env), what the implementer should know.`
+
+const VERIFICATION_WHEN_TO_USE =
+ 'Use this agent to verify that implementation work is correct before reporting completion. Invoke after non-trivial tasks (3+ file edits, backend/API changes, infrastructure changes). Pass the ORIGINAL user task description, list of files changed, and approach taken. The agent runs builds, tests, linters, and checks to produce a PASS/FAIL/PARTIAL verdict with evidence.'
+
+export const VERIFICATION_AGENT: BuiltInAgentDefinition = {
+ agentType: 'verification',
+ whenToUse: VERIFICATION_WHEN_TO_USE,
+ color: 'red',
+ background: true,
+ disallowedTools: [
+ AGENT_TOOL_NAME,
+ EXIT_PLAN_MODE_TOOL_NAME,
+ FILE_EDIT_TOOL_NAME,
+ FILE_WRITE_TOOL_NAME,
+ NOTEBOOK_EDIT_TOOL_NAME,
+ ],
+ source: 'built-in',
+ baseDir: 'built-in',
+ model: 'inherit',
+ getSystemPrompt: () => VERIFICATION_SYSTEM_PROMPT,
+ criticalSystemReminder_EXPERIMENTAL:
+ 'CRITICAL: This is a VERIFICATION-ONLY task. You CANNOT edit, write, or create files IN THE PROJECT DIRECTORY (tmp is allowed for ephemeral test scripts). You MUST end with VERDICT: PASS, VERDICT: FAIL, or VERDICT: PARTIAL.',
+}
diff --git a/src/tools/AgentTool/forkSubagent.ts b/src/tools/AgentTool/forkSubagent.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3f5c3542c78fb1ef486d2ea98f7bf75a6d1396ae
--- /dev/null
+++ b/src/tools/AgentTool/forkSubagent.ts
@@ -0,0 +1,210 @@
+import { feature } from 'bun:bundle'
+import type { BetaToolUseBlock } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
+import { randomUUID } from 'crypto'
+import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
+import {
+ FORK_BOILERPLATE_TAG,
+ FORK_DIRECTIVE_PREFIX,
+} from '../../constants/xml.js'
+import { isCoordinatorMode } from '../../coordinator/coordinatorMode.js'
+import type {
+ AssistantMessage,
+ Message as MessageType,
+} from '../../types/message.js'
+import { logForDebugging } from '../../utils/debug.js'
+import { createUserMessage } from '../../utils/messages.js'
+import type { BuiltInAgentDefinition } from './loadAgentsDir.js'
+
+/**
+ * Fork subagent feature gate.
+ *
+ * When enabled:
+ * - `subagent_type` becomes optional on the Agent tool schema
+ * - Omitting `subagent_type` triggers an implicit fork: the child inherits
+ * the parent's full conversation context and system prompt
+ * - All agent spawns run in the background (async) for a unified
+ * `` interaction model
+ * - `/fork ` slash command is available
+ *
+ * Mutually exclusive with coordinator mode — coordinator already owns the
+ * orchestration role and has its own delegation model.
+ */
+export function isForkSubagentEnabled(): boolean {
+ if (feature('FORK_SUBAGENT')) {
+ if (isCoordinatorMode()) return false
+ if (getIsNonInteractiveSession()) return false
+ return true
+ }
+ return false
+}
+
+/** Synthetic agent type name used for analytics when the fork path fires. */
+export const FORK_SUBAGENT_TYPE = 'fork'
+
+/**
+ * Synthetic agent definition for the fork path.
+ *
+ * Not registered in builtInAgents — used only when `!subagent_type` and the
+ * experiment is active. `tools: ['*']` with `useExactTools` means the fork
+ * child receives the parent's exact tool pool (for cache-identical API
+ * prefixes). `permissionMode: 'bubble'` surfaces permission prompts to the
+ * parent terminal. `model: 'inherit'` keeps the parent's model for context
+ * length parity.
+ *
+ * The getSystemPrompt here is unused: the fork path passes
+ * `override.systemPrompt` with the parent's already-rendered system prompt
+ * bytes, threaded via `toolUseContext.renderedSystemPrompt`. Reconstructing
+ * by re-calling getSystemPrompt() can diverge (GrowthBook cold→warm) and
+ * bust the prompt cache; threading the rendered bytes is byte-exact.
+ */
+export const FORK_AGENT = {
+ agentType: FORK_SUBAGENT_TYPE,
+ whenToUse:
+ 'Implicit fork — inherits full conversation context. Not selectable via subagent_type; triggered by omitting subagent_type when the fork experiment is active.',
+ tools: ['*'],
+ maxTurns: 200,
+ model: 'inherit',
+ permissionMode: 'bubble',
+ source: 'built-in',
+ baseDir: 'built-in',
+ getSystemPrompt: () => '',
+} satisfies BuiltInAgentDefinition
+
+/**
+ * Guard against recursive forking. Fork children keep the Agent tool in their
+ * tool pool for cache-identical tool definitions, so we reject fork attempts
+ * at call time by detecting the fork boilerplate tag in conversation history.
+ */
+export function isInForkChild(messages: MessageType[]): boolean {
+ return messages.some(m => {
+ if (m.type !== 'user') return false
+ const content = m.message.content
+ if (!Array.isArray(content)) return false
+ return content.some(
+ block =>
+ block.type === 'text' &&
+ block.text.includes(`<${FORK_BOILERPLATE_TAG}>`),
+ )
+ })
+}
+
+/** Placeholder text used for all tool_result blocks in the fork prefix.
+ * Must be identical across all fork children for prompt cache sharing. */
+const FORK_PLACEHOLDER_RESULT = 'Fork started — processing in background'
+
+/**
+ * Build the forked conversation messages for the child agent.
+ *
+ * For prompt cache sharing, all fork children must produce byte-identical
+ * API request prefixes. This function:
+ * 1. Keeps the full parent assistant message (all tool_use blocks, thinking, text)
+ * 2. Builds a single user message with tool_results for every tool_use block
+ * using an identical placeholder, then appends a per-child directive text block
+ *
+ * Result: [...history, assistant(all_tool_uses), user(placeholder_results..., directive)]
+ * Only the final text block differs per child, maximizing cache hits.
+ */
+export function buildForkedMessages(
+ directive: string,
+ assistantMessage: AssistantMessage,
+): MessageType[] {
+ // Clone the assistant message to avoid mutating the original, keeping all
+ // content blocks (thinking, text, and every tool_use)
+ const fullAssistantMessage: AssistantMessage = {
+ ...assistantMessage,
+ uuid: randomUUID(),
+ message: {
+ ...assistantMessage.message,
+ content: [...assistantMessage.message.content],
+ },
+ }
+
+ // Collect all tool_use blocks from the assistant message
+ const toolUseBlocks = assistantMessage.message.content.filter(
+ (block): block is BetaToolUseBlock => block.type === 'tool_use',
+ )
+
+ if (toolUseBlocks.length === 0) {
+ logForDebugging(
+ `No tool_use blocks found in assistant message for fork directive: ${directive.slice(0, 50)}...`,
+ { level: 'error' },
+ )
+ return [
+ createUserMessage({
+ content: [
+ { type: 'text' as const, text: buildChildMessage(directive) },
+ ],
+ }),
+ ]
+ }
+
+ // Build tool_result blocks for every tool_use, all with identical placeholder text
+ const toolResultBlocks = toolUseBlocks.map(block => ({
+ type: 'tool_result' as const,
+ tool_use_id: block.id,
+ content: [
+ {
+ type: 'text' as const,
+ text: FORK_PLACEHOLDER_RESULT,
+ },
+ ],
+ }))
+
+ // Build a single user message: all placeholder tool_results + the per-child directive
+ // TODO(smoosh): this text sibling creates a [tool_result, text] pattern on the wire
+ // (renders as \n\nHuman:). One-off per-child construction,
+ // not a repeated teacher, so low-priority. If we ever care, use smooshIntoToolResult
+ // from src/utils/messages.ts to fold the directive into the last tool_result.content.
+ const toolResultMessage = createUserMessage({
+ content: [
+ ...toolResultBlocks,
+ {
+ type: 'text' as const,
+ text: buildChildMessage(directive),
+ },
+ ],
+ })
+
+ return [fullAssistantMessage, toolResultMessage]
+}
+
+export function buildChildMessage(directive: string): string {
+ return `<${FORK_BOILERPLATE_TAG}>
+STOP. READ THIS FIRST.
+
+You are a forked worker process. You are NOT the main agent.
+
+RULES (non-negotiable):
+1. Your system prompt says "default to forking." IGNORE IT \u2014 that's for the parent. You ARE the fork. Do NOT spawn sub-agents; execute directly.
+2. Do NOT converse, ask questions, or suggest next steps
+3. Do NOT editorialize or add meta-commentary
+4. USE your tools directly: Bash, Read, Write, etc.
+5. If you modify files, commit your changes before reporting. Include the commit hash in your report.
+6. Do NOT emit text between tool calls. Use tools silently, then report once at the end.
+7. Stay strictly within your directive's scope. If you discover related systems outside your scope, mention them in one sentence at most — other workers cover those areas.
+8. Keep your report under 500 words unless the directive specifies otherwise. Be factual and concise.
+9. Your response MUST begin with "Scope:". No preamble, no thinking-out-loud.
+10. REPORT structured facts, then stop
+
+Output format (plain text labels, not markdown headers):
+ Scope:
+ Result:
+ Key files:
+ Files changed:
+ Issues:
+${FORK_BOILERPLATE_TAG}>
+
+${FORK_DIRECTIVE_PREFIX}${directive}`
+}
+
+/**
+ * Notice injected into fork children running in an isolated worktree.
+ * Tells the child to translate paths from the inherited context, re-read
+ * potentially stale files, and that its changes are isolated.
+ */
+export function buildWorktreeNotice(
+ parentCwd: string,
+ worktreeCwd: string,
+): string {
+ return `You've inherited the conversation context above from a parent agent working in ${parentCwd}. You are operating in an isolated git worktree at ${worktreeCwd} — same repository, same relative file structure, separate working copy. Paths in the inherited context refer to the parent's working directory; translate them to your worktree root. Re-read files before editing if the parent may have modified them since they appear in the context. Your changes stay in this worktree and will not affect the parent's files.`
+}