Copilot caught an important runtime parity gap on PR #27489: the fix
imported the npm `wrap-ansi` package directly, but Ink's `<Text
wrap="wrap">` uses a runtime-selecting shim
(`ui-tui/packages/hermes-ink/src/ink/wrapAnsi.ts`) that prefers
`Bun.wrapAnsi` when running under Bun and falls back to the npm package
elsewhere. So under Bun, Ink would render via `Bun.wrapAnsi` while
`cursorLayout` would compute breaks via the npm package — any
disagreement reintroduces the exact cursor-drift symptom the PR is
meant to eliminate.
Fix:
- Export `wrapAnsi` from `@hermes/ink` (`packages/hermes-ink/src/entry-exports.ts`
and `packages/hermes-ink/index.d.ts`) so the shim is the public surface.
- Switch `ui-tui/src/lib/inputMetrics.ts` from `import wrapAnsi from
'wrap-ansi'` to `import { wrapAnsi } from '@hermes/ink'`. Both
renderer (Ink) and cursor layout now traverse the same shim, so
they share the runtime-selected implementation by construction.
- Same swap in `textInputWrap.test.ts` and `cursorDriftRegression.test.ts`
— tests now assert parity through the shim, which means under Bun
they actually exercise Bun's implementation instead of asserting a
tautology against the npm package.
- Drop the direct `"wrap-ansi": "^9.0.0"` from `ui-tui/package.json`.
`@hermes/ink` (which IS a declared dep) pulls wrap-ansi in
transitively — that's not a phantom dep because the import path
goes through `@hermes/ink`'s public exports, not through a
hoisting accident.
Verified: 791/791 vitest tests pass. `@hermes/ink` rebuilt
(`dist/entry-exports.js` includes `wrapAnsi` export). TUI bundle
rebuilt clean.
Three small follow-ups from the Copilot review on #27489:
1. Declare `wrap-ansi` as a direct dependency of `ui-tui`. It was a
phantom dep that resolved via npm hoisting from `@hermes/ink`'s
transitive graph — fine on hoisted installs, but breaks under pnpm
or `npm install --no-install-strategy=hoisted` style isolated
installs. Now listed as `"wrap-ansi": "^9.0.0"` matching the
@hermes/ink version. Lockfile regenerated.
2. Implement the defensive resync the comment promised. Previously the
comment claimed the loop would "fall back to advancing by one to
stay in lockstep" on wrap-ansi desync, but the code unconditionally
advanced `originalIdx` with no actual check — so any future
wrap-ansi option change or styled-input caller could silently slide
`originalIdx` past the end of `value` and emit garbage line ranges.
Now actually compares `value[originalIdx] === ch`, re-syncs via
`indexOf` on mismatch, and bails out (returning whatever was built
so far) if the desync is unrecoverable. Production paths still hit
the equality fast-path on every char.
3. Drop the `visualLines` wrapper. It was a one-line indirection over
`visualLinesFromWrappedOutput`. Renamed the implementation to
`visualLines` and removed the wrapper — same name, no extra layer.
No behavior change beyond the defensive realign; all 791 vitest tests
still pass.
Replace the tsc + babel pipeline with a single esbuild invocation that
produces a self-contained dist/entry.js. The nix TUI derivation no
longer copies node_modules — only dist/ + package.json ship, shrinking
the output from hundreds of MB to ~2.9 MB.
- ui-tui/scripts/build.mjs: new esbuild bundler. Aliases @hermes/ink
to source (esbuild's __esm helper doesn't await nested async init,
which breaks lazy-assigned exports like 'render' when re-exporting
through a prebuilt submodule). Stubs react-devtools-core (dev-only).
Injects a createRequire shim for transitive CJS deps. Strips the
shebang from src/entry.tsx because Nix patchShebangs mangles
'/usr/bin/env -S node --max-old-space-size=8192 --expose-gc' — it
drops the 'node' token. The Python launcher always invokes node
explicitly, so the shebang is redundant.
- nix/tui.nix: installPhase no longer copies node_modules or the
@hermes/ink packages dir.
- nix/checks.nix: drop the 'node_modules present' assertion.
- hermes_cli/main.py: _tui_need_npm_install short-circuits when
dist/entry.js exists and no package-lock.json is present. That is
the prebuilt-bundle layout (nix / packaged release) and there is
nothing to install. Without this, the launcher tried to npm install
in a non-existent site-packages/ui-tui path.
- Switch tsconfig to nodenext module resolution for Node 22 (used by
installer script)
- Add shebang to entry.tsx, preserved into index.js
- Add HERMES_ROOT env var fallback for repo root resolution