Wraps + heights are column-dependent, so a width change must remeasure
every row and the renderer must repaint the full viewport.
- Key virtualRows on cols so React remounts wrapped rows on resize.
- Snap back to bottom after sticky-mode resize once React rerenders.
- Reserve a scrollbar + gap column in transcriptBodyWidth (non-termux).
- Full repaint on any viewport height change (was: shrink-only).
- ScrollBox scrollHeight uses deepest child bottom so sticky-bottom
math can reach the real final rendered row after reflow.
- DECSTBM fast-path now requires full container rect match.
Salvaged from #28942 (adybag14-cyber). Only the Ink TUI half is taken
here — the bundled "termux compatibility note" added to skills_tool.py
in the original PR did not address the actual user-reported bug
(skill_matches_platform() filtering Linux skills out on Termux) and
also regressed the EXCLUDED_SKILL_DIRS set used to prune nested
.venv/site-packages skills.
Changes:
- ui-tui/src/lib/prompt.ts: single-cell ASCII '>' marker in Termux mode
to avoid ambiguous-width glyph artifacts while typing.
- ui-tui/src/components/appLayout.tsx: suppress profile prefix on
narrow Termux panes (>=90 cols still shows it).
- ui-tui/src/lib/inputMetrics.ts + components/messageLine.tsx +
lib/virtualHeights.ts: termux-aware transcript body width — drop
the desktop 20-col floor on narrow mobile layouts, align virtual
heights with actual rendered width.
- ui-tui/src/components/textInput.tsx: disable fast-echo bypass by
default in Termux to avoid ghosting at soft-wrap boundaries.
HERMES_TUI_TERMUX_FAST_ECHO=1 opts back in.
Tests: ui-tui/src/__tests__/{prompt,termuxComposerLayout,textInputFastEcho}.test.ts
(12 PR-added tests pass; 3 pre-existing wrapAnsi-bundling failures on
main are unrelated.)
The real skill-listing fix on Termux ('android' platform matching
Linux skills) ships as a follow-up commit on this branch.
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.
The composer's `cursorLayout` (in `ui-tui/src/lib/inputMetrics.ts`) used a
hand-rolled word-wrap algorithm to decide where `useDeclaredCursor`
should park the hardware cursor. But Ink's `<Text wrap="wrap">` renders
the same text via `wrap-ansi`. The two algorithms disagreed on common
real-world inputs — `"branch investigate"` at cols=20, `"hello world"`
at cols=8, exact-fill strings like `"abcdefgh"` at cols=8 — so the
hardware cursor parked several cells past where Ink actually rendered
the last character. Users saw a multi-cell blank gap between their
last-typed letter and the cursor block, especially on narrow terminals
(the Cursor IDE built-in terminal was the worst offender).
Three previous PRs (#26717, #25860, #22197) chased fast-echo
displayCursor/cursorDeclaration drift and in-band-vs-native cursor
heuristics. None of them touched the underlying wrap-algorithm
mismatch, which is why the bug kept resurfacing.
Fix: source cursorLayout's line breaks from wrap-ansi directly. Walk
its emitted string char-by-char, tracking original-string offsets, push
a VisualLine at each '\n'. Also drop the buggy `column >= w` overflow
rule in cursorLayout — that's what pushed exact-fill text onto a
phantom next row.
canFastBackspaceShape now detects the wrap boundary in BOTH coordinate
conventions (column === 0 OR column >= columns), since exact-fill now
reports as (0, columns) instead of the previous (1, 0). The physical
state is identical — the terminal auto-wraps at column N either way —
but the layout function reports the position more honestly.
Tests:
- ui-tui/src/__tests__/textInputWrap.test.ts: 3 tests that pinned the
BUGGY behavior were updated to assert wrap-ansi parity (the real
invariant). Added a typing-prefix invariant: cursorLayout must agree
with wrap-ansi at every character of a long input.
- ui-tui/src/__tests__/cursorDriftRegression.test.ts: new file. Walks
the user-reported bug message char-by-char at 7 widths and asserts
agreement with wrap-ansi at every prefix.
Verification:
- 791/791 vitest tests pass.
- 84/84 tui-gateway pytest tests pass via scripts/run_tests.sh.
- PTY repro (typing into a real `hermes --tui` PTY at cols=50/55/60):
cursor lands exactly 1 cell past the last typed char in every case
the bug previously drifted.
* fix(tui): word-wrap composer input
Wrap composer input at word boundaries and anchor the good-vibes heart to the full composer row.
* test(tui): cover composer word wrap edge
Add regression coverage for moving the next word instead of splitting it at the composer edge.