Compare commits

...

14 Commits

Author SHA1 Message Date
ethernet
6832b910c2 fix(tui): account for paddingLeft when mapping click col to source col
Reported by ethie:

    ```mermaid
    graph LR
        user[ethie] -->|asks| packet[packet >w<]
    ```

Double-click "ethie" inside the fence → copied "hie]". Selection
shifted right by 2 at the start AND extended past `]` at the end.

Root cause: code fences (and tables / lists / blockquotes) render their
content inside a `<Box paddingLeft={2}>` nested inside the
`<CopySource>` Box. The hit-test walked up the DOM looking for a
copyRangeId and reported visualLine/col relative to THAT outer Box,
which has rect.x=0. The visual col (which includes the +2 padding) was
passed through to `simpleOffsetFor` unchanged — but simpleOffsetFor
treats col as a source col, so every char shifted +2 in source space.

Also: code fences register no per-fragment source data, so the focus
point falls through to the no-fragment path in toCopyText where the
cell-INCLUSIVE col was treated as a byte-EXCLUSIVE slice end. Last char
got dropped.

Fix (two parts):

1. copyPointHitTest tracks the deepest non-rangeId rect's X during the
   walk-up and reports col relative to that (when present). visualLine
   stays relative to the rangeId Box's rect.y — that's the coordinate
   system the registered rowStarts / visualLineCount were measured in
   (rows counted from block start, not from any sub-text element).
   Inline content w/o an indented wrapper sees no change.

2. buildCopyTextFromDom now bumps the focus point's col by +1 when the
   hit-test returned no sourceOffset (the fallback path). This handles
   the cell-INCLUSIVE → byte-EXCLUSIVE conversion for blocks that don't
   carry per-fragment source data. The fragment path already does this
   bump internally via the endpoint='end' arg.

Tests:
- packages/hermes-ink/src/ink/copyPointHitTest.test.ts: new test asserts
  visualLine/col reporting respects an inner padded Box's rect.x while
  preserving the rangeId Box's rect.y for visualLine.
- ui-tui/src/lib/copySource/__tests__/codeFencePadding.test.ts: end-to-end
  regression test reproducing ethie's exact mermaid example and
  asserting the copied string is 'ethie' (not 'hie]').
2026-05-19 23:26:00 -04:00
ethernet
30dfbd9b09 fix(tui): cell-end-inclusive byte offset in copyPointAt for selection focus
Word-select or drag-select on the last character of a word inside a
callout (or anywhere — the bug was general) was copying one char short:
`> [!WARNING]\n> things might break if u skip this`, double-click
'might' → copies 'migh'.

Root cause: anchor/focus selection bounds are stored as CELL-INCLUSIVE
coords (anchor/focus point AT the cell containing the character, not
past it — verified in isCellSelected line 852: `col > end.col`).
copyPointAt for verbatim fragments returned `f.start + (localCol -
f.colStart)` — the START byte of the clicked cell — for both endpoints.
toCopyText then did `source.slice(from, to)` which is to-EXCLUSIVE,
dropping one char off the right edge of every selection.

Fix: copyPointAt now takes an `endpoint: 'start' | 'end'` arg
(default 'start' preserves old behavior). When 'end', the verbatim
cell→byte math bumps by 1, clamped to fragment end so no over-read.
buildCopyTextFromDom passes 'start' for anchor and 'end' for focus.
Non-verbatim path is unchanged — its existing half-cell heuristic
already does the right thing.

Tests:
- packages/hermes-ink/src/ink/copyPointHitTest.test.ts: new test
  asserts endpoint='end' bumps verbatim sourceOffset by 1, default
  arg behaves like 'start', and end-of-fragment clamps to f.end.
- ui-tui/src/lib/copySource/__tests__/wordSelectionEndpoint.test.ts:
  regression test (started as a probe documenting the bug, then
  flipped to assert the fix). Three cases: full word copy yields
  'might', clamp-at-fragment-end, and anchor-side unchanged.
2026-05-19 21:06:34 -04:00
ethernet
8b6ab220a6 Merge branch 'main' into hermes/hermes-72b329fd
Resolve TUI copy/selection vs ANSI sanitization + cols-aware markdown conflicts:
- messageLine.tsx: combine wrapCopySource (HEAD) + sanitizeAnsiForRender (main)
- streamingMarkdown.tsx: pass both cols (main) and msgId (HEAD) through to <Md>
- markdown.tsx: merge MdBlock + msgId/blockIndexBase (HEAD) with cols cache key
  (main); thread cols through MdImpl and parseToBlocks; adopt renderResolvedLink
  helper from main while keeping the wrap(node, srcStart, srcEnd, verbatim) copy
  source signature from HEAD.
- markdown.test.ts: keep both new test additions (math content + dunder
  identifiers; copy-source fragments + link labels).
2026-05-19 15:24:59 -04:00
ethernet
079fc8727d fix(tui): same-row fallback in copyPointAt for triple-click selections
Bug: triple-clicking a line to select it would set selection focus
to (col=screen-width-1, row), and ctrl-c would copy NOTHING.

Root cause: selectLineAt in hermes-ink/src/ink/selection.ts uses
screen.width-1 for the focus column. When the message body box is
narrower than the screen (gutter on left, padding on right — the
common case in messageLine.tsx where Box width is bounded by
transcriptBodyWidth), col=119 lands OUTSIDE the CopySource box's
x-extent. hitDeepest returns null → copyPointAt falls through to
findAdjacentRanges → finds no STRICTLY above/below ranges (the
only range is on the SAME row) → returns gap with both adjacents
null → resolvePoint returns null → toCopyText emits empty.

Drag-select works because the focus lands ON the text content
(inside the box's x-extent), so the in-range path resolves
normally.

Fix: when hitDeepest finds no tagged ancestor at (col, row), check
for a tagged box whose y-extent covers row before falling through
to findAdjacentRanges. If found, return in-range with col clamped
into the box's x-extent. Pick the SMALLEST (innermost) box when
multiple nest — that's the user's intent (the specific block they
clicked, not its enclosing container).

Tests: 2 new tests in copyPointHitTest.test.ts covering the bug
repro (triple-click anchor in gutter, focus past content) and the
innermost-wins behavior for nested ranges. Both fail when the fix
is reverted.

Full suite: 750 passing / 1 skipped (up from 748).
2026-05-19 14:37:06 -04:00
ethernet
cf53f4c88f test(tui): add fence-selection regression tests for inner code lines
Two new integration tests pinning the expected behavior when selecting
INSIDE a python fence:

1. Single docstring-line selection (the user's reported scenario):
   - Source: 7-line fence with python code including a triple-quote docstring.
   - Selection: visual row 2 col 0 → visual row 2 col 27 (the docstring line).
   - Expected: copies EXACTLY the docstring line content, no surrounding
     code, no opener/closer. The fence-stripping rule in toCopyText
     applies because both endpoints land in innerSource bounds.

2. Wrap-continuation past visualLineCount (defensive scenario):
   - Source: 3-line minimal fence with the docstring as the only content.
   - Selection: visualLine=1 (docstring start) → visualLine=99 (way past
     visualLineCount=3, simulating a wrap-continuation click that lands
     beyond what the block tracks).
   - Expected: result doesn't include the closer line, does include the
     docstring content. Validates that pointToOffset's defensive
     last-row clamp keeps the selection bounded to the actual code
     even when the hit-test reports past-end visual rows.

Both pass with current behavior — these are pinning tests for the
fence + wrap-continuation interaction, not bug-fixes themselves.
2026-05-18 16:12:44 -04:00
ethernet
797c84a5c5 fix(tui): defensive clamp in pointToOffset for wrap-continuation rows
When a click lands on a wrap-continuation row of a block whose
CopySource was registered with `visualLineCount = source-line-count`
(rather than the actual rendered wrap-row count), the host emits an
in-range SelectionPoint with `visualLine` past the tracked count.
toCopyText's pointToOffset clamped to `outerSource.length` in that
case, which copied the WHOLE source line (or worse, everything past
the click) instead of the prefix the user dragged across.

The intended path for wrap-continuation rows is the per-row fragment
hit (sourceOffset set on the SelectionPoint, bypassing pointToOffset
entirely). But fragments can be unset for various reasons — node not
yet rendered, stale cache, renderer skipping the fragment-emission
branch — and the fallback should degrade gracefully, not detonate.

New behavior: when visualLine >= visualLineCount, defer to
`getOffset(visualLineCount - 1, col)`. The offset map's per-row
clamping bounds the result at the LAST tracked row's source-end,
which is the right semantic for 'click landed on a wrap of the last
tracked source line' — bounded by the line, not by the whole block.

Tests:
  - toCopyText.test.ts: 2 new regression tests (single-source-line
    wrap-continuation and multi-source-line range with click past
    visualLineCount). 2/2 fail when the fix is reverted, prove the
    clamp-to-last-row math is right.
  - copyPointHitTest.test.ts: 3 new tests pinning the upstream
    happy-path (per-row fragments give byte-exact sourceOffset on
    continuation rows) and documenting the no-fragments fallback
    that this pointToOffset fix now handles correctly.
  - render-node-to-output.test.ts: 1 new test for the wrap-trim
    inter-row whitespace eating in computeFragmentsForWrappedText
    (covers the upstream guarantee that fragments map row 1 of a
    wrap-trim'd paragraph past the eaten space).

Full suite: 746 passing / 1 skipped (up from 740).
2026-05-16 09:56:19 -04:00
ethernet
637ec1f237 fix(tui): correct findAdjacentRanges afterRangeId/beforeRangeId semantics
When a click landed in a blank gap between two ranges, findAdjacentRanges
was assigning the range ABOVE the click to `beforeRangeId` and the
range BELOW to `afterRangeId` — the opposite of the convention used
elsewhere in the copy-source pipeline:

  - afterRangeId  = range the gap comes AFTER (above the gap)
  - beforeRangeId = range the gap comes BEFORE (below the gap)

The integration tests in lib/copySource/__tests__ document this
convention via the comments on synthetic gap points ("afterRangeId=id1
means the gap is AFTER range id1"), and reducePoint/resolvePoint in
toCopyText.ts depend on it.

Symptom: selecting from the blank row above a block to the blank row
below it copied the WHOLE MESSAGE instead of just the bracketed range,
because reducePoint resolved both gap endpoints to the wrong sides and
the slice window grew unbounded.

This was untested — findAdjacentRanges had no coverage at all. Adds 5
unit tests using a synthetic DOM (createNode + manual nodeCache.set):
  - mid-stack gap → afterRangeId=above, beforeRangeId=below
  - click below all ranges → only afterRangeId
  - click above all ranges → only beforeRangeId
  - tie-break by smaller rangeId (document-order proxy)
  - click inside a tagged range → in-range, not gap

Verified the tests catch the bug: 4/5 fail when the fix is reverted.
2026-05-16 09:17:32 -04:00
ethernet
f42f211a84 fix(tui): wrap-aware per-row source fragments for byte-exact copy across wrap boundaries
Previously, computeSegmentFragments emitted one CachedFragment per
copySourceFragment-tagged segment, all on row 0. When a paragraph
wrapped across multiple visual rows, hits on continuation rows fell
through to block-level offset mapping — selecting across a wrap
boundary degraded from byte-exact source to width-math-derived
guesses ("degraded but never source-leaks").

Rewrite as computeFragmentsForWrappedText: walks the wrapped output
line-by-line, tracking charIndex into originalPlain to emit one
fragment per (segment-run × wrapped-row) intersection. For verbatim
segments the per-row start/end is the source slice (so cell→byte
stays linear via 'start + (col - colStart)'); for formatted segments
each row carries whole-segment bounds (snap rule in copyPointAt
handles within-row clicks regardless of row).

All three render branches (single-seg wrap / multi-seg wrap / no-wrap)
now build a charToSegment map and feed the helper. Also fixes a
pre-existing TS narrowing quirk in findRangeDom's recursion (surfaces
when re-exported through index.d.ts) and exposes copyPointAt /
findRangeDom / getInkForStdout in the package's d.ts shim to match
what entry-exports.ts already exposes at runtime.

Tests: 6 new colocated unit tests on the pure helper cover verbatim
1:1 mapping, verbatim wrap split, formatted whole-seg-per-row,
mixed verbatim+formatted, and hard-newline charIndex bookkeeping.
Full suite: 735 passing / 1 skipped (up from 730).
2026-05-16 08:57:57 -04:00
Packet
46e2ff57f7 fix(tui): byte-exact copy for inline-formatted markdown + thinking content
Two follow-up bugs from the initial transcript-virtual selection rewrite:

1. Inline math / bold / links etc copied wrong: selecting 'E = mc^2 or'
   from '$E = mc^2$ or' dropped the 'or' because the block-level
   simple-offset map assumed rendered cells == source bytes. For
   inline-formatted content (math $x$, bold **x**, links [text](url),
   code `x`, etc.) that assumption is wrong.

2. Reasoning text in expanded ToolTrail copied as empty (no CopySource
   wrapper) — clicking ctrl-c gave nothing.

Fix: rather than recomputing visual->source via width math at the host
level (the broken v1 approach), let the RENDERER attach per-segment
source-byte ranges to the rendered nodes. MdInline already knows
exactly which source bytes each <Text> came from — we just thread
that info through as style.copySourceFragment.

How it flows:
- styles.ts: add copySourceFragment style with start/end/verbatim fields
- Text.tsx: accept copySourceFragment as a prop and forward to ink-text
- squash-text-nodes.ts: propagate copySourceFragment through segment list
  (child fragments override parent — bold containing math => inner math
  fragment wins)
- render-node-to-output.ts: compute per-row CachedFragment[] for any
  ink-text whose segments carry copySourceFragment, attach to nodeCache
- node-cache.ts: CachedLayout gains optional fragments[] field
- copyPointHitTest.ts: when walking up DOM looking for copyRangeId, also
  check each rect's fragments[] for one covering (col, row); when found,
  return precomputed sourceOffset on the SelectionPoint
- toCopyText.ts: resolvePoint uses point.sourceOffset directly when set,
  bypassing getOffset

MdInline now wraps each emitted segment in <Text copySourceFragment={...}>
recording the exact source byte range (with verbatim flag for plain text
+ code spans, false for tokens where rendered cells != source bytes).
Recursive MdInline calls thread sourceOffset down so nested formatting
keeps correct byte positions against the outer block source.

Block branches that go through MdInline (paragraph, heading, bullet,
numbered, setext heading, math fallback) pass the appropriate
sourceOffset of inner text within the block's outerSource so headings
('# Title' source vs 'Title' rendered) map correctly.

Thinking/reasoning fix: ToolTrail and Thinking accept an msgId prop;
when set, Thinking wraps its rendered content in CopySource with
blockIndex=-1 (sorts before reply blocks). outerSource is the full
reasoning text — copy returns full text even when the on-screen preview
is truncated.

Tests: +3 new tests in markdown.test.ts exercising the fragment path
end-to-end (paragraph with inline math, byte-exact partial copy across
formatted spans). All 730 tests pass.

Trade-offs accepted for v2:
- Soft-wrap of an inline-formatted line: fragments only emit on the
  FIRST visual row of a wrapped paragraph. Clicks on wrap-continuation
  rows fall through to block-level mapping. Adequate — full-paragraph
  selections still byte-exact; partial selections that don't cross a
  wrap boundary are byte-exact; the only degraded case is partial
  selections through a wrap boundary on formatted content.
- Code spans treated as one non-verbatim fragment (snap to start/end on
  partial click). Partial code-span selections rare.
- Table cells, quotes, footnotes, definition lists: not yet plumbed
  with sourceOffset (still simple-offset). Bullet/numbered/heading/
  setext/paragraph/math fallback ARE plumbed (the common cases).
2026-05-15 18:31:19 -04:00
Packet
1306f234f4 feat(tui): selection/copy via transcript-virtual coordinates
Replaces the cell-tagging copy-source pipeline with a transcript-virtual
one. Selection endpoints are anchored to (msgId, blockIndex, visualLine,
col) instead of screen cells; copy text is sliced directly from the Msg[]
source ranges, never from rendered cells.

Why: the cell grid is not the right level of abstraction for source
round-trip. By the time content reaches the cells, the mapping back to
source has been destroyed by markdown rendering, soft-wrap, truncation,
color attributes, and viewport culling. v1-v3 tried to recover it
post-hoc with shadowing rules and scroll-off accumulators and kept
hitting edge cases (drag-scroll double-emit, partial fence selection,
nested region shadowing).

How:

- New module ui-tui/src/lib/copySource/: pure functions over a typed
  registry. registry.ts (msgId,blockIndex)->RangeId. toCopyText.ts
  slices outerSource between two SelectionPoints; fence-strip when both
  endpoints land inside the inner body. offsetMaps.ts builds (visualRow,
  col)->byteOffset functions for both rendered-text-equals-source ranges
  and inline-formatted ranges.
- New Ink helper: copyPointAt(root, col, row) walks the DOM for boxes
  tagged with style.copyRangeId and returns a raw SelectionPoint with
  gap adjacency baked in.
- Wiring: <CopySource> wrapper in messageLine for non-markdown content
  (blockIndex=0), per-block in markdown.tsx (blockIndex>=1), suffix-
  offset in streamingMarkdown so the two Md subtrees don't collide.
- useMainApp installs setCopyTextFn on the Ink instance once at mount
  via a transcriptRef pattern so the override always sees the live
  message list. History-cap eviction calls evictMessage(msgId) for
  every msgId that vanishes between renders.

Test matrix: 53 copy-source tests (offsetMaps + toCopyText units +
integration scenarios covering whole-msg, multi-msg, partial-block,
fence inner/outer, eviction, re-registration, gaps, reversed
selections, mid-paragraph-through-mid-next-paragraph). Full suite green
(727 tests).

Removed:
- packages/hermes-ink/src/ink/screen.ts: CopySourcePool, screen.copySources
  Int32Array, markCopySourceRegion, blit/shift/migrate copysource paths.
- packages/hermes-ink/src/ink/output.ts: CopySourceOperation,
  copySource() method, internCopySource().
- packages/hermes-ink/src/ink/selection.ts: ~250 lines of
  computeFullyCoveredCopySources, parent-child shadowing, segment-by-id
  emit loop, drag-scroll bail-out.
- packages/hermes-ink/src/ink/selection-copy-source.test.ts (440 lines),
  subsumed by the new integration tests.

Net: -699 lines on touched files plus +1500 lines of new module and
tests (1100 of those are tests + types). Cell grid no longer knows
about copy sources; future region types are trivial to add via a new
<CopySource> wrapper.
2026-05-15 13:17:36 -04:00
ethernet
bda9a22558 fix(tui): suppress copy-source substitution during drag-to-scroll
When the user drag-selects past the viewport edge, captureScrolledRows
caches each row of off-screen content as RENDERED text via
extractRowText (cells, no copySources lookup). The on-screen path then
emits the FULL source string for any region whose remaining on-screen
cells are fully inside the selection — even though the source string
ALSO covers the scrolled-off rows that were just emitted as rendered
text. Result: every region gets the duplication that was reported in
session 20260513_104920_63829e — 'horizontal rule' six times, list
items repeated, code fences interleaved with rendered partials.

Cells that scrolled out are gone from the screen buffer, so we can't
detect 'this region extends past the viewport' from copySources alone.
Conservative fix: skip the source-substitution path entirely whenever
either scrolledOff buffer is non-empty, falling back to the original
cell-extraction behavior. Drag-scroll selections lose the markdown
round-trip in exchange for honest, non-duplicating output. Static
(non-dragged) selections still get the full source treatment.

Tests:
  - falls back to rendered cells when scrolledOffAbove is non-empty
  - sanity: empty scrolledOff buffers still trigger source substitution

15 selection-copy-source tests pass, 688 ui-tui tests total, 0
regressions, no lint or typecheck errors.
2026-05-13 11:03:14 -04:00
ethernet
e922110ac3 feat(tui): per-markdown-block copySource so partial selections round-trip
v1 wrapped each whole message in <Box copySource={msg.text}>. Selecting
a whole assistant message returned the raw markdown — but selecting
just one paragraph, heading, or code fence inside a longer message
still gave back rendered cells (asterisks/headings/fences stripped).

v2 instruments <Md> itself: every top-level block — paragraph, heading,
code fence, list item, table, quote, math block, footnote, etc. —
gets wrapped in its own <Box copySource={rawBlockSource}>. The
parser already advances through lines block by block; we capture
each block's [start, end) line range and group consecutive nodes
emitted by the same iteration into one wrapper after the parse.

Mechanism in markdown.tsx:

  - Outer 'while (i < lines.length)' body wrapped in a labeled
    'blockIter:' block; every existing 'continue' becomes
    'break blockIter' so each branch falls through to a wrap step
    that records the block's source range alongside whatever
    nodes it pushed.
  - Post-loop pass groups consecutive nodes sharing the same range
    object and emits one <Box copySource={blockSource}> per group.
    Gap nodes (no range) stay flat so empty visual rows don't
    inherit a neighboring block's source.
  - <StreamingMd> uses <Md> internally for both the stable prefix
    and the in-flight suffix → per-block copySource works for
    streaming responses too with no further changes.

Nested-region semantics in getSelectedText:

  - msg-level <Box copySource={msg.text}> still wraps the whole
    message body. When a selection covers the whole message, BOTH
    the outer (msg) and inners (every block) end up fully covered.
    Without de-dup we'd emit msg.text AND every block's source —
    duplicating content.
  - computeFullyCoveredCopySources now returns { coveredAll, emit }:
    coveredAll is the un-filtered set, emit drops any region whose
    bounding rect is strictly contained inside another fully-
    covered region. Parent wins; child's text is already inside
    parent.source.
  - The row-segment loop checks coveredAll to decide 'this segment's
    cells are already accounted for by an outer emission, skip',
    instead of falling through to extractRowText (which would
    duplicate the rendered text after the parent's source string).

Tests (4 new, total 13):

  - emit only outer source when both outer and inner are fully covered
  - emit only inner source when selection covers just one block
  - emit multiple inner blocks when outer is partial but inners full
  - keep single fully-covered region (no shadowing partner)

Plus the 9 existing tests, the 5 osc tests, and all 65 ui-tui suites
— 686 passed, 1 skipped, 0 lint or typecheck errors.
2026-05-13 10:42:18 -04:00
ethernet
734090a905 feat(tui): copy raw markdown source on selection, not rendered cells
Copying an assistant message used to give the rendered version with
formatting stripped — `**bold**` came out as `bold`, `# heading` as
`heading`, `[text](url)` as `text` (URL gone), code fences gone, math
unicode-substituted away from LaTeX. The screen-cell copy path
(getSelectedText reading cell.char) had no way to recover the source
because the markdown renderer parses asterisks/headings/etc. into
React style nodes long before chars hit the screen.

Adds a per-cell copy-source mapping that survives all the way through
to the clipboard.

## Mechanics

New `<Box copySource="raw markdown source">` style prop on
hermes-ink:

  - Each cell on screen carries a `copySources: Int32Array` index
    into a shared `CopySourcePool` (analogous to the existing
    hyperlinkPool: tiny pool, monotonic ID interning, migrates with
    pools between turns).
  - render-node-to-output emits a copySource op for any Box with the
    style; `output.get()` applies it AFTER blits/writes so it wins
    regardless of what's painted into the region — same architecture
    as noSelect.
  - blitRegion/shiftRows copy the copySources array alongside cells,
    so blit fast-paths preserve the mapping (no re-emission required
    when a Box stays clean across frames).
  - resetScreen clears it each frame.

`getSelectedText` consults copySources before falling back to cell
text:

  1. Pre-scan the selection rect to find every copy-source ID it
     touches AND that ID's full bounding rect on the screen.
  2. An ID is "fully covered" iff every cell carrying it lives
     inside the selection. Substitute source for fully-covered IDs;
     fall back to rendered cells otherwise.
  3. Walk each row segmenting on ID transitions: emit fully-covered
     regions ONCE at first appearance (multi-row regions still emit
     a single source string), unmarked spans extract from cells as
     before.

A partial selection within a single region still falls back to
rendered cells — there's no safe sub-mapping from rendered chars
back into arbitrary markdown source. v2 could add per-row spans
into the source string for finer granularity; v1 punts.

## Wiring

`<MessageLine>` wraps each message's body in
`<Box copySource={msg.text}>`. Selecting a whole assistant message
gives back the raw markdown source. Cross-message selection
concatenates each fully-covered message's source. Partial selection
within one message falls back to rendered cells (current behaviour).

Per-block source mapping inside `<Md>` is left for v2 — once we
nail down nested-region semantics (parent vs child copySource on
overlapping cells) that case'll fall out cleanly.

## Tests

9 new vitest cases for getSelectedText copy-source override:

  - falls back to rendered text with no copy source
  - substitutes when selection fully covers the region (exact bounds)
  - substitutes when selection rect is wider than the region
  - falls back to rendered when only part of the region is selected
  - concatenates multiple regions on different rows
  - emits a multi-row region's source ONCE (not once per row)
  - mixes copy-source regions and unmarked cells in one selection
  - handles two regions side-by-side on a single row
  - skips substitution when a region extends outside the selection

Plus the existing 5 selection tests, 5 osc tests, all 65 ui-tui
suites — 682 passed total, 0 regressions, 0 lint or typecheck
errors.
2026-05-13 10:18:07 -04:00
ethernet
98aa6da414 fix(tui): clipboard copy on linux/wayland + alt-screen stderr debug
Two interlocking bugs broke ctrl-c → clipboard on linux:

1. `probeLinuxCopy` and `copyNative` in `osc.ts` await
   `execFileNoThrow` for wl-copy / xclip / xsel. Those tools
   double-fork a daemon that holds the system selection live, and
   the daemon inherits stdio pipes from `spawn(stdio: 'pipe')`.
   Node's 'close' event only fires when stdio is fully closed → the
   daemon keeps the pipes open → 'close' never fires → the await
   leaks past the timeout (kill(SIGTERM) on an already-exited child
   is a no-op, daemon survives).

   Result: `linuxCopy` cache stays `undefined` permanently, the
   actual copy never runs, ctrl-c silently does nothing on
   wayland/x11. Reproduced in isolation, confirmed across wl-copy
   and a daemonization-shaped fixture.

   Fix: add `resolveOnExit` option to `execFileNoThrow`. When set,
   the promise settles on the immediate child's 'exit' event
   instead of waiting for stdio drainage. Wired into both the
   probe and the actual copy spawns for every clipboard tool
   (pbcopy, wl-copy, xclip, xsel, clip).

2. `logForDebugging` was a no-op stub. ink's `patchStderr()`
   redirects `process.stderr.write` (and therefore every
   `console.error`) into `logForDebugging` so stray writes can't
   corrupt the alt-screen diff. With the no-op, every diagnostic
   the TUI emitted in alt-screen mode landed in /dev/null —
   including `HERMES_TUI_DEBUG_CLIPBOARD` traces, which is what
   masked bug #1 in the first place.

   Fix: `logForDebugging` now appends to
   `~/.hermes/logs/tui-stderr.log` whenever any
   `HERMES_TUI_DEBUG*` flag is set. Override path with
   `HERMES_TUI_DEBUG_LOG=<path>`. Best-effort; unwritable paths
   silently drop the message rather than crashing the TUI.

Tests: 12 new vitest cases covering daemon-style child handling,
non-zero exit propagation, timeout behavior, double-resolve guard,
log-level routing, append semantics, and unwritable-path
resilience. The forever-hang case is committed as `it.skip` with
documentation so a reviewer can verify the bug by hand.
2026-05-11 19:00:17 -04:00
38 changed files with 5158 additions and 556 deletions

View File

@@ -31,7 +31,8 @@ export { useTerminalFocus } from './src/ink/hooks/use-terminal-focus.ts'
export { useTerminalTitle } from './src/ink/hooks/use-terminal-title.ts'
export { useTerminalViewport } from './src/ink/hooks/use-terminal-viewport.ts'
export { default as measureElement } from './src/ink/measure-element.ts'
export { createRoot, forceRedraw, default as render, renderSync } from './src/ink/root.ts'
export { copyPointAt, findRangeDom } from './src/ink/copyPointHitTest.ts'
export { createRoot, forceRedraw, getInkForStdout, default as render, renderSync } from './src/ink/root.ts'
export type { Instance, RenderOptions, Root } from './src/ink/root.ts'
export { stringWidth } from './src/ink/stringWidth.ts'
export { wrapAnsi } from './src/ink/wrapAnsi.ts'

View File

@@ -11,6 +11,7 @@ export { RawAnsi } from './ink/components/RawAnsi.js'
export { default as ScrollBox } from './ink/components/ScrollBox.js'
export { default as Spacer } from './ink/components/Spacer.js'
export { default as Text } from './ink/components/Text.js'
export { copyPointAt, findRangeDom } from './ink/copyPointHitTest.js'
export { default as useApp } from './ink/hooks/use-app.js'
export { useCursorAdvance } from './ink/hooks/use-cursor-advance.js'
export { useDeclaredCursor } from './ink/hooks/use-declared-cursor.js'
@@ -24,7 +25,7 @@ export { useTerminalTitle } from './ink/hooks/use-terminal-title.js'
export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.js'
export { default as measureElement } from './ink/measure-element.js'
export { scrollFastPathStats, type ScrollFastPathStats } from './ink/render-node-to-output.js'
export { createRoot, forceRedraw, default as render, renderSync } from './ink/root.js'
export { createRoot, forceRedraw, getInkForStdout, default as render, renderSync } from './ink/root.js'
export { stringWidth } from './ink/stringWidth.js'
export { wrapAnsi } from './ink/wrapAnsi.js'
export { isXtermJs } from './ink/terminal.js'

View File

@@ -45,6 +45,15 @@ type BaseProps = {
*/
readonly wrap?: Styles['textWrap']
readonly children?: ReactNode
/**
* Per-segment source byte range, forwarded to the underlying ink-text
* element's style so the copy-source hit-test can map clicks back to
* exact source bytes. See styles.ts `copySourceFragment` for the full
* semantics. Used by markdown inline rendering — most Text consumers
* leave it undefined.
*/
readonly copySourceFragment?: Styles['copySourceFragment']
}
/**
@@ -167,6 +176,7 @@ export default function Text(t0: Props) {
strikethrough: t3,
inverse: t4,
wrap: t5,
copySourceFragment,
children
} = t0
@@ -314,10 +324,25 @@ export default function Text(t0: Props) {
}
const textStyles = t14
const t15 = memoizedStylesForWrap[wrap]
const baseWrapStyle = memoizedStylesForWrap[wrap]
// When a copySourceFragment is set on this Text, we MUST emit a fresh
// style object (not the memoized wrap-style) so the fragment lands on
// the ink-text's style. The memoization above caches the children +
// style + textStyles tuple; copySourceFragment values are unique per
// segment so the memo would miss anyway. We skip the cache lookup
// entirely in this case to keep the render correct.
const t15 = copySourceFragment ? { ...baseWrapStyle, copySourceFragment } : baseWrapStyle
let t16
if ($[25] !== children || $[26] !== t15 || $[27] !== textStyles) {
if (copySourceFragment) {
// Non-memoized path: always re-emit. Cheap for typical markdown
// rendering (each segment renders ≤1x per parent re-render).
t16 = (
<ink-text style={t15} textStyles={textStyles}>
{children}
</ink-text>
)
} else if ($[25] !== children || $[26] !== t15 || $[27] !== textStyles) {
t16 = (
<ink-text style={t15} textStyles={textStyles}>
{children}

View File

@@ -0,0 +1,455 @@
import { describe, expect, it } from 'vitest'
import { copyPointAt } from './copyPointHitTest.js'
import { appendChildNode, createNode, type DOMElement } from './dom.js'
import { nodeCache } from './node-cache.js'
/**
* Unit tests for `copyPointAt` — specifically the gap-adjacency
* resolution path (`findAdjacentRanges`).
*
* Bug fixed here: `findAdjacentRanges` had `afterRangeId` and
* `beforeRangeId` swapped — when a click landed in a blank row
* between two ranges, the resulting SelectionPoint reported the
* range ABOVE as `beforeRangeId` and the range BELOW as
* `afterRangeId`, which is the opposite of the convention used
* everywhere else in the copy-source pipeline:
*
* - `afterRangeId` = the range the gap comes AFTER (above)
* - `beforeRangeId` = the range the gap comes BEFORE (below)
*
* Symptom: selecting from the blank line above a table to the blank
* line below it would copy the entire message instead of just the
* table (because reducePoint resolved both gap endpoints to the
* wrong side and the resulting slice window grew unbounded).
*/
describe('copyPointAt gap adjacency', () => {
/**
* Build a minimal Ink-style DOM with N range-tagged boxes stacked
* vertically, each at a specified y/height. Returns the root so
* `copyPointAt(root, col, row)` can probe it.
*/
function buildRangeStack(
ranges: ReadonlyArray<{ id: number; y: number; height: number }>
): DOMElement {
const root = createNode('ink-root')
// Root rect must cover everything so hitDeepest descends.
const totalHeight = ranges.reduce(
(acc, r) => Math.max(acc, r.y + r.height),
0
)
nodeCache.set(root, { x: 0, y: 0, width: 100, height: totalHeight })
for (const range of ranges) {
const box = createNode('ink-box')
box.style = { copyRangeId: range.id } as DOMElement['style']
nodeCache.set(box, { x: 0, y: range.y, width: 100, height: range.height })
appendChildNode(root, box)
}
return root
}
it('click in blank gap between two ranges: afterRangeId=above, beforeRangeId=below', () => {
// Range 1 occupies rows 0-1. Gap at row 2. Range 2 occupies rows 3-4.
const root = buildRangeStack([
{ id: 1, y: 0, height: 2 },
{ id: 2, y: 3, height: 2 }
])
// Click at row 2, col 0 — but col 0 IS inside the root rect, so
// hitDeepest will find the root and walk back without entering
// either range box (their rects don't cover row 2). The walk-up
// loop in copyPointAt finds no tagged ancestor → falls through
// to findAdjacentRanges.
const result = copyPointAt(root, 50, 2)
expect(result.kind).toBe('gap')
if (result.kind === 'gap') {
// The gap is AFTER range 1 (above) and BEFORE range 2 (below).
expect(result.afterRangeId).toBe(1)
expect(result.beforeRangeId).toBe(2)
}
})
it('click below all ranges: only afterRangeId set (to the last range above)', () => {
const root = buildRangeStack([
{ id: 1, y: 0, height: 2 },
{ id: 2, y: 3, height: 2 }
])
// Make root span further down so hitDeepest succeeds.
nodeCache.set(root, { x: 0, y: 0, width: 100, height: 10 })
const result = copyPointAt(root, 50, 8)
expect(result.kind).toBe('gap')
if (result.kind === 'gap') {
expect(result.afterRangeId).toBe(2) // last range above
expect(result.beforeRangeId).toBeNull()
}
})
it('click above all ranges: only beforeRangeId set (to the first range below)', () => {
const root = buildRangeStack([
{ id: 1, y: 2, height: 2 },
{ id: 2, y: 5, height: 2 }
])
const result = copyPointAt(root, 50, 0)
expect(result.kind).toBe('gap')
if (result.kind === 'gap') {
expect(result.afterRangeId).toBeNull()
expect(result.beforeRangeId).toBe(1) // first range below
}
})
it('ties broken by smaller rangeId (document order proxy)', () => {
// Two ranges, both 2 rows above the click. The one with the
// smaller id (= earlier mount order) wins.
const root = buildRangeStack([
{ id: 5, y: 0, height: 1 },
{ id: 3, y: 0, height: 1 }
])
nodeCache.set(root, { x: 0, y: 0, width: 100, height: 10 })
const result = copyPointAt(root, 50, 3)
expect(result.kind).toBe('gap')
if (result.kind === 'gap') {
expect(result.afterRangeId).toBe(3) // smaller id wins tie
}
})
it('click inside a tagged range: returns in-range, not gap', () => {
const root = buildRangeStack([
{ id: 1, y: 0, height: 3 }
])
const result = copyPointAt(root, 50, 1)
expect(result.kind).toBe('in-range')
if (result.kind === 'in-range') {
expect(result.rangeId).toBe(1)
}
})
it('wrap-continuation row: per-row fragment gives byte-exact sourceOffset, not whole-line', () => {
// Regression for: dragging from mid-row 0 to col 0 of row 1 (a
// wrap-continuation row of a single source line) was copying the
// WHOLE source line because the block's visualLineCount was the
// SOURCE-line count (1), not the WRAPPED count (2). visualLine=1
// therefore clamped pointToOffset to outerSource.length.
//
// The fix: per-row fragments on the ink-text node carry the
// source-byte slice for each wrapped row, so the hit-test on
// continuation rows returns `sourceOffset` and toCopyText skips
// the buggy pointToOffset path entirely.
const root = createNode('ink-root')
nodeCache.set(root, { x: 0, y: 0, width: 15, height: 2 })
const box = createNode('ink-box')
box.style = { copyRangeId: 7 } as DOMElement['style']
nodeCache.set(box, { x: 0, y: 0, width: 15, height: 2 })
appendChildNode(root, box)
const text = createNode('ink-text')
nodeCache.set(text, {
x: 0,
y: 0,
width: 15,
height: 2,
// "the quick brown" on row 0 [source 0..15) +
// "fox jumps over" on row 1 [source 16..30) (the space at byte
// 15 is wrap-trimmed away).
fragments: [
{ row: 0, colStart: 0, colEnd: 15, start: 0, end: 15, verbatim: true },
{ row: 1, colStart: 0, colEnd: 14, start: 16, end: 30, verbatim: true }
]
})
appendChildNode(box, text)
// Click at col 0 of the wrap-continuation row.
const result = copyPointAt(root, 0, 1)
expect(result.kind).toBe('in-range')
if (result.kind === 'in-range') {
expect(result.rangeId).toBe(7)
// Critical: sourceOffset is set so toCopyText bypasses pointToOffset.
// Without per-row fragments this was undefined and pointToOffset
// returned outerSource.length, leaking the whole line.
expect(result.sourceOffset).toBe(16)
}
})
it('triple-click sets focus at col=width-1 OUTSIDE content rect → falls back to same-row in-range', () => {
// Reproduces the user-reported triple-click bug. selectLineAt sets
// anchor=(0, row) focus=(width-1, row) using the SCREEN width, not
// the content rect. When the message body is narrower than the
// screen (typical: gutter on left, padding on right), focus lands
// OUTSIDE the CopySource Box rect.
//
// hitDeepest returns null for col=119 if the box only spans col
// 4..80. Without the same-row fallback, copyPointAt would return
// a gap with no adjacency (the only range is on the same row, and
// findAdjacentRanges only finds STRICTLY above/below ranges).
// resolvePoint would then return null and toCopyText would emit
// empty text.
//
// Fix: same-row fallback returns in-range with col clamped to the
// box's right edge (or left edge if click was to the box's left).
const root = createNode('ink-root')
nodeCache.set(root, { x: 0, y: 0, width: 120, height: 5 })
// Message body box: not full width. Starts at col 4 (gutter), ends
// at col 80. So col=119 (the triple-click focus) is OUTSIDE.
const body = createNode('ink-box')
body.style = { copyRangeId: 42 } as DOMElement['style']
nodeCache.set(body, { x: 4, y: 1, width: 76, height: 1 })
appendChildNode(root, body)
const text = createNode('ink-text')
nodeCache.set(text, { x: 4, y: 1, width: 76, height: 1 })
appendChildNode(body, text)
// Anchor click at col=0 row=1 — LEFT of the body box (in the gutter).
// Without fix: gap with no adjacency. With fix: in-range, col=0
// (clamped to body box's left edge, since col=0 - rect.x=4 < 0).
const anchor = copyPointAt(root, 0, 1)
expect(anchor.kind).toBe('in-range')
if (anchor.kind === 'in-range') {
expect(anchor.rangeId).toBe(42)
expect(anchor.visualLine).toBe(0)
expect(anchor.col).toBe(0)
}
// Focus click at col=119 row=1 — right edge of the screen, way past
// body box's x+width=80. Without fix: empty gap. With fix:
// in-range, col=75 (clamped to box.width - 1).
const focus = copyPointAt(root, 119, 1)
expect(focus.kind).toBe('in-range')
if (focus.kind === 'in-range') {
expect(focus.rangeId).toBe(42)
expect(focus.visualLine).toBe(0)
// col clamped to box.width - 1 = 76 - 1 = 75.
expect(focus.col).toBe(75)
}
})
it('same-row fallback picks the SMALLEST tagged box when ranges are nested', () => {
// When multiple tagged boxes straddle the click row (e.g. a msg
// box containing a fence block), the fallback should pick the
// INNERMOST (smallest-area) one — that's what the user clicked.
const root = createNode('ink-root')
nodeCache.set(root, { x: 0, y: 0, width: 120, height: 10 })
// Outer msg box (large area).
const msgBox = createNode('ink-box')
msgBox.style = { copyRangeId: 100 } as DOMElement['style']
nodeCache.set(msgBox, { x: 4, y: 1, width: 76, height: 5 })
appendChildNode(root, msgBox)
// Inner fence block (smaller area, nested inside msg).
const fenceBox = createNode('ink-box')
fenceBox.style = { copyRangeId: 101 } as DOMElement['style']
nodeCache.set(fenceBox, { x: 6, y: 2, width: 70, height: 3 })
appendChildNode(msgBox, fenceBox)
// Click in the gutter on a row inside the fence.
const result = copyPointAt(root, 0, 3)
expect(result.kind).toBe('in-range')
if (result.kind === 'in-range') {
// Should pick the smaller fence box, not the outer msg box.
expect(result.rangeId).toBe(101)
}
})
it('wrap-continuation row with NO fragments: degrades to in-range with bad visualLine (documents the regression)', () => {
// What happens when the renderer didn't emit fragments for the
// wrap (e.g. paragraph rendered without the MdInline wrap()
// wrapper, or fragments were stale-evicted). The hit-test still
// returns in-range, but with `visualLine = row - rect.y` = the
// visual row index relative to the ink-text rect.
//
// For a wrapped block whose CopySource was registered with
// visualLineCount = source-line-count (1, not the wrapped count
// 2), pointToOffset(visualLine=1, ...) clamps to outerSource.length
// and toCopyText emits the whole source line. This test pins down
// exactly what the host receives in that scenario so we can spot
// it from logs.
const root = createNode('ink-root')
nodeCache.set(root, { x: 0, y: 0, width: 15, height: 2 })
const box = createNode('ink-box')
box.style = { copyRangeId: 11 } as DOMElement['style']
nodeCache.set(box, { x: 0, y: 0, width: 15, height: 2 })
appendChildNode(root, box)
const text = createNode('ink-text')
// NOTE: no `fragments` set — simulating the broken state.
nodeCache.set(text, { x: 0, y: 0, width: 15, height: 2 })
appendChildNode(box, text)
const result = copyPointAt(root, 0, 1)
expect(result.kind).toBe('in-range')
if (result.kind === 'in-range') {
expect(result.rangeId).toBe(11)
expect(result.visualLine).toBe(1)
expect(result.col).toBe(0)
// sourceOffset is undefined → falls through to the
// pointToOffset(visualLine=1, col=0) path in toCopyText, which
// clamps to outerSource.length when visualLineCount=1.
expect(result.sourceOffset).toBeUndefined()
}
})
it('wrap-continuation row mid-fragment: sourceOffset uses verbatim cell→byte math', () => {
// Same wrapped paragraph, click at col 5 of row 1 → should give
// source byte 21 (16 + 5), not the whole-line clamp.
const root = createNode('ink-root')
nodeCache.set(root, { x: 0, y: 0, width: 15, height: 2 })
const box = createNode('ink-box')
box.style = { copyRangeId: 9 } as DOMElement['style']
nodeCache.set(box, { x: 0, y: 0, width: 15, height: 2 })
appendChildNode(root, box)
const text = createNode('ink-text')
nodeCache.set(text, {
x: 0,
y: 0,
width: 15,
height: 2,
fragments: [
{ row: 0, colStart: 0, colEnd: 15, start: 0, end: 15, verbatim: true },
{ row: 1, colStart: 0, colEnd: 14, start: 16, end: 30, verbatim: true }
]
})
appendChildNode(box, text)
const result = copyPointAt(root, 5, 1)
expect(result.kind).toBe('in-range')
if (result.kind === 'in-range') {
expect(result.sourceOffset).toBe(21)
}
})
it('endpoint="end" bumps verbatim sourceOffset by 1 (cell-INCLUSIVE → byte-EXCLUSIVE)', () => {
// Regression: cell-INCLUSIVE selection bounds × byte-EXCLUSIVE
// slice semantics dropped one char off the right edge of every
// word/drag selection ("might" → "migh"). Fix: hit-test bumps the
// verbatim cell→byte mapping by 1 when endpoint='end' is passed
// (e.g. by buildCopyTextFromDom for the focus point of a selection),
// clamped to the fragment's end byte.
const root = createNode('ink-root')
nodeCache.set(root, { x: 0, y: 0, width: 18, height: 1 })
const box = createNode('ink-box')
box.style = { copyRangeId: 11 } as DOMElement['style']
nodeCache.set(box, { x: 0, y: 0, width: 18, height: 1 })
appendChildNode(root, box)
// "things might break" — single verbatim fragment, 18 cells = 18 bytes.
const text = createNode('ink-text')
nodeCache.set(text, {
x: 0,
y: 0,
width: 18,
height: 1,
fragments: [
{ row: 0, colStart: 0, colEnd: 18, start: 0, end: 18, verbatim: true }
]
})
appendChildNode(box, text)
// Cell 11 = 't' of "might" (the last cell of the word).
const startResult = copyPointAt(root, 11, 0, 'start')
const endResult = copyPointAt(root, 11, 0, 'end')
expect(startResult.kind).toBe('in-range')
expect(endResult.kind).toBe('in-range')
if (startResult.kind === 'in-range') {
expect(startResult.sourceOffset).toBe(11) // cell start byte
}
if (endResult.kind === 'in-range') {
expect(endResult.sourceOffset).toBe(12) // one PAST cell — fixes "migh"
}
// Sanity: default arg behaves like 'start' (backward compat).
const defaultResult = copyPointAt(root, 11, 0)
if (defaultResult.kind === 'in-range') {
expect(defaultResult.sourceOffset).toBe(11)
}
// Clamp check: end-of-fragment click with endpoint='end' must not
// over-read past the fragment's end byte.
const endOfFragment = copyPointAt(root, 17, 0, 'end')
if (endOfFragment.kind === 'in-range') {
expect(endOfFragment.sourceOffset).toBe(18) // == f.end, clamped
}
})
it('reports visualLine/col relative to inner padded content, not outer rangeId Box', () => {
// Regression for ethie's report #2: a mermaid code fence renders
//
// <CopySource Box copyRangeId=N> rect.x=0
// <Box paddingLeft=2> rect.x=2
// <Text>graph LR</Text> rect.x=2
// <Text> user[ethie] -->...</Text> rect.x=2
// </Box>
// </CopySource Box>
//
// Click on the 'e' of "ethie" (visual col 11 = source col 9 + 2
// padding). The hit-test used to walk up to the rangeId Box and
// report col = 11 - 0 = 11, but getOffset interprets col=11 as
// source col 11 — shifted +2 (hits 'h'). Selecting 'ethie' →
// copies 'hie]'.
//
// Fix: report col relative to the INNERMOST non-rangeId rect
// (the padded inner box / text), so col = 11 - 2 = 9 = source 'e'.
const root = createNode('ink-root')
nodeCache.set(root, { x: 0, y: 0, width: 50, height: 5 })
const outerBox = createNode('ink-box')
outerBox.style = { copyRangeId: 42 } as DOMElement['style']
nodeCache.set(outerBox, { x: 0, y: 0, width: 50, height: 5 })
appendChildNode(root, outerBox)
const paddedBox = createNode('ink-box')
nodeCache.set(paddedBox, { x: 2, y: 0, width: 48, height: 5 }) // paddingLeft=2
appendChildNode(outerBox, paddedBox)
const text = createNode('ink-text')
nodeCache.set(text, { x: 2, y: 2, width: 48, height: 1 })
appendChildNode(paddedBox, text)
// Click at visual col 11, row 2 — the 'e' of 'ethie'.
const result = copyPointAt(root, 11, 2, 'start')
expect(result.kind).toBe('in-range')
if (result.kind === 'in-range') {
expect(result.rangeId).toBe(42)
// col is reported RELATIVE TO INNER content (innerX=2):
// 11 - 2 = 9, which is source col 9 = 'e' of ethie.
// visualLine STAYS relative to the rangeId Box (rect.y=0):
// 2 - 0 = 2, which is the third source row of the block —
// matching the registered rowStarts that count from block start.
expect(result.col).toBe(9)
expect(result.visualLine).toBe(2)
}
})
})

View File

@@ -0,0 +1,407 @@
/**
* Map (col, row) screen coordinates to a copy-source SelectionPoint.
*
* Used by the new transcript-virtual selection pipeline: when a mouse
* event fires at (col, row), this walks the DOM to find the nearest
* ancestor box tagged with `style.copyRangeId` and translates the
* coords to (visualLine, col) relative to that box's rect.
*
* If the deepest hit ancestor also has `style.copySourceFragment` set
* (the per-segment tag attached to each <Text> by markdown inline
* rendering), the SelectionPoint includes a precomputed `sourceOffset`
* — the EXACT byte offset within the enclosing range's outerSource.
* Host code uses this directly without consulting `getOffset`, sidestepping
* the width-math that would otherwise be needed for formatted segments
* like `**bold**` or `$math$` where rendered cells ≠ source bytes.
*
* The returned SelectionPoint is structurally identical to the
* `lib/copySource/types.ts` SelectionPoint (host code), but this module
* doesn't import from there to avoid a circular dependency (host depends
* on hermes-ink, not vice versa). Host code reinterprets the returned
* object via a duck-typed cast.
*
* Gap handling: when (col,row) isn't inside any tagged region, we walk
* the entire DOM looking for ranges and return the rangeIds of the
* nearest ranges above (`beforeRangeId`) and below (`afterRangeId`).
* This lets toCopyText anchor the gap-endpoint correctly between two
* known messages instead of degrading to far-end-of-doc.
*/
import type { DOMElement } from './dom.js'
import { nodeCache } from './node-cache.js'
export type RawSelectionPoint =
| {
kind: 'in-range'
rangeId: number
visualLine: number
col: number
/**
* When set, this is the precomputed source byte offset within the
* range's outerSource — the host MUST use this verbatim instead of
* resolving (visualLine, col) via getOffset. Set whenever the
* ink-text along the path up to the range carries cached fragment
* info covering (col, row).
*/
sourceOffset?: number
}
| { kind: 'gap'; afterRangeId: null | number; beforeRangeId: null | number }
/**
* Walk the DOM tree from `root` finding the deepest box at (col, row),
* then walk back up looking for `style.copyRangeId`. Returns the raw
* SelectionPoint with adjacency info for gaps and a precomputed source
* byte offset when a fragment tag was found on the way up.
*
* `root` is the Ink rootNode. The walk uses nodeCache rects (computed
* by the last frame's render pass), which already account for
* scrollTop translation — so a click on a visually-on-screen row that
* came from a virtually-scrolled ScrollBox is hit correctly.
*
* `endpoint` controls how the per-cell click maps to a source-byte
* offset on verbatim fragments. Selection bounds are stored as
* CELL-INCLUSIVE coords (anchor/focus both point AT the cell containing
* the character), but `String.slice(from, to)` is `to`-EXCLUSIVE. So
* for the END of a selection we must add 1 to skip past the clicked
* cell; for the START we use the cell's start byte as-is.
*
* - 'start' (default): start-of-clicked-cell.
* Used for the anchor of a selection, and for mouse-click probes
* where there's no anchor/focus context yet.
* - 'end': one past the clicked cell, clamped to the fragment end.
* Used for the focus of a finalized selection (where the cell is
* the LAST included cell, and slice(from, to) needs `to` past it).
*
* Non-verbatim fragments already use the half-cell heuristic (left
* half → fragment start, right half → fragment end) which is
* endpoint-agnostic; `endpoint` is ignored for them.
*/
export function copyPointAt(
root: DOMElement,
col: number,
row: number,
endpoint: 'start' | 'end' = 'start'
): RawSelectionPoint {
const deepest = hitDeepest(root, col, row)
if (deepest) {
// Walk up looking for a Box tagged with copyRangeId. Along the way,
// if we cross an ink-text whose cached layout carries `fragments`,
// try to resolve the click against those per-segment ranges — that
// gives byte-exact source mapping for markdown inline content
// (math, bold, links, code, etc.) without any width math.
let fragmentResolved: number | undefined
// Track the deepest non-rangeId X-offset so we can report col
// relative to the INNERMOST rendered content, not the outer
// copyRangeId-carrying Box. This matters when CopySource wraps a
// Box with paddingLeft (code fences, tables, blockquotes, lists):
// the outer Box's rect.x = 0 but the inner content lives at
// rect.x = paddingLeft. Without this, a click on the rendered char
// at visual col 11 (= source col 9 + 2 padding) returns col=11,
// which getOffset interprets as source col 11 — shifted +2.
//
// We only adjust X — visualLine (Y) is reported relative to the
// rangeId Box's rect, because that's the coordinate system that
// matches the registered visualLineCount + rowStarts (which are
// counted from the START of the rendered block, not the start of
// any sub-text element).
let innerX: number | undefined
let node: DOMElement | undefined = deepest
while (node) {
const rangeId = (node.style as { copyRangeId?: number }).copyRangeId
const rect = nodeCache.get(node)
if (rect && innerX === undefined && rangeId === undefined) {
// First rect we see that is NOT the rangeId Box becomes the
// anchor for col reporting. We walk from deepest upward, so
// this is the innermost text container.
innerX = rect.x
}
// If THIS node has cached fragments (ink-text), try to find one
// covering (col, row). First hit wins; we don't keep looking up
// the tree once we've resolved.
if (rect && rect.fragments && fragmentResolved === undefined) {
const localRow = row - rect.y
const localCol = col - rect.x
for (const f of rect.fragments) {
if (f.row === localRow && localCol >= f.colStart && localCol < f.colEnd) {
const len = f.end - f.start
if (f.verbatim) {
// Cell-INCLUSIVE click coord → byte offset. For an end-of-
// selection point we want one past the clicked cell so
// slice(from, to) includes it; for start-of-selection we
// want the cell's start byte. Bumped offset is clamped to
// the fragment's end so we never read past it.
const cellsIn = localCol - f.colStart
const bump = endpoint === 'end' ? 1 : 0
fragmentResolved = f.start + Math.min(cellsIn + bump, len)
} else {
const widthInFragment = f.colEnd - f.colStart
const colInFragment = localCol - f.colStart
fragmentResolved =
colInFragment * 2 < widthInFragment ? f.start : f.end
}
break
}
}
}
if (typeof rangeId === 'number' && rect) {
// Report col relative to innermost rendered content (innerX)
// when available, falling back to the rangeId Box's rect.x.
// visualLine stays relative to the rangeId Box (rect.y),
// matching the registered rowStarts / visualLineCount.
const reportX = innerX ?? rect.x
return {
kind: 'in-range',
rangeId,
visualLine: Math.max(0, row - rect.y),
col: Math.max(0, col - reportX),
...(fragmentResolved !== undefined && { sourceOffset: fragmentResolved })
}
}
node = node.parentNode
}
}
// No tagged ancestor at (col, row). Before falling through to gap
// resolution, check for a tagged box on the SAME row whose
// horizontal extent we missed (click was in the gutter on the left
// or past the content on the right). triple-click selectLineAt sets
// focus=(width-1, row) using the SCREEN width, not the content rect;
// when the message body is narrower than the screen the focus lands
// outside the box and otherwise resolves to an empty gap, which
// toCopyText turns into empty output.
//
// For each tagged box whose y-range covers `row`, return an
// in-range point at the nearest edge of the box (left edge if click
// was to its left, right edge if click was to its right). Snap to
// the SMALLEST such box (deepest tagged) when multiple straddle the
// row — that's the user's intent (the specific block they clicked
// on, not its enclosing container).
const sameRow = findSameRowRange(root, col, row)
if (sameRow) {
return {
kind: 'in-range',
rangeId: sameRow.rangeId,
visualLine: row - sameRow.rect.y,
col: sameRow.col
}
}
// No tagged ancestor at (col, row) and nothing on the same row.
// Scan the WHOLE DOM for tagged boxes, partition them into "above
// row" and "below row" by their cached y bounds, and pick the
// nearest each direction. This gives toCopyText enough info to
// slot the gap between two known ranges.
const { afterRangeId, beforeRangeId } = findAdjacentRanges(root, row)
return { kind: 'gap', afterRangeId, beforeRangeId }
}
/**
* Find the SMALLEST tagged box whose y-extent contains `row`, even
* when `col` is outside its x-extent. Returns the rangeId and the
* snapped column (clamped to the box's x-extent). Returns null when
* no tagged box straddles `row`.
*
* Used as a recovery path for clicks in the row gutter / past the
* content — the user's intent is "select this row's content," and a
* gap point with same-row-only adjacency would otherwise resolve to
* nothing.
*/
function findSameRowRange(
root: DOMElement,
col: number,
row: number
): { rangeId: number; rect: { x: number; y: number; width: number; height: number }; col: number } | null {
let best: { rangeId: number; rect: { x: number; y: number; width: number; height: number }; col: number; area: number } | null =
null
const visit = (node: DOMElement): void => {
const rangeId = (node.style as { copyRangeId?: number }).copyRangeId
if (typeof rangeId === 'number') {
const rect = nodeCache.get(node)
if (rect && row >= rect.y && row < rect.y + rect.height) {
// y matches; snap col into the box's x-extent.
const snappedCol = Math.max(0, Math.min(col - rect.x, rect.width - 1))
const area = rect.width * rect.height
if (!best || area < best.area) {
best = { rangeId, rect, col: snappedCol, area }
}
}
}
for (const child of node.childNodes) {
if (child.nodeName === '#text') {
continue
}
visit(child as DOMElement)
}
}
visit(root)
if (!best) {
return null
}
return { rangeId: (best as { rangeId: number }).rangeId, rect: (best as { rect: { x: number; y: number; width: number; height: number } }).rect, col: (best as { col: number }).col }
}
/**
* Recursive depth-first hit test. Returns the deepest element whose
* cached rect contains (col, row). Mirrors the existing hit-test.ts
* implementation but without the side effects (no event dispatch, no
* hover tracking).
*/
function hitDeepest(node: DOMElement, col: number, row: number): DOMElement | null {
const rect = nodeCache.get(node)
if (!rect) {
return null
}
if (col < rect.x || col >= rect.x + rect.width || row < rect.y || row >= rect.y + rect.height) {
return null
}
// Reverse iteration: later siblings paint over earlier (so they win on
// overlap). Matches existing hit-test.ts.
for (let i = node.childNodes.length - 1; i >= 0; i--) {
const child = node.childNodes[i]
if (!child || child.nodeName === '#text') {
continue
}
const hit = hitDeepest(child, col, row)
if (hit) {
return hit
}
}
return node
}
/**
* Walk the tree collecting every node with `copyRangeId`, then bucket
* each by whether its rect ends strictly above `row` (→ candidate for
* `afterRangeId`: the gap is AFTER this range) or starts strictly
* below `row` (→ candidate for `beforeRangeId`: the gap is BEFORE
* this range). Ranges straddling `row` are ignored — they would have
* been picked up by the in-range path before us.
*
* Naming convention (matches SelectionPoint.kind === 'gap' in
* lib/copySource/types.ts):
* - `afterRangeId` = the range the gap comes AFTER (i.e. the range
* ABOVE the click, in document order BEFORE the gap)
* - `beforeRangeId` = the range the gap comes BEFORE (i.e. the range
* BELOW the click, in document order AFTER the gap)
*
* "Nearest" is measured by row distance (Manhattan-y). Ties are broken
* by the smaller rangeId, which approximates document order (ids are
* allocated in mount order).
*/
function findAdjacentRanges(root: DOMElement, row: number): { afterRangeId: null | number; beforeRangeId: null | number } {
let afterRangeId: null | number = null
let afterDist = Number.POSITIVE_INFINITY
let beforeRangeId: null | number = null
let beforeDist = Number.POSITIVE_INFINITY
const visit = (node: DOMElement): void => {
const rangeId = (node.style as { copyRangeId?: number }).copyRangeId
if (typeof rangeId === 'number') {
const rect = nodeCache.get(node)
if (rect) {
const top = rect.y
const bottom = rect.y + rect.height // exclusive
if (bottom <= row) {
// Range is ABOVE the click → the gap comes AFTER this range
// → it's a candidate for `afterRangeId`.
const d = row - (bottom - 1)
if (d < afterDist || (d === afterDist && (afterRangeId === null || rangeId < afterRangeId))) {
afterDist = d
afterRangeId = rangeId
}
} else if (top > row) {
// Range is BELOW the click → the gap comes BEFORE this range
// → it's a candidate for `beforeRangeId`.
const d = top - row
if (d < beforeDist || (d === beforeDist && (beforeRangeId === null || rangeId < beforeRangeId))) {
beforeDist = d
beforeRangeId = rangeId
}
}
// Straddling row — leave to the in-range path; we wouldn't be
// here if it had hit, so the rect's hit-test failed (likely
// because col was outside). Treat as neither above nor below.
}
}
for (const child of node.childNodes) {
if (child.nodeName === '#text') {
continue
}
visit(child as DOMElement)
}
}
visit(root)
return { afterRangeId, beforeRangeId }
}
/**
* Locate the DOM node currently rendering a given rangeId by walking the
* tree top-down. Returns null if no node has `style.copyRangeId === id`
* (e.g. the range is registered but its rendering is unmounted due to
* virtual scrolling).
*
* Used by the host's selection-overlay path to translate a virtual
* anchor/focus point back to screen coordinates for highlight rendering.
*/
export function findRangeDom(root: DOMElement, id: number): DOMElement | null {
if ((root.style as { copyRangeId?: number }).copyRangeId === id) {
return root
}
for (const child of root.childNodes) {
if (child.nodeName === '#text') {
continue
}
// The cast through `unknown` is to dodge a TS quirk: when this file
// is re-exported from the package's `index.d.ts` shim, the recursive
// `findRangeDom` call's return is inferred as `unknown` rather than
// the explicit `DOMElement | null` signature.
const found = findRangeDom(child as DOMElement, id) as DOMElement | null
if (found) {
return found
}
}
return null
}

View File

@@ -177,6 +177,16 @@ export default class Ink {
// Ignore last render after unmounting a tree to prevent empty output before exit
private isUnmounted = false
private isPaused = false
/**
* Optional host-provided override for selection→clipboard-text. When set,
* `copySelection*` calls this instead of the default cell-extracting
* getSelectedText. Used by the transcript-virtual copy-source pipeline
* to emit raw markdown / source text instead of rendered cells.
*
* Receives the Ink instance so the override can read `this.rootNode`,
* `this.selection`, `this.frontFrame.screen`, etc.
*/
private copyTextFn: ((self: Ink) => string) | null = null
private readonly container: FiberRoot
private rootNode: dom.DOMElement
readonly focusManager: FocusManager
@@ -1460,7 +1470,9 @@ export default class Ink {
return ''
}
const text = getSelectedText(this.selection, this.frontFrame.screen)
const text = this.copyTextFn
? this.copyTextFn(this)
: getSelectedText(this.selection, this.frontFrame.screen)
if (text) {
try {
@@ -1759,6 +1771,30 @@ export default class Ink {
hasTextSelection(): boolean {
return hasSelection(this.selection)
}
/**
* Install (or clear) the host-provided copy-text override. Called by
* the TUI host once on mount to wire up the transcript-virtual
* copy-source pipeline. Pass null to revert to the default cell-text
* extraction behavior.
*/
setCopyTextFn(fn: ((self: Ink) => string) | null): void {
this.copyTextFn = fn
}
/**
* Read access to the rendered root DOM tree. Used by the copy-text
* override to walk for `copyRangeId` tagged boxes.
*/
getRootDom(): dom.DOMElement {
return this.rootNode
}
/**
* Read access to the current selection's screen-coord bounds. Used by
* the copy-text override to know which ranges/cells the selection
* covers. Returns null when no selection.
*/
getSelectionBoundsScreen(): { start: { col: number; row: number }; end: { col: number; row: number } } | null {
return selectionBounds(this.selection)
}
getSelectionVersion(): number {
return this.selectionVersion

View File

@@ -1,11 +1,43 @@
import type { DOMElement } from './dom.js'
import type { Rectangle } from './layout/geometry.js'
/**
* One source-fragment entry attached to an ink-text node's cached layout.
*
* After ink-text renders its (possibly multi-segment, possibly wrapped)
* content, the renderer emits one entry per `<ink-virtual-text>` child
* with a `copySourceFragment` style. Each entry says "rows[r] cols
* [colStart, colEnd) on the screen rect render bytes [start, end) of
* the enclosing copy-source range's outerSource."
*
* Multiple entries on the same row are allowed (one per virtual-text
* child); they're scanned linearly by the copy hit-test for the cell
* containing (col, row) and `start`/`end` are returned.
*
* `verbatim` mirrors the same field on `Styles.copySourceFragment`:
* verbatim segments map visual col → source byte 1:1 via
* `start + (col - colStart)`; formatted segments snap to either
* `start` or `end` based on which half of the segment was clicked.
*/
export type CachedFragment = {
row: number
colStart: number
colEnd: number
start: number
end: number
verbatim: boolean
}
/**
* Cached layout bounds for each rendered node (used for blit + clearing).
* `top` is the yoga-local getComputedTop() — stored so ScrollBox viewport
* culling can skip yoga reads for clean children whose position hasn't
* shifted (O(dirty) instead of O(mounted) first-pass).
*
* `fragments` is set on ink-text nodes whose children carry
* copySourceFragment styles; it gives the hit-test a per-row, per-col
* lookup table for byte-exact source mapping. Unset for nodes with no
* fragment children (the common case).
*/
export type CachedLayout = {
x: number
@@ -13,6 +45,7 @@ export type CachedLayout = {
width: number
height: number
top?: number
fragments?: CachedFragment[]
}
export const nodeCache = new WeakMap<DOMElement, CachedLayout>()

View File

@@ -0,0 +1,212 @@
import { describe, expect, it } from 'vitest'
import { computeFragmentsForWrappedText } from './render-node-to-output.js'
import type { StyledSegment } from './squash-text-nodes.js'
/**
* Unit tests for `computeFragmentsForWrappedText` — the helper that
* emits per-row CachedFragment entries for an ink-text whose segments
* carry `copySourceFragment` style tags.
*
* This is the core of the wrap-aware copy fix: pre-fix, fragments only
* emitted on row 0, so partial selections across wrap boundaries
* degraded to block-level mapping. Post-fix, every (segment × row)
* intersection emits a fragment with the per-row source slice (for
* verbatim segments) or the whole-segment bounds (for formatted spans).
*/
describe('computeFragmentsForWrappedText', () => {
const mkSeg = (text: string, tag?: { start: number; end: number; verbatim: boolean }): StyledSegment => ({
text,
styles: {} as StyledSegment['styles'],
...(tag ? { copySourceFragment: tag } : {})
})
it('emits no fragments when no segments carry copySourceFragment', () => {
const segments = [mkSeg('hello world')]
const charToSegment = Array(11).fill(0)
const fragments = computeFragmentsForWrappedText(
'hello world',
segments,
charToSegment,
'hello world',
false
)
expect(fragments).toEqual([])
})
it('verbatim segment: row 0 fragment maps cells 1:1 to source bytes', () => {
const segments = [mkSeg('hello', { start: 10, end: 15, verbatim: true })]
const charToSegment = [0, 0, 0, 0, 0]
const fragments = computeFragmentsForWrappedText(
'hello',
segments,
charToSegment,
'hello',
false
)
expect(fragments).toEqual([
{ row: 0, colStart: 0, colEnd: 5, start: 10, end: 15, verbatim: true }
])
})
it('verbatim segment spanning wrap: row 0 + row 1 each get per-row source slice', () => {
// Single segment "abcdefgh" with source bytes [10, 18) wraps to two
// rows of width 4. Row 0 = "abcd" → [10, 14). Row 1 = "efgh" → [14, 18).
const segments = [mkSeg('abcdefgh', { start: 10, end: 18, verbatim: true })]
const charToSegment = [0, 0, 0, 0, 0, 0, 0, 0]
const fragments = computeFragmentsForWrappedText(
'abcd\nefgh',
segments,
charToSegment,
'abcdefgh',
false
)
expect(fragments).toHaveLength(2)
expect(fragments[0]).toEqual({
row: 0,
colStart: 0,
colEnd: 4,
start: 10,
end: 14,
verbatim: true
})
expect(fragments[1]).toEqual({
row: 1,
colStart: 0,
colEnd: 4,
start: 14,
end: 18,
verbatim: true
})
})
it('formatted segment spanning wrap: every row emits whole-segment bounds', () => {
// Formatted (non-verbatim) segment "XYZWQR" with source bytes [20, 30)
// wraps across two rows. Both rows should report start=20, end=30 so
// the copyPointAt snap-rule maps clicks to start or end based on
// half-width within the on-row part, regardless of which row was hit.
const segments = [mkSeg('XYZWQR', { start: 20, end: 30, verbatim: false })]
const charToSegment = [0, 0, 0, 0, 0, 0]
const fragments = computeFragmentsForWrappedText(
'XYZ\nWQR',
segments,
charToSegment,
'XYZWQR',
false
)
expect(fragments).toHaveLength(2)
expect(fragments[0]).toEqual({
row: 0,
colStart: 0,
colEnd: 3,
start: 20,
end: 30,
verbatim: false
})
expect(fragments[1]).toEqual({
row: 1,
colStart: 0,
colEnd: 3,
start: 20,
end: 30,
verbatim: false
})
})
it('mixed verbatim + formatted segments wrapping mid-paragraph', () => {
// verbatim "abcdefgh" source [10, 18) followed by formatted
// "XYZWQRSTUV" source [20, 30). Wrap at 5 cols:
// row 0: "abcde" → verbatim seg, source [10, 15)
// row 1: "fghXY" → verbatim part [15, 18) + formatted [20, 30)
// row 2: "ZWQRS" → formatted whole-seg [20, 30)
// row 3: "TUV" → formatted whole-seg [20, 30)
const segments = [
mkSeg('abcdefgh', { start: 10, end: 18, verbatim: true }),
mkSeg('XYZWQRSTUV', { start: 20, end: 30, verbatim: false })
]
const charToSegment = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
const fragments = computeFragmentsForWrappedText(
'abcde\nfghXY\nZWQRS\nTUV',
segments,
charToSegment,
'abcdefghXYZWQRSTUV',
false
)
// Row 0: one verbatim fragment for "abcde".
const row0 = fragments.filter(f => f.row === 0)
expect(row0).toHaveLength(1)
expect(row0[0]).toEqual({ row: 0, colStart: 0, colEnd: 5, start: 10, end: 15, verbatim: true })
// Row 1: two fragments — verbatim "fgh" then formatted "XY".
const row1 = fragments.filter(f => f.row === 1).sort((a, b) => a.colStart - b.colStart)
expect(row1).toHaveLength(2)
expect(row1[0]).toEqual({ row: 1, colStart: 0, colEnd: 3, start: 15, end: 18, verbatim: true })
expect(row1[1]).toEqual({ row: 1, colStart: 3, colEnd: 5, start: 20, end: 30, verbatim: false })
// Row 2 & 3: formatted segment only, whole-segment bounds each row.
const row2 = fragments.filter(f => f.row === 2)
expect(row2).toHaveLength(1)
expect(row2[0]).toEqual({ row: 2, colStart: 0, colEnd: 5, start: 20, end: 30, verbatim: false })
const row3 = fragments.filter(f => f.row === 3)
expect(row3).toHaveLength(1)
expect(row3[0]).toEqual({ row: 3, colStart: 0, colEnd: 3, start: 20, end: 30, verbatim: false })
})
it('hard newlines in original advance charIndex correctly across rows', () => {
// Two-line source separated by \n. Each line is its own visual row
// — no wrap, but the function still walks them line by line.
const segments = [mkSeg('hi\nbye', { start: 0, end: 6, verbatim: true })]
const charToSegment = [0, 0, 0, 0, 0, 0]
const fragments = computeFragmentsForWrappedText(
'hi\nbye',
segments,
charToSegment,
'hi\nbye',
false
)
expect(fragments).toHaveLength(2)
expect(fragments[0]).toEqual({ row: 0, colStart: 0, colEnd: 2, start: 0, end: 2, verbatim: true })
// After row 0, charIndex skips the '\n' so row 1 starts at byte 3.
expect(fragments[1]).toEqual({ row: 1, colStart: 0, colEnd: 3, start: 3, end: 6, verbatim: true })
})
it('wrap-trim eats inter-row whitespace: row 1 maps past the eaten char', () => {
// Single source line "the quick brown fox jumps over" wraps at 15
// cols. wrap-trim removes the space at byte 15 from the visual
// output but it's still in originalPlain. The function must skip
// that char when advancing charIndex between rows, otherwise the
// row-1 fragment would think it covers bytes 15+ instead of 16+
// and clicks on row 1 would map to the wrong source position.
const source = 'the quick brown fox jumps over'
const segments = [mkSeg(source, { start: 0, end: 30, verbatim: true })]
const charToSegment = Array.from({ length: 30 }, () => 0)
const fragments = computeFragmentsForWrappedText(
'the quick brown\nfox jumps over',
segments,
charToSegment,
source,
/* trimEnabled */ true
)
expect(fragments).toHaveLength(2)
expect(fragments[0]).toEqual({ row: 0, colStart: 0, colEnd: 15, start: 0, end: 15, verbatim: true })
// CRITICAL: row 1's source byte starts at 16 (past the eaten space
// at byte 15), not at 15.
expect(fragments[1]).toEqual({ row: 1, colStart: 0, colEnd: 14, start: 16, end: 30, verbatim: true })
})
})

View File

@@ -5,7 +5,7 @@ import type { DOMElement } from './dom.js'
import getMaxWidth from './get-max-width.js'
import type { Rectangle } from './layout/geometry.js'
import { LayoutDisplay, LayoutEdge, type LayoutNode } from './layout/node.js'
import { nodeCache, pendingClears } from './node-cache.js'
import { type CachedFragment, nodeCache, pendingClears } from './node-cache.js'
import type Output from './output.js'
import renderBorder from './render-border.js'
import type { Screen } from './screen.js'
@@ -586,12 +586,19 @@ function renderNodeToOutput(
let text: string
let softWrap: boolean[] | undefined
let wrappedPlain: string
let charToSegmentForFragments: number[]
let trimEnabledForFragments = false
if (needsWrapping && segments.length === 1) {
// Single segment: wrap plain text first, then apply styles to each line
const segment = segments[0]!
const w = wrapWithSoftWrap(plainText, maxWidth, textWrap)
softWrap = w.softWrap
wrappedPlain = w.wrapped
trimEnabledForFragments = textWrap === 'wrap-trim'
// Single-segment case: every char in the wrapped output maps to segment 0.
charToSegmentForFragments = new Array(plainText.length).fill(0)
text = w.wrapped
.split('\n')
.map(line => {
@@ -614,12 +621,17 @@ function renderNodeToOutput(
// per-segment styles even when text wraps across lines.
const w = wrapWithSoftWrap(plainText, maxWidth, textWrap)
softWrap = w.softWrap
wrappedPlain = w.wrapped
trimEnabledForFragments = textWrap === 'wrap-trim'
const charToSegment = buildCharToSegmentMap(segments)
charToSegmentForFragments = charToSegment
text = applyStylesToWrappedText(w.wrapped, segments, charToSegment, plainText, textWrap === 'wrap-trim')
// Hyperlinks are handled per-run in applyStylesToWrappedText via
// wrapWithOsc8Link, similar to how styles are applied per-run.
} else {
// No wrapping needed: apply styles directly
wrappedPlain = plainText
charToSegmentForFragments = buildCharToSegmentMap(segments)
text = segments
.map(segment => {
let styledText = applyTextStyles(segment.text, segment.styles)
@@ -636,6 +648,24 @@ function renderNodeToOutput(
text = applyPaddingToText(node, text, softWrap)
output.write(x, y, text, softWrap)
// Build per-row fragment ranges for the copy hit-test. Each
// segment with a `copySourceFragment` style emits one or more
// CachedFragment entries (multiple when the segment wraps
// across visual rows). Stored on the node directly so the
// generic `nodeCache.set` at the end of renderNodeToOutput
// picks it up.
const segmentFragments = computeFragmentsForWrappedText(
wrappedPlain,
segments,
charToSegmentForFragments,
plainText,
trimEnabledForFragments
)
if (segmentFragments.length > 0) {
;(node as DOMElement & { _copyFragments?: CachedFragment[] })._copyFragments = segmentFragments
}
}
} else if (node.nodeName === 'ink-box') {
const boxBackgroundColor = node.style.backgroundColor ?? inheritedBackgroundColor
@@ -1280,8 +1310,20 @@ function renderNodeToOutput(
renderChildren(node, output, x, y, hasRemovedChild, prevScreen, inheritedBackgroundColor)
}
// Cache layout bounds for dirty tracking
const rect = { x, y, width, height, top: yogaTop }
// Cache layout bounds for dirty tracking. If the ink-text branch
// computed per-segment fragment ranges, attach them — the copy
// hit-test reads them from rect.fragments to map clicks to source
// bytes without DOM-walking for child virtual-text nodes (which
// don't have their own nodeCache entries).
const fragmentsFromBranch = (node as DOMElement & { _copyFragments?: CachedFragment[] })._copyFragments
const rect = { x, y, width, height, top: yogaTop, ...(fragmentsFromBranch && { fragments: fragmentsFromBranch }) }
// Clear after consuming so a future render that has no fragments
// doesn't see stale data.
if (fragmentsFromBranch) {
;(node as DOMElement & { _copyFragments?: CachedFragment[] })._copyFragments = undefined
}
nodeCache.set(node, rect)
if (node.style.position === 'absolute') {
@@ -1555,6 +1597,135 @@ function dropSubtreeCache(node: DOMElement): void {
}
// Exported for testing
export { applyStylesToWrappedText, buildCharToSegmentMap }
export { applyStylesToWrappedText, buildCharToSegmentMap, computeFragmentsForWrappedText }
/**
* Compute per-row CachedFragment[] for an ink-text that has wrapped
* across multiple visual rows. Each segment carrying `copySourceFragment`
* emits one entry per visual row it touches.
*
* The `start`/`end` fields on each emitted fragment are the source byte
* range corresponding to JUST THIS ROW'S slice of the segment:
*
* - For verbatim segments (rendered == source 1:1): `start` is the
* segment's source-start plus the segment-relative offset where this
* row begins; `end` is start + this-row's plain-text width. The
* hit-test then maps within-row col linearly to source bytes via
* `start + col` (clamped to end).
*
* - For non-verbatim (formatted) segments: `start`/`end` are the WHOLE
* segment's source byte range on every row. The hit-test snaps clicks
* to start or end based on which half of the on-row width was hit,
* so per-row identical bounds yields the same behavior — selections
* inside formatted spans land on the span's source boundaries
* regardless of which wrap-row was clicked. (Slicing source bytes
* proportionally per row for formatted spans would be wrong: clicking
* mid-row of a wrapped `$\sum_{i=1}^{n}$` math span has no defined
* source-byte for that cell because the rendered glyph and source
* char counts differ.)
*
* Mirrors `applyStylesToWrappedText`'s charIndex bookkeeping so the
* visual-cell ↔ source-char mapping stays exact through wrap-trim
* whitespace eating and through hard newlines in the original.
*/
function computeFragmentsForWrappedText(
wrappedPlain: string,
segments: readonly StyledSegment[],
charToSegment: readonly number[],
originalPlain: string,
trimEnabled: boolean
): CachedFragment[] {
const out: CachedFragment[] = []
const lines = wrappedPlain.split('\n')
// Pre-compute each segment's char-start in originalPlain so we can
// convert (segment, charIndex) → segment-relative offset → source byte.
const segPlainStarts: number[] = []
{
let acc = 0
for (const seg of segments) {
segPlainStarts.push(acc)
acc += seg.text.length
}
}
let charIndex = 0
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
const line = lines[lineIdx]!
let runStart = 0
let runSegIdx = charToSegment[charIndex] ?? 0
let runCharStart = charIndex
const flushRun = (visualEnd: number): void => {
if (visualEnd <= runStart) {
return
}
const seg = segments[runSegIdx]
const tag = seg?.copySourceFragment
if (tag) {
let start: number
let end: number
if (tag.verbatim) {
// For verbatim segments, the on-row source slice is the
// segment-relative offset (runCharStart - segPlainStart) +
// segment-source-start, length = visualEnd - runStart.
const segPlainStart = segPlainStarts[runSegIdx] ?? 0
const offsetInSeg = runCharStart - segPlainStart
const runLen = visualEnd - runStart
start = tag.start + offsetInSeg
end = Math.min(tag.end, start + runLen)
} else {
// Formatted segments use whole-segment bounds; the snap rule
// in copyPointAt picks start vs end based on which half of
// the on-row width was clicked.
start = tag.start
end = tag.end
}
out.push({
row: lineIdx,
colStart: runStart,
colEnd: visualEnd,
start,
end,
verbatim: tag.verbatim
})
}
}
for (let i = 0; i < line.length; i++) {
const curSeg = charToSegment[charIndex] ?? runSegIdx
if (curSeg !== runSegIdx) {
flushRun(i)
runStart = i
runSegIdx = curSeg
runCharStart = charIndex
}
charIndex++
}
flushRun(line.length)
// Skip the inter-line char in originalPlain (real \n or wrap-trim
// whitespace) — same logic as applyStylesToWrappedText.
if (charIndex < originalPlain.length && originalPlain[charIndex] === '\n') {
charIndex++
} else if (trimEnabled && lineIdx < lines.length - 1 && /\s/.test(originalPlain[charIndex] ?? '')) {
charIndex++
}
}
return out
}
export default renderNodeToOutput

View File

@@ -101,6 +101,16 @@ export const forceRedraw = (stdout: NodeJS.WriteStream = process.stdout): boolea
return true
}
/**
* Look up the live Ink instance for a given output stream. Returns null if
* no Ink is mounted for that stdout. Used by host code (TUI) to install
* copy-text overrides + read selection state for the transcript-virtual
* copy-source pipeline.
*/
export const getInkForStdout = (stdout: NodeJS.WriteStream = process.stdout): Ink | null => {
return instances.get(stdout) ?? null
}
/**
* Mount a component and render the output.
*/

View File

@@ -1,27 +1,43 @@
import type { DOMElement } from './dom.js'
import type { TextStyles } from './styles.js'
import type { Styles, TextStyles } from './styles.js'
/**
* A segment of text with its associated styles.
* Used for structured rendering without ANSI string transforms.
*
* `copySourceFragment` is propagated from the deepest enclosing
* `<ink-virtual-text>` (or `<ink-text>`) that carries one; this lets
* the renderer attach per-segment source-byte ranges to the ink-text's
* cached layout for the copy hit-test to use.
*/
export type StyledSegment = {
text: string
styles: TextStyles
hyperlink?: string
copySourceFragment?: Styles['copySourceFragment']
}
/**
* Squash text nodes into styled segments, propagating styles down through the tree.
* This allows structured styling without relying on ANSI string transforms.
* Squash text nodes into styled segments, propagating styles (and the
* per-segment `copySourceFragment` tag) down through the tree. Allows
* structured styling without ANSI string transforms.
*
* Fragment inheritance: a child's fragment OVERRIDES its parent's. This
* matches MdInline's behavior — nested formatting (e.g. bold containing
* inline math) emits a single outer fragment for the bold-source span
* AND inner fragments for the math-source span; the inner ones are what
* the user sees and clicks, so they win.
*/
export function squashTextNodesToSegments(
node: DOMElement,
inheritedStyles: TextStyles = {},
inheritedHyperlink?: string,
inheritedFragment?: Styles['copySourceFragment'],
out: StyledSegment[] = []
): StyledSegment[] {
const mergedStyles = node.textStyles ? { ...inheritedStyles, ...node.textStyles } : inheritedStyles
const ownFragment = (node.style as { copySourceFragment?: Styles['copySourceFragment'] }).copySourceFragment
const effectiveFragment = ownFragment ?? inheritedFragment
for (const childNode of node.childNodes) {
if (childNode === undefined) {
@@ -33,14 +49,15 @@ export function squashTextNodesToSegments(
out.push({
text: childNode.nodeValue,
styles: mergedStyles,
hyperlink: inheritedHyperlink
hyperlink: inheritedHyperlink,
...(effectiveFragment && { copySourceFragment: effectiveFragment })
})
}
} else if (childNode.nodeName === 'ink-text' || childNode.nodeName === 'ink-virtual-text') {
squashTextNodesToSegments(childNode, mergedStyles, inheritedHyperlink, out)
squashTextNodesToSegments(childNode, mergedStyles, inheritedHyperlink, effectiveFragment, out)
} else if (childNode.nodeName === 'ink-link') {
const href = childNode.attributes['href'] as string | undefined
squashTextNodesToSegments(childNode, mergedStyles, href || inheritedHyperlink, out)
squashTextNodesToSegments(childNode, mergedStyles, href || inheritedHyperlink, effectiveFragment, out)
}
}

View File

@@ -396,6 +396,48 @@ export type Styles = {
* doesn't pick up leading whitespace from middle rows.
*/
readonly noSelect?: boolean | 'from-left-edge'
/**
* Source-range id from the copySource registry.
*
* When set on an ink-box, the rendered DOMElement carries this rangeId
* on its `attributes` and the copy-source hitTest can map (col, row)
* mouse positions back to a transcript-virtual SelectionPoint. The Box
* itself has no visual effect from this prop — it's purely a data
* carrier for the selection/copy pipeline (mirrors how `noSelect` only
* affects selection extraction, not visual rendering).
*/
readonly copyRangeId?: number
/**
* Per-segment source byte range, when the block-level CopySource isn't
* fine-grained enough to map a click back to source bytes.
*
* Used by markdown inline rendering (MdInline): each `<Text>` produced
* by the inline regex dispatcher (link, bold, math, code, etc.) wraps
* in `<Box copySourceFragment={{start, end, verbatim}}>` so the
* hit-test can return the exact source byte the user clicked, without
* needing width-math at the block level.
*
* `start`/`end` are byte offsets RELATIVE TO the enclosing
* `copyRangeId`'s outerSource. The hit-test resolves up the DOM:
* fragment found → use fragment.start + clampedCol when verbatim,
* snap to fragment.start/end otherwise; no fragment → fall through to
* the block's `getOffset`.
*
* `verbatim` is true when the rendered text width matches the source
* byte length character-for-character (plain text between markdown
* tokens, code spans). For those, col-within-fragment maps directly
* to source byte = start + col. False for tokens where rendered
* cells ≠ source bytes (`**bold**`, `$math$`, `[link](url)`); for
* those, partial-fragment clicks snap to the nearer of start/end so
* selections don't slice mid-glyph.
*/
readonly copySourceFragment?: {
readonly start: number
readonly end: number
readonly verbatim: boolean
}
}
const applyPositionStyles = (node: LayoutNode, style: Styles): void => {

View File

@@ -310,7 +310,10 @@ let linuxCopy: 'wl-copy' | 'xclip' | 'xsel' | null | undefined
/** Internal: probe once and cache — wl-copy first, then xclip, then xsel. */
async function probeLinuxCopy(): Promise<'wl-copy' | 'xclip' | 'xsel' | null> {
const opts = { useCwd: false, timeout: 500 }
// resolveOnExit: wl-copy daemonizes and the daemon inherits stdio pipes,
// so 'close' never fires and the await would hang past the timeout.
// 'exit' fires on the immediate child's exit — what we actually care about.
const opts = { useCwd: false, timeout: 500, resolveOnExit: true }
const r = await execFileNoThrow('wl-copy', [], opts)
@@ -347,7 +350,11 @@ async function probeLinuxCopy(): Promise<'wl-copy' | 'xclip' | 'xsel' | null> {
* we skip probing entirely and treat linuxCopy as permanently null.
*/
function copyNative(text: string): boolean {
const opts = { input: text, useCwd: false, timeout: 2000 }
// resolveOnExit: pbcopy/wl-copy/xclip/xsel/clip all daemonize or hold
// the system selection live in a forked process. Without resolveOnExit,
// the inherited stdio pipes keep node from seeing 'close' → the
// fire-and-forget await never resolves and the actual copy never runs.
const opts = { input: text, useCwd: false, timeout: 2000, resolveOnExit: true }
switch (process.platform) {
case 'darwin':

View File

@@ -0,0 +1,111 @@
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { logForDebugging } from './debug.js'
let logDir: string
let logPath: string
const ENV_KEYS = [
'HERMES_TUI_DEBUG',
'HERMES_TUI_DEBUG_CLIPBOARD',
'HERMES_TUI_DEBUG_INPUT',
'HERMES_TUI_DEBUG_RENDER',
'HERMES_TUI_DEBUG_SELECTION',
'HERMES_TUI_DEBUG_LOG'
]
beforeEach(() => {
logDir = join(tmpdir(), `hermes-debug-test-${process.pid}-${Date.now()}`)
mkdirSync(logDir, { recursive: true })
logPath = join(logDir, 'tui-stderr.log')
// Clean slate every test — env vars from this test must not leak
// into the next, and vice versa.
for (const k of ENV_KEYS) {
delete process.env[k]
}
})
afterEach(() => {
for (const k of ENV_KEYS) {
delete process.env[k]
}
rmSync(logDir, { recursive: true, force: true })
})
describe('logForDebugging', () => {
it('drops messages on the floor when no debug flag is set', () => {
process.env.HERMES_TUI_DEBUG_LOG = logPath
logForDebugging('should not appear')
expect(existsSync(logPath)).toBe(false)
})
it('writes to HERMES_TUI_DEBUG_LOG when HERMES_TUI_DEBUG=1', () => {
process.env.HERMES_TUI_DEBUG = '1'
process.env.HERMES_TUI_DEBUG_LOG = logPath
logForDebugging('hello world')
const contents = readFileSync(logPath, 'utf8')
expect(contents).toMatch(/\[info\] hello world/)
})
it('honors level option', () => {
process.env.HERMES_TUI_DEBUG = '1'
process.env.HERMES_TUI_DEBUG_LOG = logPath
logForDebugging('something went wrong', { level: 'error' })
const contents = readFileSync(logPath, 'utf8')
expect(contents).toMatch(/\[error\] something went wrong/)
})
it('activates on any HERMES_TUI_DEBUG_* flag', () => {
process.env.HERMES_TUI_DEBUG_CLIPBOARD = '1'
process.env.HERMES_TUI_DEBUG_LOG = logPath
logForDebugging('clipboard probe done')
const contents = readFileSync(logPath, 'utf8')
expect(contents).toMatch(/clipboard probe done/)
})
it('appends rather than overwriting', () => {
process.env.HERMES_TUI_DEBUG = '1'
process.env.HERMES_TUI_DEBUG_LOG = logPath
logForDebugging('first')
logForDebugging('second')
const lines = readFileSync(logPath, 'utf8').trim().split('\n')
expect(lines).toHaveLength(2)
expect(lines[0]).toMatch(/first/)
expect(lines[1]).toMatch(/second/)
})
it('prefixes each line with an ISO timestamp', () => {
process.env.HERMES_TUI_DEBUG = '1'
process.env.HERMES_TUI_DEBUG_LOG = logPath
logForDebugging('marker')
const contents = readFileSync(logPath, 'utf8').trim()
// ISO 8601 prefix: 2026-05-11T22:30:45.123Z
expect(contents).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z /)
})
it('does not throw when the log directory cannot be created', () => {
process.env.HERMES_TUI_DEBUG = '1'
// Path under /proc/1 is read-only on Linux — unwritable for tests.
// Falls back to silent failure rather than crashing the TUI.
process.env.HERMES_TUI_DEBUG_LOG = '/proc/1/cant-write-here.log'
expect(() => logForDebugging('boom')).not.toThrow()
})
})

View File

@@ -1,6 +1,93 @@
import { appendFileSync, mkdirSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
/**
* Capture stderr/console.error writes that the alt-screen patcher would
* otherwise drop on the floor.
*
* Why this exists: ink's `patchStderr()` rewrites `process.stderr.write`
* to call `logForDebugging()` so stray writes don't corrupt the
* alt-screen diff buffer. Historically this function was a no-op, which
* meant `console.error` (and any module that wrote diagnostics to stderr)
* was completely silent inside the TUI. That made bugs like the wl-copy
* daemonization hang impossible to diagnose without rebuilding and
* trial-and-erroring with strategic edits.
*
* Now: when `HERMES_TUI_DEBUG=1` (or any of the more specific
* `HERMES_TUI_DEBUG_*` flags) is set, drop messages into
* `~/.hermes/logs/tui-stderr.log`. Best-effort — failures are swallowed
* because we'd rather lose a debug message than crash the TUI.
*
* Override the destination via `HERMES_TUI_DEBUG_LOG=<path>` when you
* want a one-off log file (e.g. `/tmp/clip.log`).
*/
const HERMES_DEBUG_FLAGS = [
'HERMES_TUI_DEBUG',
'HERMES_TUI_DEBUG_CLIPBOARD',
'HERMES_TUI_DEBUG_INPUT',
'HERMES_TUI_DEBUG_RENDER',
'HERMES_TUI_DEBUG_SELECTION'
]
function isDebugEnabled(): boolean {
for (const flag of HERMES_DEBUG_FLAGS) {
if (process.env[flag]) {
return true
}
}
return false
}
function resolveLogPath(): string {
const override = process.env.HERMES_TUI_DEBUG_LOG?.trim()
if (override) {
return override
}
return join(homedir(), '.hermes', 'logs', 'tui-stderr.log')
}
let logPathReady = false
function ensureLogPath(path: string): void {
if (logPathReady) {
return
}
try {
mkdirSync(dirname(path), { recursive: true })
logPathReady = true
} catch {
// Best-effort — a missing/unwritable parent dir means we'll try
// appendFileSync below and silently lose this message. Caller is
// already in an error path; we don't surface the issue.
}
}
export function logForDebugging(
_message: string,
_options: {
message: string,
options: {
level?: string
} = {}
): void {}
): void {
if (!isDebugEnabled()) {
return
}
const path = resolveLogPath()
ensureLogPath(path)
const level = options.level ?? 'info'
const ts = new Date().toISOString()
const line = `${ts} [${level}] ${message}\n`
try {
appendFileSync(path, line)
} catch {
// Lost message — the alternative is crashing the TUI from a logger.
}
}

View File

@@ -0,0 +1,110 @@
import { chmodSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { execFileNoThrow } from './execFileNoThrow.js'
// We simulate `wl-copy`'s daemonization behavior with a tiny shell script:
// 1. Fork a long-lived background sleeper that inherits stdio (so the
// parent process's pipes can never close).
// 2. Exit immediately with status 0.
//
// Without resolveOnExit, the await on `'close'` hangs until SIGTERM at
// timeout — exactly the production wl-copy bug. With resolveOnExit, the
// promise settles on `'exit'` regardless of the inherited pipes.
let scriptDir: string
let daemonScript: string
beforeEach(() => {
scriptDir = join(tmpdir(), `hermes-execfile-test-${process.pid}-${Date.now()}`)
mkdirSync(scriptDir, { recursive: true })
daemonScript = join(scriptDir, 'fake-daemonizer.sh')
// Posix sh: the `sleep 30 &` child inherits stdin/stdout/stderr from the
// shell, which inherited them from `spawn(stdio: 'pipe')`. The shell
// exits but its child (the sleeper) keeps the pipes open. Mirrors how
// wl-copy double-forks then exits while the daemon holds the selection.
writeFileSync(daemonScript, '#!/bin/sh\nsleep 30 &\nexit 0\n')
chmodSync(daemonScript, 0o755)
})
afterEach(() => {
rmSync(scriptDir, { recursive: true, force: true })
})
describe('execFileNoThrow with daemon-style children', () => {
// Skipped because the bug it documents is a forever-hang. Without
// resolveOnExit, the 'close' event doesn't fire when the immediate
// child has exited but a forked daemon still holds stdio open. Even
// SIGTERM at the timeout doesn't help — the daemon survives it. To
// verify by hand: remove `it.skip` and watch the test timeout. This
// test is here so a reviewer reading the resolveOnExit option knows
// *why* every clipboard-tool spawn in osc.ts wires it on.
it.skip("(documented hang) without resolveOnExit, await never resolves when daemon inherits stdio", async () => {
const result = await execFileNoThrow(daemonScript, [], { timeout: 300 })
expect(result.code).toBe(124)
})
it("settles immediately on 'exit' when resolveOnExit is true, regardless of daemon stdio", async () => {
const start = Date.now()
const result = await execFileNoThrow(daemonScript, [], {
timeout: 2000,
resolveOnExit: true
})
const elapsed = Date.now() - start
// The shell exits in a few ms. resolveOnExit lets us return on exit
// (code 0) instead of waiting for the orphaned sleeper to release
// stdio. Should be well under 200ms even on slow CI.
expect(result.code).toBe(0)
expect(elapsed).toBeLessThan(500)
})
it("still surfaces the right code when resolveOnExit'd child exits non-zero", async () => {
const failScript = join(scriptDir, 'fail.sh')
writeFileSync(failScript, '#!/bin/sh\nsleep 30 &\nexit 7\n')
chmodSync(failScript, 0o755)
const result = await execFileNoThrow(failScript, [], {
timeout: 2000,
resolveOnExit: true
})
expect(result.code).toBe(7)
})
it('settles on timeout=124 when the child itself never exits, even with resolveOnExit', async () => {
const slowScript = join(scriptDir, 'slow.sh')
writeFileSync(slowScript, '#!/bin/sh\nsleep 30\n')
chmodSync(slowScript, 0o755)
const result = await execFileNoThrow(slowScript, [], {
timeout: 200,
resolveOnExit: true
})
// Child process never exits on its own → timer fires → SIGTERM →
// child exits → 'exit' fires with non-null signal. The settle()
// call from the timer registers code=124 first. Either way: 124.
expect(result.code).toBe(124)
})
it('does not double-resolve when both timer and exit fire', async () => {
// Race: child happens to exit right around the timeout. The settled
// guard ensures only the first resolution wins.
const result = await execFileNoThrow(daemonScript, [], {
timeout: 50, // very tight
resolveOnExit: true
})
// Either code=0 (exit beat timer) or code=124 (timer beat exit).
// Both are valid outcomes; the contract is that the promise settles
// exactly once and doesn't throw.
expect([0, 124]).toContain(result.code)
})
})

View File

@@ -4,6 +4,14 @@ type ExecFileOptions = {
timeout?: number
useCwd?: boolean
env?: NodeJS.ProcessEnv
/** Resolve as soon as the child *exits*, instead of waiting for its
* stdio streams to close. Use this for tools that fork a daemon and
* let the daemon inherit the parent's stdio (e.g. `wl-copy`): the
* child exits immediately, but `'close'` never fires because the
* daemon holds the pipes open. The caller must not depend on
* collecting stdout/stderr from that point on — only what arrived
* before exit is included in the resolved value. */
resolveOnExit?: boolean
}
export function execFileNoThrow(
@@ -26,11 +34,33 @@ export function execFileNoThrow(
let stdout = ''
let stderr = ''
let timedOut = false
let settled = false
const settle = (code: number, error?: string) => {
if (settled) {
return
}
settled = true
if (timer) {
clearTimeout(timer)
}
resolve({ stdout, stderr, code, ...(error ? { error } : {}) })
}
const timer = options.timeout
? setTimeout(() => {
timedOut = true
child.kill('SIGTERM')
// When resolving on exit, SIGTERM-ing a child that has already
// exited is a no-op and `'exit'` won't fire again — settle here
// so the promise doesn't leak. Safe under settled-guard.
if (options.resolveOnExit) {
settle(124)
}
}, options.timeout)
: null
@@ -41,19 +71,20 @@ export function execFileNoThrow(
stderr += String(chunk)
})
child.on('error', error => {
if (timer) {
clearTimeout(timer)
}
resolve({ stdout, stderr, code: 1, error: String(error) })
settle(1, String(error))
})
child.on('close', code => {
if (timer) {
clearTimeout(timer)
}
resolve({ stdout, stderr, code: timedOut ? 124 : (code ?? 0) })
})
if (options.resolveOnExit) {
// 'exit' fires when the child process itself exits — even if the
// daemon it forked still holds the inherited stdio pipes open.
child.on('exit', code => {
settle(timedOut ? 124 : (code ?? 0))
})
} else {
child.on('close', code => {
settle(timedOut ? 124 : (code ?? 0))
})
}
if (options.input) {
child.stdin?.write(options.input)

View File

@@ -2,9 +2,11 @@ import { PassThrough } from 'stream'
import { Box, renderSync } from '@hermes/ink'
import React from 'react'
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'
import { AUDIO_DIRECTIVE_RE, INLINE_RE, Md, MEDIA_LINE_RE, stripInlineMarkup } from '../components/markdown.js'
import { listRanges, resetRegistry } from '../lib/copySource/registry.js'
import { toCopyText } from '../lib/copySource/toCopyText.js'
import { stripAnsi } from '../lib/text.js'
import { DEFAULT_THEME } from '../theme.js'
@@ -228,6 +230,16 @@ describe('Md wrapping', () => {
expect(lines.some(line => line.startsWith(' hi ok'))).toBe(true)
})
it('renders math content correctly', () => {
// Smoke test: rendering doesn't crash on the math example from
// ethie's bug report. Visual output is checked elsewhere.
const lines = renderPlain(
React.createElement(Md, { msgId: 'm1', t: DEFAULT_THEME, text: 'inline: $E = mc^2$ or done' })
)
expect(lines.join('\n')).toContain('or done')
})
it('renders Python dunder identifiers literally outside code fences', () => {
const lines = renderPlain(
React.createElement(
@@ -247,6 +259,65 @@ describe('Md wrapping', () => {
})
})
describe('Md copy-source fragments', () => {
// These tests exercise the inline-fragment plumbing end-to-end:
// render a paragraph with markdown formatting, then verify the
// registered CopySource range's outerSource matches the raw source
// and the rendered tree carries copySourceFragment style props on
// its segments (so the Ink hit-test will find byte-exact mappings).
beforeEach(() => {
resetRegistry()
})
it('paragraph with inline math: each segment registers a fragment', () => {
const source = 'inline: $E = mc^2$ or done'
renderPlain(
React.createElement(Md, { msgId: 'fragment-msg', t: DEFAULT_THEME, text: source })
)
const ranges = listRanges()
const block = ranges.find(r => r.msgId === 'fragment-msg' && r.blockIndex >= 1)
expect(block).toBeDefined()
expect(block!.outerSource).toBe(source)
// The math span `$E = mc^2$` occupies source bytes [8, 18]. A
// synthetic in-range SelectionPoint pointing at sourceOffset=8
// (just past "inline: ") would copy from there forward — verifying
// toCopyText honors precomputed source offsets from the hit-test.
const copied = toCopyText({
anchor: { kind: 'in-range', rangeId: block!.id, visualLine: 0, col: 0, sourceOffset: 8 },
focus: { kind: 'after-all' },
transcript: [{ id: 'fragment-msg', order: 0 }]
})
expect(copied).toBe('$E = mc^2$ or done')
})
it('sourceOffset=0 anchor + post-math focus copies bytes [0..N]', () => {
const source = 'inline: $E = mc^2$ or done'
renderPlain(
React.createElement(Md, { msgId: 'fragment-msg-2', t: DEFAULT_THEME, text: source })
)
const ranges = listRanges()
const block = ranges.find(r => r.msgId === 'fragment-msg-2' && r.blockIndex >= 1)!
// Anchor at start of "inline:". Focus at "or" via sourceOffset=22.
// Should yield 'inline: $E = mc^2$ or' — byte-exact even across the
// formatted math span.
const copied = toCopyText({
anchor: { kind: 'in-range', rangeId: block.id, visualLine: 0, col: 0, sourceOffset: 0 },
focus: { kind: 'in-range', rangeId: block.id, visualLine: 0, col: 0, sourceOffset: 21 },
transcript: [{ id: 'fragment-msg-2', order: 0 }]
})
expect(copied).toBe('inline: $E = mc^2$ or')
})
})
describe('Md link labels', () => {
it('renders bare URLs with readable slug labels', () => {
const lines = renderPlain(

View File

@@ -1,4 +1,4 @@
import { useApp, useHasSelection, useSelection, useStdout, useTerminalTitle, type ScrollBoxHandle } from '@hermes/ink'
import { getInkForStdout, type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -16,6 +16,9 @@ import type {
} from '../gatewayTypes.js'
import { useGitBranch } from '../hooks/useGitBranch.js'
import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
import { makeCopyTextFn } from '../lib/copySource/buildCopyTextFromDom.js'
import { evictMessage } from '../lib/copySource/registry.js'
import type { MsgSnapshot } from '../lib/copySource/types.js'
import { composerPromptWidth } from '../lib/inputMetrics.js'
import { appendTranscriptMessage } from '../lib/messages.js'
import { DEFAULT_VOICE_RECORD_KEY, isMac, type ParsedVoiceRecordKey } from '../lib/platform.js'
@@ -238,6 +241,67 @@ export function useMainApp(gw: GatewayClient) {
[historyItems, messageId]
)
// ── Copy-source pipeline wiring ────────────────────────────────────────
// The transcript-virtual copy-source registry needs three things from
// the host (`useMainApp`):
// 1. A getter for the current msg ordering, so toCopyText can sort
// ranges in document order.
// 2. Eviction of registry entries whose msgs have been popped from
// history (history-cap, /undo, /clear). Without this, stale
// ranges accumulate forever.
// 3. Installation of the copy-text override on the live Ink instance,
// once at mount, so ctrl-c uses the transcript-virtual pipeline
// instead of cell extraction.
//
// The transcriptRef + makeCopyTextFn pattern decouples the closure-
// captured transcript at install time from the live transcript at copy
// time — without the ref the override would always see the empty
// initial array.
const transcriptRef = useRef<MsgSnapshot[]>([])
// Keep transcriptRef in sync with the latest virtualRows. Runs in an
// effect rather than the render body so react-compiler is happy (writing
// to a ref outside an effect is a foot-gun in concurrent React even
// though refs don't trigger re-renders).
useEffect(() => {
transcriptRef.current = virtualRows.map((row, idx) => ({ id: row.key, order: idx }))
}, [virtualRows])
// Track which msgIds are currently mounted so eviction fires for the
// delta on each render. Plain string Set is enough — msgIds are stable
// strings allocated by `messageId()`.
const liveMsgIdsRef = useRef<Set<string>>(new Set())
useEffect(() => {
const current = new Set(virtualRows.map(r => r.key))
for (const id of liveMsgIdsRef.current) {
if (!current.has(id)) {
evictMessage(id)
}
}
liveMsgIdsRef.current = current
}, [virtualRows])
// One-time install of the copy-text override on the Ink instance. The
// override reads transcriptRef so it always sees the latest msg list
// even though it's installed only once.
useEffect(() => {
const ink = getInkForStdout(stdout)
if (!ink) {
return
}
const fn = makeCopyTextFn(() => transcriptRef.current)
ink.setCopyTextFn(fn)
return () => {
ink.setCopyTextFn(null)
}
}, [stdout])
const detailsLayoutKey = useMemo(() => {
const thinking = sectionMode('thinking', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride)
const tools = sectionMode('tools', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride)
@@ -736,10 +800,13 @@ export function useMainApp(gw: GatewayClient) {
const anyPanelVisible = SECTION_NAMES.some(
s => sectionMode(s, ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
)
const thinkingPanelVisible =
sectionMode('thinking', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
const toolsPanelVisible =
sectionMode('tools', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
const activityPanelVisible =
sectionMode('activity', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'

View File

@@ -125,6 +125,7 @@ const TranscriptPane = memo(function TranscriptPane({
detailsMode={ui.detailsMode}
detailsModeCommandOverride={ui.detailsModeCommandOverride}
msg={row.msg}
msgId={row.key}
sections={ui.sections}
t={ui.theme}
/>

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,12 @@
import { Ansi, Box, NoSelect, Text } from '@hermes/ink'
import { memo, useState } from 'react'
import { memo, type ReactNode, useState } from 'react'
import { LONG_MSG } from '../config/limits.js'
import { sectionMode } from '../domain/details.js'
import { userDisplay } from '../domain/messages.js'
import { ROLE } from '../domain/roles.js'
import { CopySource } from '../lib/copySource/CopySource.js'
import { buildLineStartsFromRows, simpleOffsetFor } from '../lib/copySource/offsetMaps.js'
import { transcriptBodyWidth, transcriptGutterWidth } from '../lib/inputMetrics.js'
import {
boundedLiveRenderText,
@@ -32,6 +34,7 @@ export const MessageLine = memo(function MessageLine({
detailsModeCommandOverride = false,
isStreaming = false,
msg,
msgId,
sections,
t,
tools = []
@@ -69,6 +72,7 @@ export const MessageLine = memo(function MessageLine({
<ToolTrail
commandOverride={detailsModeCommandOverride}
detailsMode={detailsMode}
msgId={msgId}
reasoning={thinking}
reasoningTokens={msg.thinkingTokens}
sections={sections}
@@ -87,17 +91,19 @@ export const MessageLine = memo(function MessageLine({
const safeAnsi = hasAnsi(msg.text) ? sanitizeAnsiForRender(msg.text) : msg.text
const preview = compactPreview(stripped, maxChars) || '(empty tool result)'
const previewNode = hasAnsi(msg.text) ? (
<Text wrap="truncate-end">
<Ansi>{safeAnsi}</Ansi>
</Text>
) : (
<Text color={t.color.muted} wrap="truncate-end">
{preview}
</Text>
)
return (
<Box alignSelf="flex-start" borderColor={t.color.muted} borderStyle="round" marginLeft={3} paddingX={1}>
{hasAnsi(msg.text) ? (
<Text wrap="truncate-end">
<Ansi>{safeAnsi}</Ansi>
</Text>
) : (
<Text color={t.color.muted} wrap="truncate-end">
{preview}
</Text>
)}
{wrapCopySource(msgId, msg.text, previewNode)}
</Box>
)
}
@@ -110,7 +116,7 @@ export const MessageLine = memo(function MessageLine({
const content = (() => {
if (msg.kind === 'slash') {
return <Text color={t.color.muted}>{msg.text}</Text>
return wrapCopySource(msgId, msg.text, <Text color={t.color.muted}>{msg.text}</Text>)
}
// ── Collapsible long system message (system prompt, AGENTS.md, etc.) ──
@@ -129,13 +135,13 @@ export const MessageLine = memo(function MessageLine({
{msg.text.length.toLocaleString()} chars
</Text>
</Box>
{systemOpen && <Ansi>{sanitizeAnsiForRender(msg.text)}</Ansi>}
{systemOpen && wrapCopySource(msgId, msg.text, <Ansi>{sanitizeAnsiForRender(msg.text)}</Ansi>)}
</Box>
)
}
if (msg.role !== 'user' && hasAnsi(msg.text)) {
return <Ansi>{sanitizeAnsiForRender(msg.text)}</Ansi>
return wrapCopySource(msgId, msg.text, <Ansi>{sanitizeAnsiForRender(msg.text)}</Ansi>)
}
if (msg.role === 'assistant') {
@@ -145,16 +151,22 @@ export const MessageLine = memo(function MessageLine({
// Incremental markdown: split at the last stable block boundary so
// only the in-flight tail re-tokenizes per delta. See
// streamingMarkdown.tsx for the cost model.
<StreamingMd cols={bodyWidth} compact={compact} t={t} text={boundedLiveRenderText(msg.text)} />
<StreamingMd cols={bodyWidth} compact={compact} msgId={msgId} t={t} text={boundedLiveRenderText(msg.text)} />
) : (
<Md cols={bodyWidth} compact={compact} t={t} text={msg.text} />
<Md cols={bodyWidth} compact={compact}
msgId={msgId}
t={t}
text={msg.text}
/>
)
}
if (msg.role === 'user' && msg.text.length > LONG_MSG && isPasteBackedText(msg.text)) {
const [head, ...rest] = userDisplay(msg.text).split('[long message]')
return (
return wrapCopySource(
msgId,
msg.text,
<Text color={body}>
{head}
<Text color={t.color.muted} dimColor>
@@ -165,7 +177,7 @@ export const MessageLine = memo(function MessageLine({
)
}
return <Text {...(body ? { color: body } : {})}>{msg.text}</Text>
return wrapCopySource(msgId, msg.text, <Text {...(body ? { color: body } : {})}>{msg.text}</Text>)
})()
// Diff segments (emitted by pushInlineDiffSegment between narration
@@ -184,6 +196,7 @@ export const MessageLine = memo(function MessageLine({
<ToolTrail
commandOverride={detailsModeCommandOverride}
detailsMode={detailsMode}
msgId={msgId}
reasoning={thinking}
reasoningTokens={msg.thinkingTokens}
sections={sections}
@@ -214,7 +227,50 @@ interface MessageLineProps {
detailsModeCommandOverride?: boolean
isStreaming?: boolean
msg: Msg
/** Stable id used to anchor copy-source ranges in the registry. When
* unset, the message isn't covered by the copy-source pipeline — its
* text won't survive partial-selection round-trip. Set this for any
* message in the transcript that the user might copy. Trail / intro /
* panel messages don't need it (no copyable body text). */
msgId?: string
sections?: SectionVisibility
t: Theme
tools?: ActiveTool[]
}
/**
* Wrap a rendered node in a whole-message CopySource so partial selection
* of plain (non-markdown) message content round-trips the raw source text.
*
* blockIndex=0 is reserved for whole-msg ranges (markdown blocks use ≥1
* via Md's `blockIndexBase`). visualLineCount = source line count; the
* simple offset map maps each visual row (relative to the wrapping box)
* to the byte offset of the corresponding source line. Soft-wrap
* continuations at the Ink layer fall past `visualLineCount`, which
* clamps to `outerSource.length` — copying a selection that ends inside
* a soft-wrapped continuation snaps to the end of that source line.
*
* When `msgId` is undefined (trail / intro / panel etc.), returns the
* raw node — those msgs aren't covered by the copy pipeline and don't
* need to be.
*/
function wrapCopySource(msgId: string | undefined, source: string, node: ReactNode): ReactNode {
if (!msgId) {
return node
}
const lineRows = source.split('\n')
const rowStarts = buildLineStartsFromRows(lineRows)
return (
<CopySource
blockIndex={0}
getOffset={simpleOffsetFor(source, rowStarts)}
msgId={msgId}
outerSource={source}
visualLineCount={Math.max(1, lineRows.length)}
>
{node}
</CopySource>
)
}

View File

@@ -42,6 +42,7 @@ export const StreamingAssistant = memo(function StreamingAssistant({
detailsModeCommandOverride={detailsModeCommandOverride}
key={`seg:${i}`}
msg={msg}
msgId={`streaming:seg:${i}`}
sections={sections}
t={ui.theme}
/>
@@ -72,6 +73,7 @@ export const StreamingAssistant = memo(function StreamingAssistant({
text: streaming,
...(streamPendingTools.length && { tools: streamPendingTools })
}}
msgId="streaming:live"
sections={sections}
t={ui.theme}
/>

View File

@@ -128,7 +128,7 @@ export const findStableBoundary = (text: string) => {
return -1
}
export const StreamingMd = memo(function StreamingMd({ cols, compact, t, text }: StreamingMdProps) {
export const StreamingMd = memo(function StreamingMd({ cols, compact, msgId, t, text }: StreamingMdProps) {
const stablePrefixRef = useRef('')
// Reset if the text no longer starts with our recorded prefix (defensive;
@@ -150,18 +150,25 @@ export const StreamingMd = memo(function StreamingMd({ cols, compact, t, text }:
const stablePrefix = stablePrefixRef.current
const unstableSuffix = text.slice(stablePrefix.length)
// Suffix blockIndexBase is offset by SUFFIX_BLOCK_OFFSET so its blocks
// order AFTER the prefix's in document order, regardless of how many
// blocks the prefix has. 1_000_000 is comfortably above any realistic
// prefix block count (would need a million top-level markdown blocks
// in one message to collide; chat messages cap at thousands of lines).
const SUFFIX_BLOCK_OFFSET = 1_000_000
if (!stablePrefix) {
return <Md cols={cols} compact={compact} t={t} text={unstableSuffix} />
return <Md cols={cols} compact={compact} msgId={msgId} t={t} text={unstableSuffix} />
}
if (!unstableSuffix) {
return <Md cols={cols} compact={compact} t={t} text={stablePrefix} />
return <Md cols={cols} compact={compact} msgId={msgId} t={t} text={stablePrefix} />
}
return (
<Box flexDirection="column">
<Md cols={cols} compact={compact} t={t} text={stablePrefix} />
<Md cols={cols} compact={compact} t={t} text={unstableSuffix} />
<Md cols={cols} compact={compact} msgId={msgId} t={t} text={stablePrefix} />
<Md blockIndexBase={SUFFIX_BLOCK_OFFSET} cols={cols} compact={compact} msgId={msgId} t={t} text={unstableSuffix} />
</Box>
)
})
@@ -169,6 +176,11 @@ export const StreamingMd = memo(function StreamingMd({ cols, compact, t, text }:
interface StreamingMdProps {
cols?: number
compact?: boolean
/** Message id this stream belongs to. Threaded into both Md subtrees so
* the prefix and suffix blocks register under the same msgId in the
* copy-source registry. Selection that spans both halves copies the raw
* source seamlessly across the boundary. */
msgId?: string
t: Theme
text: string
}

View File

@@ -4,6 +4,8 @@ import spinners, { type BrailleSpinnerName } from 'unicode-animations'
import { THINKING_COT_MAX } from '../config/limits.js'
import { sectionMode } from '../domain/details.js'
import { CopySource } from '../lib/copySource/CopySource.js'
import { buildLineStartsFromRows, simpleOffsetFor } from '../lib/copySource/offsetMaps.js'
import {
buildSubagentTree,
fmtCost,
@@ -622,6 +624,7 @@ export const Thinking = memo(function Thinking({
active = false,
branch = 'last',
mode = 'truncated',
msgId,
rails = [],
reasoning,
streaming = false,
@@ -630,6 +633,11 @@ export const Thinking = memo(function Thinking({
active?: boolean
branch?: TreeBranch
mode?: ThinkingMode
/** Stable msg id for anchoring the reasoning text in the copy-source
* registry. When set, the rendered content is wrapped in a CopySource
* with blockIndex=-1 (reserved for thinking — kept negative so it
* orders BEFORE the assistant's reply blocks at blockIndex≥0). */
msgId?: string
rails?: TreeRails
reasoning: string
streaming?: boolean
@@ -647,31 +655,56 @@ export const Thinking = memo(function Thinking({
return null
}
return (
<TreeRow branch={branch} rails={rails} t={t}>
<Box flexDirection="column" flexGrow={1}>
{preview ? (
mode === 'full' ? (
lines.map((line, index) => (
<Text color={t.color.muted} key={index} wrap="wrap-trim">
{line || ' '}
{index === lines.length - 1 ? (
<StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
) : null}
</Text>
))
) : (
<Text color={t.color.muted} wrap="truncate-end">
{preview}
<StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
const content = (
<Box flexDirection="column" flexGrow={1}>
{preview ? (
mode === 'full' ? (
lines.map((line, index) => (
<Text color={t.color.muted} key={index} wrap="wrap-trim">
{line || ' '}
{index === lines.length - 1 ? (
<StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
) : null}
</Text>
)
))
) : (
<Text color={t.color.muted}>
<Text color={t.color.muted} wrap="truncate-end">
{preview}
<StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
</Text>
)}
</Box>
)
) : (
<Text color={t.color.muted}>
<StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
</Text>
)}
</Box>
)
// When we have an msgId, wrap the rendered content in a CopySource so
// ctrl-c over the thinking text gives the user the raw reasoning. Use
// blockIndex=-1 so this range sorts BEFORE any blockIndex≥0 (assistant
// reply blocks). outerSource is the full reasoning string — even when
// the rendered preview is truncated, copy returns the full text.
// visualLineCount tracks the rendered preview's row count so clicks
// on rendered rows resolve correctly; offset map is line-starts.
const wrapped = msgId ? (
<CopySource
blockIndex={-1}
getOffset={simpleOffsetFor(reasoning, buildLineStartsFromRows(reasoning.split('\n')))}
msgId={msgId}
outerSource={reasoning}
visualLineCount={Math.max(1, lines.length)}
>
{content}
</CopySource>
) : (
content
)
return (
<TreeRow branch={branch} rails={rails} t={t}>
{wrapped}
</TreeRow>
)
})
@@ -690,6 +723,7 @@ export const ToolTrail = memo(function ToolTrail({
busy = false,
commandOverride = false,
detailsMode = 'collapsed',
msgId,
outcome = '',
reasoningActive = false,
reasoning = '',
@@ -706,6 +740,10 @@ export const ToolTrail = memo(function ToolTrail({
busy?: boolean
commandOverride?: boolean
detailsMode?: DetailsMode
/** Stable msg id for anchoring copy-source ranges on the reasoning
* content. When set, the thinking text is wrapped in a CopySource so
* `ctrl-c` over expanded thinking returns the raw reasoning text. */
msgId?: string
outcome?: string
reasoningActive?: boolean
reasoning?: string
@@ -1029,6 +1067,7 @@ export const ToolTrail = memo(function ToolTrail({
active={reasoningActive}
branch="last"
mode="full"
msgId={msgId}
rails={rails}
reasoning={busy ? reasoning : cot}
streaming={busy && reasoningStreaming}

View File

@@ -0,0 +1,87 @@
/**
* React component that wraps content with a source-range association.
*
* Usage:
*
* <CopySource msgId={msg.id} blockIndex={0} outerSource={msg.text}>
* <Text>{rendered}</Text>
* </CopySource>
*
* The component:
* 1. Registers the range with the copySource registry on mount.
* 2. Renders its children inside an <ink-box copyRangeId={id}>, which
* causes the underlying DOMElement to carry the rangeId so the
* hit-test pipeline can map mouse coords back to a SelectionPoint.
* 3. Updates the registry's domNode pointer via a ref so hit-test can
* find the DOM node from a rangeId.
* 4. On unmount, clears the domNode pointer but DOES NOT evict the
* range from the registry — virtual-scroll unmounts and remounts
* should reuse the same rangeId. The host calls `evictMessage()`
* from the history-cap path when a message is dropped entirely.
*
* The component re-registers whenever `outerSource` / `innerSource` /
* `innerOffset` / `visualLineCount` / `getOffset` change so the
* registered range always reflects the current render.
*/
import { Box } from '@hermes/ink'
import { type ReactNode, useEffect, useRef } from 'react'
import { registerRange, setRangeDom } from './registry.js'
import type { RangeId, SourceRange } from './types.js'
export type CopySourceProps = {
children?: ReactNode
msgId: string
/** 0 for whole-msg, ≥1 for per-block. */
blockIndex: number
/** Full source including any wrapper (e.g. fence markers). */
outerSource: string
/** Body without wrapper. Defaults to `outerSource`. */
innerSource?: string
/** Byte offset of innerSource within outerSource. Defaults to 0. */
innerOffset?: number
/** Total visual rows this content renders to. */
visualLineCount: number
/** Source-mapping function. See offsetMaps.ts for builders. */
getOffset: SourceRange['getOffset']
}
export function CopySource(props: CopySourceProps): ReactNode {
const idRef = useRef<RangeId | null>(null)
const boxRef = useRef<unknown>(null)
// Register / update the range every render. registerRange is keyed on
// (msgId, blockIndex) so it returns the same id when those don't change.
// This is intentionally NOT inside a useEffect: the rangeId needs to
// exist on the FIRST render so the <Box copyRangeId={id}> below picks
// it up; useEffect runs post-mount which is too late.
const id = registerRange({
msgId: props.msgId,
blockIndex: props.blockIndex,
outerSource: props.outerSource,
innerSource: props.innerSource,
innerOffset: props.innerOffset,
visualLineCount: props.visualLineCount,
getOffset: props.getOffset
})
idRef.current = id
// After mount, point the registry at the live DOMElement so hit-test
// can walk DOM → rangeId → SourceRange. Cleanup nulls it out on unmount
// (virtual-scroll cycle) without evicting the range (still in registry).
useEffect(() => {
setRangeDom(id, boxRef.current)
return () => {
setRangeDom(id, null)
}
}, [id])
return (
<Box copyRangeId={id} ref={boxRef as never}>
{props.children}
</Box>
)
}

View File

@@ -0,0 +1,91 @@
/**
* Regression test for ethie's report #2:
*
* ```mermaid
* graph LR
* user[ethie] -->|asks| packet[packet >w<]
* ```
*
* Double-click "ethie" → copied "hie]". Selection shifted right by 2
* at start AND extended past `]` at end.
*
* Root cause: code fences render their content inside a `<Box
* paddingLeft={2}>` nested inside the `<CopySource>` Box. The hit-test
* reported visualLine/col relative to the OUTER (rangeId) Box, which
* has rect.x=0 — so the visual col (which includes the +2 padding) was
* passed through to `simpleOffsetFor`, which adds it to rowStart as if
* it were a source col. Every char shifted +2 in source space.
*
* Fix: `copyPointAt` now reports visualLine/col relative to the
* INNERMOST non-rangeId rect found during the walk-up. For inline
* content (no padded wrapper) this equals the rangeId Box's rect, so
* no behavior change. For code fences / tables / lists / blockquotes
* (anything wrapped in a padded Box), the col is now relative to the
* actual rendered content rect — matching `simpleOffsetFor`'s
* assumption that col=0 maps to start-of-source-line.
*
* This test verifies the SLICING is correct end-to-end given the
* post-fix col reporting. The hit-test-layer test for the col
* computation lives in copyPointHitTest.test.ts.
*/
import { describe, expect, it, beforeEach } from 'vitest'
import { buildLineStartsFromRows, simpleOffsetFor } from '../offsetMaps.js'
import { registerRange, resetRegistry } from '../registry.js'
import { toCopyText } from '../toCopyText.js'
describe('code fence padding off-by-N (ethie report #2)', () => {
beforeEach(() => {
resetRegistry()
})
it('selecting "ethie" inside a fence yields exactly "ethie"', () => {
const blockSource = [
'```mermaid',
'graph LR',
' user[ethie] -->|asks| packet[packet >w<]',
'```'
].join('\n')
const lineRows = blockSource.split('\n')
const rowStarts = buildLineStartsFromRows(lineRows)
const row2Start = rowStarts[2]!
// Sanity-check the source layout.
expect(blockSource[row2Start + 9]).toBe('e')
expect(blockSource[row2Start + 13]).toBe('e')
expect(blockSource[row2Start + 14]).toBe(']')
const rangeId = registerRange({
msgId: 'm1',
blockIndex: 1,
outerSource: blockSource,
visualLineCount: lineRows.length,
getOffset: simpleOffsetFor(blockSource, rowStarts)
})
// The hit-test gives col=9 (first 'e') for anchor and col=13 (last
// 'e' — cell-INCLUSIVE) for focus. The BRIDGE in buildCopyTextFromDom
// does the +1 cell→byte-exclusive bump on the focus before handing
// to toCopyText (when no sourceOffset is set, which is the case for
// code fences with no fragments). We simulate that here by passing
// col=14 as the focus.
const copied = toCopyText({
anchor: {
kind: 'in-range',
rangeId,
visualLine: 2,
col: 9 // first 'e' of ethie, source col 9
},
focus: {
kind: 'in-range',
rangeId,
visualLine: 2,
col: 14 // last 'e' + 1 (bridge bumped from 13 → 14 for end)
},
transcript: [{ id: 'm1', order: 0 }]
})
expect(copied).toBe('ethie')
})
})

View File

@@ -0,0 +1,399 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { buildLineStartsFromRows, simpleOffsetFor } from '../offsetMaps.js'
import { evictMessage, getRange, listRanges, registerRange, resetRegistry } from '../registry.js'
import { toCopyText } from '../toCopyText.js'
import type { MsgSnapshot, SelectionPoint } from '../types.js'
/**
* Integration tests: exercise the full transcript-virtual copy-source
* pipeline end-to-end. Each test sets up a fake transcript by registering
* one or more ranges, builds two SelectionPoints, and asserts the
* resulting copy text is the byte-exact slice the user expects.
*
* These are the "design contract" tests from the rewrite plan. The unit
* tests in offsetMaps.test.ts / toCopyText.test.ts exercise the building
* blocks in isolation; these tests verify they compose correctly under
* the real wiring pattern (one CopySource per block, per-block offset
* maps, fence inner/outer registration).
*/
function registerWholeMsg(msgId: string, source: string, blockIndex = 0): number {
const rows = source.split('\n')
return registerRange({
msgId,
blockIndex,
outerSource: source,
visualLineCount: Math.max(1, rows.length),
getOffset: simpleOffsetFor(source, buildLineStartsFromRows(rows))
})
}
function registerFenceBlock(msgId: string, blockIndex: number, outerSource: string, innerSource: string): number {
// innerOffset = position of inner content in outer (just past the opener line)
const innerOffset = outerSource.indexOf(innerSource)
expect(innerOffset).toBeGreaterThan(0) // sanity: inner should not start at 0
const rows = outerSource.split('\n')
return registerRange({
msgId,
blockIndex,
outerSource,
innerSource,
innerOffset,
visualLineCount: Math.max(1, rows.length),
getOffset: simpleOffsetFor(outerSource, buildLineStartsFromRows(rows))
})
}
const makeTranscript = (...ids: string[]): MsgSnapshot[] =>
ids.map((id, order) => ({ id, order }))
beforeEach(() => {
resetRegistry()
})
describe('integration: byte-exact copy text from selection', () => {
it('whole-message selection emits the entire source', () => {
const text = 'hello world\nsecond line'
registerWholeMsg('m1', text)
const transcript = makeTranscript('m1')
const anchor: SelectionPoint = { kind: 'before-all' }
const focus: SelectionPoint = { kind: 'after-all' }
expect(toCopyText({ anchor, focus, transcript })).toBe(text)
})
it('selection spanning two messages joins their sources with a newline', () => {
const m1Text = 'msg one line a\nmsg one line b'
const m2Text = 'msg two line a'
registerWholeMsg('m1', m1Text)
registerWholeMsg('m2', m2Text)
const transcript = makeTranscript('m1', 'm2')
expect(
toCopyText({
anchor: { kind: 'before-all' },
focus: { kind: 'after-all' },
transcript
})
).toBe(`${m1Text}\n${m2Text}`)
})
it('partial selection within a single range emits the inner slice', () => {
const text = 'abcdefghij'
const id = registerWholeMsg('m1', text)
const transcript = makeTranscript('m1')
// 'cdefg' spans cols [2..7) of the single visual line.
const anchor: SelectionPoint = { kind: 'in-range', rangeId: id, visualLine: 0, col: 2 }
const focus: SelectionPoint = { kind: 'in-range', rangeId: id, visualLine: 0, col: 7 }
expect(toCopyText({ anchor, focus, transcript })).toBe('cdefg')
})
it('fence-strip: both endpoints inside fence body yield bare code', () => {
const outer = '```py\nprint("hello")\nprint("world")\n```'
const inner = 'print("hello")\nprint("world")'
const id = registerFenceBlock('m1', 1, outer, inner)
const transcript = makeTranscript('m1')
const range = getRange(id)!
// Selection: from start of first inner line to end of second inner line.
// visualLine 1 = first inner content row in the rendered fence (row 0
// is the ```py opener), col 0 = first byte.
const innerLine1Start = range.innerOffset
const innerLine2End = range.innerOffset + inner.length
// Build points that resolve to those exact source offsets:
// visualLine 1 col 0 → offset = rowStart(1) = innerLine1Start (because
// simpleOffsetFor with one row per source line gives rowStarts[1] =
// length of row 0 + 1 = "```py".length + 1 = 6 = innerOffset).
expect(innerLine1Start).toBe(6)
const anchor: SelectionPoint = { kind: 'in-range', rangeId: id, visualLine: 1, col: 0 }
// visualLine 2 col 14 → row 2 starts at offset 21, col 14 → 35 = innerLine2End.
const focus: SelectionPoint = { kind: 'in-range', rangeId: id, visualLine: 2, col: 14 }
expect(toCopyText({ anchor, focus, transcript })).toBe(inner)
expect(toCopyText({ anchor, focus, transcript })).not.toContain('```')
})
it('fence: selection extending past the closer keeps the fence markers', () => {
const outer = '```py\nprint("hello")\n```'
const inner = 'print("hello")'
const id = registerFenceBlock('m1', 1, outer, inner)
const transcript = makeTranscript('m1')
// Anchor at start of OPENER line (visualLine 0 col 0), focus past end.
const anchor: SelectionPoint = { kind: 'in-range', rangeId: id, visualLine: 0, col: 0 }
const focus: SelectionPoint = { kind: 'after-all' }
expect(toCopyText({ anchor, focus, transcript })).toBe(outer)
})
it('two messages, partial selection: anchor mid-msg1, focus mid-msg2', () => {
const m1 = 'hello world'
const m2 = 'second message'
const id1 = registerWholeMsg('m1', m1)
const id2 = registerWholeMsg('m2', m2)
const transcript = makeTranscript('m1', 'm2')
// Anchor: col 6 of m1 (start of "world").
// Focus: col 6 of m2 (after "second").
const anchor: SelectionPoint = { kind: 'in-range', rangeId: id1, visualLine: 0, col: 6 }
const focus: SelectionPoint = { kind: 'in-range', rangeId: id2, visualLine: 0, col: 6 }
expect(toCopyText({ anchor, focus, transcript })).toBe('world\nsecond')
})
it('eviction: msg dropped from history → range gone → stale point gives empty', () => {
const m1 = 'doomed msg'
const id1 = registerWholeMsg('m1', m1)
// Even with the transcript still listing m1, eviction wipes the range
// from the registry. The selection point's rangeId no longer resolves.
evictMessage('m1')
const transcript = makeTranscript('m1')
const anchor: SelectionPoint = { kind: 'in-range', rangeId: id1, visualLine: 0, col: 0 }
const focus: SelectionPoint = { kind: 'in-range', rangeId: id1, visualLine: 0, col: 10 }
expect(toCopyText({ anchor, focus, transcript })).toBe('')
expect(listRanges()).toHaveLength(0)
})
it('re-registration preserves the rangeId (virtual-scroll unmount/remount)', () => {
const text = 'abc'
const id1 = registerWholeMsg('m1', text)
const id2 = registerWholeMsg('m1', text)
expect(id2).toBe(id1)
expect(listRanges()).toHaveLength(1)
})
it('gap point between msgs slots correctly in document order', () => {
const m1 = 'first'
const m2 = 'second'
const id1 = registerWholeMsg('m1', m1)
const id2 = registerWholeMsg('m2', m2)
const transcript = makeTranscript('m1', 'm2')
// Gap between m1 (end) and m2 (start) — like clicking on a blank
// spacer row. afterRangeId=id1 means the gap is AFTER range id1.
// beforeRangeId=id2 means the gap is BEFORE range id2.
const anchor: SelectionPoint = { kind: 'in-range', rangeId: id1, visualLine: 0, col: 0 }
const focus: SelectionPoint = { kind: 'gap', afterRangeId: id1, beforeRangeId: id2 }
// Should slice from col 0 of m1 to end of m1; m2 is past the gap.
expect(toCopyText({ anchor, focus, transcript })).toBe('first')
})
it('gap → in-range covers everything from gap-before-range through the focus', () => {
const m1 = 'first'
const m2 = 'second'
const id1 = registerWholeMsg('m1', m1)
const id2 = registerWholeMsg('m2', m2)
const transcript = makeTranscript('m1', 'm2')
// Gap BEFORE m2 (so positioned right after m1's end). Focus mid-m2.
const anchor: SelectionPoint = { kind: 'gap', afterRangeId: id1, beforeRangeId: id2 }
const focus: SelectionPoint = { kind: 'in-range', rangeId: id2, visualLine: 0, col: 3 }
// Gap-after-m1 == position past m1's last visual line, so m1 isn't
// included. Output is just the prefix of m2.
expect(toCopyText({ anchor, focus, transcript })).toBe('sec')
})
it('reversed selection (focus before anchor) produces the same text', () => {
const text = 'abcdefgh'
const id = registerWholeMsg('m1', text)
const transcript = makeTranscript('m1')
const forward = toCopyText({
anchor: { kind: 'in-range', rangeId: id, visualLine: 0, col: 1 },
focus: { kind: 'in-range', rangeId: id, visualLine: 0, col: 5 },
transcript
})
const reversed = toCopyText({
anchor: { kind: 'in-range', rangeId: id, visualLine: 0, col: 5 },
focus: { kind: 'in-range', rangeId: id, visualLine: 0, col: 1 },
transcript
})
expect(forward).toBe('bcde')
expect(reversed).toBe('bcde')
})
it('multi-block msg: per-block ranges concat correctly on full-msg selection', () => {
// Simulate a markdown msg with three blocks: heading, paragraph, fence.
const headingSrc = '# Title'
const paraSrc = 'Some text with `inline` code.'
const fenceOuter = '```js\nconst x = 1;\n```'
const fenceInner = 'const x = 1;'
registerRange({
msgId: 'm1',
blockIndex: 1,
outerSource: headingSrc,
visualLineCount: 1,
getOffset: simpleOffsetFor(headingSrc, buildLineStartsFromRows([headingSrc]))
})
registerRange({
msgId: 'm1',
blockIndex: 2,
outerSource: paraSrc,
visualLineCount: 1,
getOffset: simpleOffsetFor(paraSrc, buildLineStartsFromRows([paraSrc]))
})
const innerOffset = fenceOuter.indexOf(fenceInner)
const fenceRows = fenceOuter.split('\n')
registerRange({
msgId: 'm1',
blockIndex: 3,
outerSource: fenceOuter,
innerSource: fenceInner,
innerOffset,
visualLineCount: fenceRows.length,
getOffset: simpleOffsetFor(fenceOuter, buildLineStartsFromRows(fenceRows))
})
const transcript = makeTranscript('m1')
expect(
toCopyText({
anchor: { kind: 'before-all' },
focus: { kind: 'after-all' },
transcript
})
).toBe(`${headingSrc}\n${paraSrc}\n${fenceOuter}`)
})
it('selection mid-paragraph through mid-next-paragraph: byte-exact across blocks', () => {
const para1 = 'first paragraph'
const para2 = 'second paragraph'
const id1 = registerRange({
msgId: 'm1',
blockIndex: 1,
outerSource: para1,
visualLineCount: 1,
getOffset: simpleOffsetFor(para1, buildLineStartsFromRows([para1]))
})
const id2 = registerRange({
msgId: 'm1',
blockIndex: 2,
outerSource: para2,
visualLineCount: 1,
getOffset: simpleOffsetFor(para2, buildLineStartsFromRows([para2]))
})
const transcript = makeTranscript('m1')
// Anchor: 6 chars into para1 (start of "paragraph").
// Focus: 7 chars into para2 (after "second ").
const anchor: SelectionPoint = { kind: 'in-range', rangeId: id1, visualLine: 0, col: 6 }
const focus: SelectionPoint = { kind: 'in-range', rangeId: id2, visualLine: 0, col: 7 }
expect(toCopyText({ anchor, focus, transcript })).toBe('paragraph\nsecond ')
})
it('python fence: selecting a single code line via in-range points returns just that line', () => {
// Regression: selecting the docstring line ` """packet says hi !!"""`
// inside a python fence should copy exactly that line, not the
// surrounding code or the whole fence.
const fenceOuter = [
'```python',
'def greet(name: str) -> str:',
' """packet says hi !!"""',
' return f"awaaaaa hi {name} >w<"',
'',
'print(greet("ethie"))',
'```'
].join('\n')
const fenceLines = fenceOuter.split('\n')
const innerSource = fenceLines.slice(1, -1).join('\n')
const innerOffset = fenceLines[0]!.length + 1
const id = registerRange({
msgId: 'm1',
blockIndex: 1,
outerSource: fenceOuter,
innerSource,
innerOffset,
visualLineCount: fenceLines.length,
getOffset: simpleOffsetFor(fenceOuter, buildLineStartsFromRows(fenceLines))
})
const transcript = [{ id: 'm1', order: 0 }]
// Visual row 2 = docstring line (row 0 = opener / chrome label,
// row 1 = def line, row 2 = docstring). col 0 = line start, col
// 27 = end of ` """packet says hi !!"""`.
const anchor: SelectionPoint = { kind: 'in-range', rangeId: id, visualLine: 2, col: 0 }
const focus: SelectionPoint = { kind: 'in-range', rangeId: id, visualLine: 2, col: 27 }
// Fence-stripping rule applies: both endpoints land in innerSource
// bounds → output is the innerSource slice, not the outerSource
// slice (which would include the line with no opener/closer
// adjustment).
expect(toCopyText({ anchor, focus, transcript })).toBe(' """packet says hi !!"""')
})
it('python fence: selecting one wrapped code line past visualLineCount clamps to last source row', () => {
// What happens if the docstring is the LAST tracked source row and
// the hit-test reports visualLine past visualLineCount (e.g.
// because the renderer wrapped the line to multiple visual rows
// but the block was registered with source-line-count only).
//
// Defensive fallback in pointToOffset clamps to last-row getOffset,
// bounded by the line's source-end. So the slice is at MOST the
// docstring line itself, never spilling into post-fence content.
const fenceOuter = [
'```python',
' """packet says hi !!"""',
'```'
].join('\n')
const fenceLines = fenceOuter.split('\n')
const innerSource = fenceLines.slice(1, -1).join('\n')
const innerOffset = fenceLines[0]!.length + 1
const id = registerRange({
msgId: 'm1',
blockIndex: 1,
outerSource: fenceOuter,
innerSource,
innerOffset,
visualLineCount: fenceLines.length,
getOffset: simpleOffsetFor(fenceOuter, buildLineStartsFromRows(fenceLines))
})
const transcript = [{ id: 'm1', order: 0 }]
// visualLine=99 simulates a click past the tracked visual rows
// (e.g. wrap-continuation row beyond visualLineCount). The
// defensive clamp in pointToOffset defers to the last-row offset,
// which gets clamped to the row's source-end by simpleOffsetFor.
const anchor: SelectionPoint = { kind: 'in-range', rangeId: id, visualLine: 1, col: 0 }
const focus: SelectionPoint = { kind: 'in-range', rangeId: id, visualLine: 99, col: 0 }
// Last tracked row is the closer line `\`\`\`` at visualLine=2,
// not the docstring at visualLine=1. visualLine=99 clamps to
// start of closer row. Slice covers docstring + trailing \n.
// (Pre-fix this would have clamped to outerSource.length,
// returning everything from the docstring to end of fence.)
const result = toCopyText({ anchor, focus, transcript })
// The fence-stripping rule requires BOTH points inside [innerOffset, innerEnd];
// visualLine=99 → clamp to byte 38 (start of closer). innerEnd = 38
// (innerOffset 10 + innerSource.length 28). So 38 <= 38 is at the
// boundary — fence-stripping kicks in if `<= innerEnd`. Either way,
// the result must NOT include the closer ``` line.
expect(result).not.toContain('```')
// And it must contain the docstring content.
expect(result).toContain('packet says hi !!')
})
})

View File

@@ -0,0 +1,163 @@
import { describe, expect, test } from 'vitest'
import {
buildLineStartsFromRows,
inlineOffsetFor,
type InlineSpanTable,
simpleOffsetFor
} from '../offsetMaps.js'
describe('buildLineStartsFromRows', () => {
test('three single-line rows', () => {
const out = buildLineStartsFromRows(['abc', 'def', 'ghi'])
expect(Array.from(out)).toEqual([0, 4, 8])
})
test('empty array', () => {
expect(buildLineStartsFromRows([]).length).toBe(0)
})
test('rows with varying length', () => {
const out = buildLineStartsFromRows(['', 'longer', 'x'])
expect(Array.from(out)).toEqual([0, 1, 8])
})
})
describe('simpleOffsetFor', () => {
test('col within first row returns rowStart + col', () => {
const get = simpleOffsetFor('abc\ndef\nghi', new Uint32Array([0, 4, 8]))
expect(get(0, 0)).toBe(0)
expect(get(0, 2)).toBe(2)
expect(get(1, 1)).toBe(5)
expect(get(2, 2)).toBe(10)
})
test('col past end of row clamps at row end (excludes newline)', () => {
const get = simpleOffsetFor('abc\ndef', new Uint32Array([0, 4]))
expect(get(0, 99)).toBe(3) // end of "abc", before the \n
expect(get(1, 99)).toBe(7) // end of "def" (end of source)
})
test('visualRow beyond end clamps to outerSource.length', () => {
const get = simpleOffsetFor('abc\ndef', new Uint32Array([0, 4]))
expect(get(5, 0)).toBe(7)
})
test('soft-wrap: two rows pointing into same source line', () => {
// "abcdefghij" wrapped at col 5: row 0 → "abcde", row 1 → "fghij"
const get = simpleOffsetFor('abcdefghij', new Uint32Array([0, 5]))
expect(get(0, 0)).toBe(0)
expect(get(0, 5)).toBe(5) // past row 0, clamped to row 1 start
expect(get(1, 0)).toBe(5)
expect(get(1, 4)).toBe(9)
// col past end of row 1: clamp to source end
expect(get(1, 99)).toBe(10)
})
test('negative visualRow returns 0', () => {
const get = simpleOffsetFor('abc', new Uint32Array([0]))
expect(get(-1, 0)).toBe(0)
})
test('negative col treated as 0', () => {
const get = simpleOffsetFor('abc', new Uint32Array([0]))
expect(get(0, -5)).toBe(0)
})
})
describe('inlineOffsetFor — verbatim spans (visual == source length)', () => {
test('single span: col offsets map 1:1 to source bytes', () => {
const get = inlineOffsetFor('hello world', [
[{ visualStart: 0, visualEnd: 11, sourceStart: 0, sourceEnd: 11 }]
])
expect(get(0, 0)).toBe(0)
expect(get(0, 5)).toBe(5)
expect(get(0, 11)).toBe(11)
})
test('col before first span snaps to sourceStart of that span', () => {
// Row starts with 2 columns of non-source-mapped prefix (e.g. gutter).
const get = inlineOffsetFor('text', [
[{ visualStart: 2, visualEnd: 6, sourceStart: 0, sourceEnd: 4 }]
])
expect(get(0, 0)).toBe(0)
expect(get(0, 1)).toBe(0)
expect(get(0, 2)).toBe(0)
expect(get(0, 4)).toBe(2)
expect(get(0, 6)).toBe(4) // past end → sourceEnd of last span
})
})
describe('inlineOffsetFor — rendered spans (visual != source length)', () => {
test('bold span: 4 visual cells for 8 source chars (**bold**)', () => {
// outerSource is "**bold**" (8 bytes), rendered as "bold" (4 cells).
const get = inlineOffsetFor('**bold**', [
[{ visualStart: 0, visualEnd: 4, sourceStart: 0, sourceEnd: 8 }]
])
// col 0 → sourceStart (0)
expect(get(0, 0)).toBe(0)
// col 4 (past end) → sourceEnd (8)
expect(get(0, 4)).toBe(8)
// mid: col 2 → proportional (2/4) * 8 = 4
expect(get(0, 2)).toBe(4)
})
test('link span: rendered "text" for source "[text](url)" — source byte length differs', () => {
const outerSource = '[text](url)'
const get = inlineOffsetFor(outerSource, [
[{ visualStart: 0, visualEnd: 4, sourceStart: 0, sourceEnd: outerSource.length }]
])
expect(get(0, 0)).toBe(0)
expect(get(0, 4)).toBe(outerSource.length)
})
test('mixed row: plain text + rendered span + plain text', () => {
// Source: "pre **bold** post" (17 bytes)
// Rendered: "pre bold post" (13 cells)
// Visual: 0-4 "pre " (verbatim, 4 chars), 4-8 "bold" (rendered for **bold**),
// 8-13 " post" (verbatim, 5 chars).
const outerSource = 'pre **bold** post'
const spans: InlineSpanTable = [
[
{ visualStart: 0, visualEnd: 4, sourceStart: 0, sourceEnd: 4 }, // "pre "
{ visualStart: 4, visualEnd: 8, sourceStart: 4, sourceEnd: 12 }, // "**bold**"
{ visualStart: 8, visualEnd: 13, sourceStart: 12, sourceEnd: 17 } // " post"
]
]
const get = inlineOffsetFor(outerSource, spans)
expect(get(0, 0)).toBe(0) // start of "pre "
expect(get(0, 3)).toBe(3) // last char of "pre"
expect(get(0, 4)).toBe(4) // start of bold span source
expect(get(0, 8)).toBe(12) // end of bold span source
expect(get(0, 13)).toBe(17) // end of post span source
})
test('past last span snaps to its sourceEnd', () => {
const get = inlineOffsetFor('hello', [
[{ visualStart: 0, visualEnd: 5, sourceStart: 0, sourceEnd: 5 }]
])
expect(get(0, 99)).toBe(5)
})
test('empty row finds the next non-empty row sourceStart', () => {
const get = inlineOffsetFor('first\nsecond', [
[],
[{ visualStart: 0, visualEnd: 6, sourceStart: 6, sourceEnd: 12 }]
])
expect(get(0, 0)).toBe(6)
})
test('empty row at end with no further content returns outerSource.length', () => {
const get = inlineOffsetFor('hello', [[], []])
expect(get(0, 0)).toBe(5)
})
})

View File

@@ -0,0 +1,441 @@
import { afterEach, describe, expect, test } from 'vitest'
import { buildLineStartsFromRows, simpleOffsetFor } from '../offsetMaps.js'
import { registerRange, resetRegistry } from '../registry.js'
import { toCopyText } from '../toCopyText.js'
import type { MsgSnapshot, RangeId, SelectionPoint } from '../types.js'
/**
* Helper: register a one-line-per-source-line range (no soft-wrap).
* Returns its RangeId.
*/
function registerSimple(
msgId: string,
blockIndex: number,
outerSource: string,
innerSource?: string,
innerOffset?: number
): RangeId {
const lines = outerSource.split('\n')
const rowStarts = buildLineStartsFromRows(lines)
return registerRange({
msgId,
blockIndex,
outerSource,
innerSource,
innerOffset,
visualLineCount: rowStarts.length,
getOffset: simpleOffsetFor(outerSource, rowStarts)
})
}
/**
* Helper: register a range with explicit visual→source mapping (for
* soft-wrap or out-of-order rendering tests).
*/
function registerCustom(
msgId: string,
blockIndex: number,
outerSource: string,
rowStartsArr: number[],
innerSource?: string,
innerOffset?: number
): RangeId {
const rowStarts = new Uint32Array(rowStartsArr)
return registerRange({
msgId,
blockIndex,
outerSource,
innerSource,
innerOffset,
visualLineCount: rowStarts.length,
getOffset: simpleOffsetFor(outerSource, rowStarts)
})
}
function msgs(...ids: string[]): readonly MsgSnapshot[] {
return ids.map((id, order) => ({ id, order }))
}
function ptInRange(rangeId: RangeId, visualLine: number, col: number): SelectionPoint {
return { kind: 'in-range', rangeId, visualLine, col }
}
afterEach(() => {
resetRegistry()
})
describe('toCopyText — single range', () => {
test('empty selection returns empty string', () => {
const r = registerSimple('m1', 0, 'hello world')
const p = ptInRange(r, 0, 5)
expect(toCopyText({ anchor: p, focus: p, transcript: msgs('m1') })).toBe('')
})
test('within one line, returns the exact source slice', () => {
const r = registerSimple('m1', 0, 'hello world')
expect(
toCopyText({
anchor: ptInRange(r, 0, 0),
focus: ptInRange(r, 0, 5),
transcript: msgs('m1')
})
).toBe('hello')
})
test('across two source lines, includes the newline', () => {
const r = registerSimple('m1', 0, 'hello\nworld')
expect(
toCopyText({
anchor: ptInRange(r, 0, 0),
focus: ptInRange(r, 1, 5),
transcript: msgs('m1')
})
).toBe('hello\nworld')
})
test('reversed anchor/focus produces same result (auto-order)', () => {
const r = registerSimple('m1', 0, 'hello world')
const a = ptInRange(r, 0, 0)
const b = ptInRange(r, 0, 5)
expect(toCopyText({ anchor: b, focus: a, transcript: msgs('m1') })).toBe('hello')
expect(toCopyText({ anchor: a, focus: b, transcript: msgs('m1') })).toBe('hello')
})
test('col past end of line clamps to end of that line, not next', () => {
const r = registerSimple('m1', 0, 'abc\ndef')
expect(
toCopyText({
anchor: ptInRange(r, 0, 0),
focus: ptInRange(r, 0, 99),
transcript: msgs('m1')
})
).toBe('abc')
})
test('select whole single-line range', () => {
const r = registerSimple('m1', 0, 'foo bar baz')
expect(
toCopyText({
anchor: ptInRange(r, 0, 0),
focus: ptInRange(r, 0, 11),
transcript: msgs('m1')
})
).toBe('foo bar baz')
})
})
describe('toCopyText — multiple ranges in one message', () => {
test('select across two blocks in one msg includes both source bodies', () => {
const r1 = registerSimple('m1', 1, '# heading')
const r2 = registerSimple('m1', 2, 'paragraph text')
expect(
toCopyText({
anchor: ptInRange(r1, 0, 0),
focus: ptInRange(r2, 0, 14),
transcript: msgs('m1')
})
).toBe('# heading\nparagraph text')
})
test('select partial of first block + all of second', () => {
const r1 = registerSimple('m1', 1, '# heading')
const r2 = registerSimple('m1', 2, 'para')
expect(
toCopyText({
anchor: ptInRange(r1, 0, 2),
focus: ptInRange(r2, 0, 4),
transcript: msgs('m1')
})
).toBe('heading\npara')
})
test('three blocks: middle block included whole', () => {
const r1 = registerSimple('m1', 1, 'aaa')
const r2 = registerSimple('m1', 2, 'bbb')
const r3 = registerSimple('m1', 3, 'ccc')
expect(
toCopyText({
anchor: ptInRange(r1, 0, 1),
focus: ptInRange(r3, 0, 2),
transcript: msgs('m1')
})
).toBe('aa\nbbb\ncc')
})
})
describe('toCopyText — across messages', () => {
test('select spans m1 → m2 → m3, middle msgs included whole', () => {
const r1 = registerSimple('m1', 0, 'first')
const r2 = registerSimple('m2', 0, 'middle')
const r3 = registerSimple('m3', 0, 'last')
expect(
toCopyText({
anchor: ptInRange(r1, 0, 2),
focus: ptInRange(r3, 0, 3),
transcript: msgs('m1', 'm2', 'm3')
})
).toBe('rst\nmiddle\nlas')
})
test('order independence — selecting bottom-to-top yields same text', () => {
const r1 = registerSimple('m1', 0, 'first')
const r2 = registerSimple('m2', 0, 'second')
const forward = toCopyText({
anchor: ptInRange(r1, 0, 0),
focus: ptInRange(r2, 0, 6),
transcript: msgs('m1', 'm2')
})
const reverse = toCopyText({
anchor: ptInRange(r2, 0, 6),
focus: ptInRange(r1, 0, 0),
transcript: msgs('m1', 'm2')
})
expect(forward).toBe('first\nsecond')
expect(reverse).toBe(forward)
})
})
describe('toCopyText — fence-stripping rule', () => {
test('both endpoints inside inner body of same range emits inner', () => {
const outer = '```py\ncode\nlines\n```'
const inner = 'code\nlines'
const innerOffset = outer.indexOf(inner)
const r = registerCustom('m1', 1, outer, [0, 6, 11, 17], inner, innerOffset)
// Endpoints land on the inner visual rows (visual rows 1 and 2 — the
// body lines). Sel from "code" start to "lines" end.
expect(
toCopyText({
anchor: ptInRange(r, 1, 0),
focus: ptInRange(r, 2, 5),
transcript: msgs('m1')
})
).toBe('code\nlines')
})
test('selection extending past fence emits outer with fence markers', () => {
const outer = '```py\ncode\n```'
const inner = 'code'
const innerOffset = outer.indexOf(inner)
const r = registerCustom('m1', 1, outer, [0, 6, 11], inner, innerOffset)
// Anchor on fence opener (visualLine 0), focus on inner — fence
// markers must survive.
const out = toCopyText({
anchor: ptInRange(r, 0, 0),
focus: ptInRange(r, 1, 4),
transcript: msgs('m1')
})
expect(out).toBe('```py\ncode')
})
test('endpoints land exactly on inner boundary still emits inner', () => {
const outer = '```\nx\n```'
const inner = 'x'
const innerOffset = outer.indexOf(inner)
const r = registerCustom('m1', 1, outer, [0, 4, 6], inner, innerOffset)
expect(
toCopyText({
anchor: ptInRange(r, 1, 0),
focus: ptInRange(r, 1, 1),
transcript: msgs('m1')
})
).toBe('x')
})
})
describe('toCopyText — soft-wrap', () => {
test('one source line wrapped to two visual rows', () => {
// "abcdefghij" wrapped at col 5 → visual rows "abcde" and "fghij"
// mapVisualToSource = [0, 5] (both rows point into the same source line)
const r = registerCustom('m1', 0, 'abcdefghij', [0, 5])
// Select from visual (0,2) to visual (1,3) — should give "cdefgh"
expect(
toCopyText({
anchor: ptInRange(r, 0, 2),
focus: ptInRange(r, 1, 3),
transcript: msgs('m1')
})
).toBe('cdefgh')
})
test('soft-wrap does not insert a newline that isn\'t in source', () => {
const r = registerCustom('m1', 0, 'abcdefghij', [0, 5])
expect(
toCopyText({
anchor: ptInRange(r, 0, 0),
focus: ptInRange(r, 1, 5),
transcript: msgs('m1')
})
).toBe('abcdefghij')
})
test('visualLine past visualLineCount defers to last-row offset (no whole-doc clamp)', () => {
// Regression: a paragraph that's a single source line gets
// registered with visualLineCount=1, but when rendered the
// terminal wraps it to multiple visual rows. A click on a
// wrap-continuation row (e.g. row 1) would arrive at toCopyText
// with visualLine=1. The OLD pointToOffset clamped to
// outerSource.length on this — copying the WHOLE source line
// instead of just the prefix the user dragged across. The fix
// is to defer to the last tracked row's getOffset(col), which
// is bounded by the row's source-end.
const source = 'the quick brown fox jumps over'
const r = registerSimple('m1', 0, source)
// Anchor at col 5 on the (sole) tracked row 0, focus on the
// hypothetical wrap-continuation row 1 col 0. The old behavior
// gave the whole 30-char line; the new behavior gives the row 0
// portion up to its source-end (the line's whole content since
// there's only one source line — but key thing: it's bounded by
// line content not by `outerSource.length`, which matters when
// the range has further content past this line).
const result = toCopyText({
anchor: ptInRange(r, 0, 5),
focus: ptInRange(r, 1, 0),
transcript: msgs('m1')
})
// For a single-source-line range, the deferred-to-last-row offset
// at col=0 gives byte 0 of row 0. The selection slice from byte 5
// back to byte 0 is `'the q'` (reversed, but toCopyText orders).
expect(result).toBe('the q')
})
test('multi-source-line range: visualLine past count clamps to LAST line end', () => {
// Same defensive scenario but the range has multiple source lines.
// The wrap-continuation click should NOT include subsequent lines —
// it should clamp to the end of the last tracked visual row.
const source = 'first line here\nsecond line'
// Two source lines. rowStarts = [0, 16] (16 = "first line here\n".length).
const r = registerCustom('m1', 0, source, [0, 16])
// Anchor mid-first-line, focus on a "wrap continuation" row that
// doesn't exist (visualLine=5). Should NOT include the second
// source line — should clamp to end of last known row.
const result = toCopyText({
anchor: ptInRange(r, 0, 5),
focus: ptInRange(r, 5, 0),
transcript: msgs('m1')
})
// visualLine=5 past visualLineCount=2 → defers to getOffset(1, 0)
// = start of "second line" (byte 16). Slice [5, 16) = byte index
// 5 to 16 of "first line here\n" = " line here\n" (leading space).
expect(result).toBe(' line here\n')
})
})
describe('toCopyText — boundary points', () => {
test('before-all + after-all selects entire transcript', () => {
const r1 = registerSimple('m1', 0, 'one')
const r2 = registerSimple('m2', 0, 'two')
expect(
toCopyText({
anchor: { kind: 'before-all' },
focus: { kind: 'after-all' },
transcript: msgs('m1', 'm2')
})
).toBe('one\ntwo')
})
test('before-all to mid-msg emits from start', () => {
const r1 = registerSimple('m1', 0, 'hello')
expect(
toCopyText({
anchor: { kind: 'before-all' },
focus: ptInRange(r1, 0, 3),
transcript: msgs('m1')
})
).toBe('hel')
})
test('mid-msg to after-all emits to end', () => {
const r1 = registerSimple('m1', 0, 'hello')
expect(
toCopyText({
anchor: ptInRange(r1, 0, 2),
focus: { kind: 'after-all' },
transcript: msgs('m1')
})
).toBe('llo')
})
})
describe('toCopyText — stale rangeId (evicted)', () => {
test('stale anchor + valid focus + no host-side repair → empty', () => {
// Contract: when a range is evicted, the host is expected to repair
// the selection via the truncate-to-survivor policy BEFORE calling
// toCopyText. If it forgets, toCopyText degrades gracefully — both
// endpoints fall to the far-end of the document, the resolved window
// collapses, and the output is empty rather than wrong.
const r2 = registerSimple('m2', 0, 'two')
const stale: SelectionPoint = { kind: 'in-range', rangeId: 99999, visualLine: 0, col: 0 }
expect(
toCopyText({
anchor: stale,
focus: ptInRange(r2, 0, 3),
transcript: msgs('m1', 'm2')
})
).toBe('')
})
})
describe('toCopyText — idempotence / round-trip', () => {
test('select-all of plain transcript equals concatenated source with \\n separator', () => {
const sources = ['first message', 'second message', 'third message']
const ids = ['m1', 'm2', 'm3']
for (let i = 0; i < sources.length; i++) {
registerSimple(ids[i]!, 0, sources[i]!)
}
expect(
toCopyText({
anchor: { kind: 'before-all' },
focus: { kind: 'after-all' },
transcript: msgs(...ids)
})
).toBe('first message\nsecond message\nthird message')
})
test('select-all of markdown msg (multi-block) reproduces full body', () => {
// msg "m1" with three blocks emulating:
// "# heading"
// ""
// "paragraph here"
registerSimple('m1', 1, '# heading')
registerSimple('m1', 2, '')
registerSimple('m1', 3, 'paragraph here')
expect(
toCopyText({
anchor: { kind: 'before-all' },
focus: { kind: 'after-all' },
transcript: msgs('m1')
})
).toBe('# heading\n\nparagraph here')
})
})
describe('toCopyText — gap points', () => {
test('gap between two ranges acts like the boundary between them', () => {
const r1 = registerSimple('m1', 0, 'first')
const r2 = registerSimple('m2', 0, 'second')
const gap: SelectionPoint = { kind: 'gap', afterRangeId: r1, beforeRangeId: r2 }
// Anchor at start of r1, focus in the gap → emits just first
// (gap-after-r1 means we're past r1 but before r2).
expect(
toCopyText({
anchor: ptInRange(r1, 0, 0),
focus: gap,
transcript: msgs('m1', 'm2')
})
).toBe('first')
})
})

View File

@@ -0,0 +1,129 @@
/**
* Regression test for ethie's report: double-click "might" in a callout
* (`> [!WARNING]\n> things might break if u skip this`) copied "migh" —
* one char short. Same on drag-select.
*
* Root cause: cell-INCLUSIVE selection bounds (anchor/focus point AT
* the cell, not past it) crossed with EXCLUSIVE slice semantics in
* toCopyText. The hit-test for verbatim fragments returned
* `f.start + (localCol - f.colStart)` — the START byte of the clicked
* cell — for both endpoints, dropping one char off the right edge of
* every selection.
*
* Fix: `copyPointAt` now takes an `endpoint: 'start' | 'end'` arg.
* The buildCopyTextFromDom bridge passes `'end'` for the focus, and the
* verbatim cell→byte math bumps by 1 (clamped to fragment end) so the
* end-byte points PAST the last selected cell. Slice semantics then
* work out exactly.
*/
import { describe, expect, it, beforeEach } from 'vitest'
import { simpleOffsetFor } from '../offsetMaps.js'
import { registerRange, resetRegistry } from '../registry.js'
import { toCopyText } from '../toCopyText.js'
describe('word selection endpoint off-by-one (regression)', () => {
beforeEach(() => {
resetRegistry()
})
it('focus on last cell of "might" with endpoint="end" math yields "might"', () => {
// Source layout:
// "things might break"
// 0 7 13
// t h i n g s _ m i g h t _ b r e a k
// 0 1 2 3 4 5 6 7 8 9 ...
const SOURCE = 'things might break'
const MIGHT_START = 7
const MIGHT_END = 12
const rangeId = registerRange({
msgId: 'm1',
blockIndex: 1,
outerSource: SOURCE,
visualLineCount: 1,
getOffset: simpleOffsetFor(SOURCE, new Uint32Array([0]))
})
// Simulate the post-fix verbatim cell→byte math from
// copyPointHitTest.ts. The fragment spans cells [0, SOURCE.length)
// and source bytes [0, SOURCE.length).
// anchor (endpoint='start'): bump=0 → f.start + cellsIn
// focus (endpoint='end'): bump=1 → f.start + cellsIn + 1, clamped
const cellToByte = (col: number, endpoint: 'start' | 'end'): number => {
const cellsIn = col - 0
const bump = endpoint === 'end' ? 1 : 0
const len = SOURCE.length
return 0 + Math.min(cellsIn + bump, len)
}
// anchor: cell 7 ('m'), endpoint='start' → 7
const anchorOffset = cellToByte(7, 'start')
// focus: cell 11 ('t' — last cell of 'might'), endpoint='end' → 12
const focusOffset = cellToByte(11, 'end')
expect(anchorOffset).toBe(MIGHT_START)
expect(focusOffset).toBe(MIGHT_END)
const copied = toCopyText({
anchor: { kind: 'in-range', rangeId, visualLine: 0, col: 7, sourceOffset: anchorOffset },
focus: { kind: 'in-range', rangeId, visualLine: 0, col: 11, sourceOffset: focusOffset },
transcript: [{ id: 'm1', order: 0 }]
})
expect(copied).toBe('might')
})
it('focus past last cell of fragment clamps to fragment end (no over-read)', () => {
// Click on the very last cell with endpoint='end' should land
// EXACTLY on fragment end (not over).
const SOURCE = 'might'
const rangeId = registerRange({
msgId: 'm2',
blockIndex: 1,
outerSource: SOURCE,
visualLineCount: 1,
getOffset: simpleOffsetFor(SOURCE, new Uint32Array([0]))
})
const cellToByte = (col: number, endpoint: 'start' | 'end'): number => {
const cellsIn = col - 0
const bump = endpoint === 'end' ? 1 : 0
const len = SOURCE.length
return 0 + Math.min(cellsIn + bump, len)
}
// Even with bump, clamped at fragment end — no over-read.
expect(cellToByte(4, 'end')).toBe(5)
expect(cellToByte(4, 'start')).toBe(4)
const copied = toCopyText({
anchor: { kind: 'in-range', rangeId, visualLine: 0, col: 0, sourceOffset: 0 },
focus: { kind: 'in-range', rangeId, visualLine: 0, col: 4, sourceOffset: 5 },
transcript: [{ id: 'm2', order: 0 }]
})
expect(copied).toBe('might')
})
it('anchor unchanged: endpoint="start" still gives cell-start byte', () => {
// Sanity: the fix must NOT shift anchor-side semantics.
const SOURCE = 'things might break'
const cellToByte = (col: number, endpoint: 'start' | 'end'): number => {
const cellsIn = col - 0
const bump = endpoint === 'end' ? 1 : 0
const len = SOURCE.length
return 0 + Math.min(cellsIn + bump, len)
}
// Anchor on 'm' of "might" → cell 7 → byte 7
expect(cellToByte(7, 'start')).toBe(7)
// (Same call with endpoint='end' would give 8 — the boundary clarifies
// why threading endpoint explicitly matters.)
expect(cellToByte(7, 'end')).toBe(8)
})
})

View File

@@ -0,0 +1,62 @@
/**
* Host-side copy-text builder. Plugged into Ink via `setCopyTextFn`.
*
* Walks the live DOM at copy time to find every Box tagged with
* `style.copyRangeId` that intersects the current selection rect, builds
* SelectionPoints for the anchor + focus of the selection, and calls
* `toCopyText` against the registry + transcript.
*
* Drag-scroll fidelity comes for free: rangeIds remain in the registry
* after their DOMs unmount, and the anchor SelectionPoint captured at
* mouse-down stays valid through scroll because rangeIds are stable.
* The "extends past viewport" cases that captureScrolledRows used to
* handle are handled by toCopyText seeing the anchor-side range as
* fully included (start col 0, span includes the range).
*/
import type { InkInstance } from '@hermes/ink'
import { copyPointFromColRow } from './hitTestBridge.js'
import { toCopyText } from './toCopyText.js'
import type { MsgSnapshot, SelectionPoint } from './types.js'
/**
* Build the copy-text builder. Pass the current `transcript` getter so the
* builder always sees the latest Msg[] when copy fires (avoids closing
* over stale state).
*/
export function makeCopyTextFn(
getTranscript: () => readonly MsgSnapshot[]
): (ink: InkInstance) => string {
return (ink) => {
const bounds = ink.getSelectionBoundsScreen()
if (!bounds) {
return ''
}
const rootDom = ink.getRootDom()
const transcript = getTranscript()
const anchor = copyPointFromColRow(rootDom, bounds.start.col, bounds.start.row, 'start')
const focus = copyPointFromColRow(rootDom, bounds.end.col, bounds.end.row, 'end')
// Cell-INCLUSIVE selection bounds × byte-EXCLUSIVE slice semantics:
// when the focus point fell through to the no-fragment fallback
// path (no sourceOffset set — e.g. code fences, plain text blocks
// without inline markdown registered as fragments), the resolved
// col still points AT the last selected cell. Bump it by +1 so
// toCopyText's pointToOffset returns the byte-EXCLUSIVE end. The
// bump is clamped by getOffset's per-row source-end cap, so no
// over-read across line boundaries.
//
// For the fragment path, the hit-test already baked this bump in
// (see copyPointHitTest endpoint='end' arg) and sourceOffset is
// set — we leave that alone.
const focusBumped: SelectionPoint =
focus.kind === 'in-range' && focus.sourceOffset === undefined
? { ...focus, col: focus.col + 1 }
: focus
return toCopyText({ anchor, focus: focusBumped, transcript })
}
}

View File

@@ -0,0 +1,49 @@
/**
* Bridge between hermes-ink's `copyPointAt` (operates on Ink's internal
* DOMElement type) and the host's `SelectionPoint` type. The shapes are
* structurally identical now that `copyPointAt` returns gap adjacency
* and per-fragment `sourceOffset` directly — this bridge is a thin
* re-typing layer that keeps the dependency direction clean (host
* depends on hermes-ink; hermes-ink doesn't import host types).
*/
import { copyPointAt as inkCopyPointAt } from '@hermes/ink'
import type { SelectionPoint } from './types.js'
type RawPoint =
| {
kind: 'in-range'
rangeId: number
visualLine: number
col: number
sourceOffset?: number
}
| { kind: 'gap'; afterRangeId: null | number; beforeRangeId: null | number }
export function copyPointFromColRow(
rootDom: unknown,
col: number,
row: number,
endpoint: 'start' | 'end' = 'start'
): SelectionPoint {
const raw = (inkCopyPointAt as (root: unknown, col: number, row: number, endpoint?: 'start' | 'end') => RawPoint)(
rootDom,
col,
row,
endpoint
)
if (raw.kind === 'in-range') {
return {
kind: 'in-range',
rangeId: raw.rangeId,
visualLine: raw.visualLine,
col: raw.col,
...(raw.sourceOffset !== undefined && { sourceOffset: raw.sourceOffset })
}
}
// Gap: copy adjacency through.
return { kind: 'gap', afterRangeId: raw.afterRangeId, beforeRangeId: raw.beforeRangeId }
}

View File

@@ -0,0 +1,181 @@
/**
* Factory functions for SourceRange.getOffset.
*
* Two flavors:
*
* - `simpleOffsetFor`: for ranges where rendered text == source text
* character-for-character. Code fences, plain messages, tool output.
* Built from per-visual-row source offsets (a Uint32Array).
*
* - `inlineOffsetFor`: for ranges with inline markdown rendering, where
* `**bold**` (6 source chars) renders as `bold` (4 visual cells). Built
* from per-visual-row span tables that describe each rendered segment's
* visual extent and source extent.
*
* Both produce a `(visualRow, col) → byteOffset` function with the same
* shape. The range stores this function in `range.getOffset` and toCopyText
* just calls it.
*/
import type { SourceRange } from './types.js'
/**
* For each visual row, the byte offset into outerSource where that row's
* content begins. The end of row v is `rowStarts[v+1]` (after subtracting
* 1 if the boundary is a hard newline) or `outerSource.length` for the
* last row.
*
* Multiple consecutive entries pointing into the same source line
* represent soft-wrap of one source line over multiple visual rows.
*/
export type SimpleOffsetMap = Uint32Array
export function simpleOffsetFor(
outerSource: string,
rowStarts: SimpleOffsetMap
): (visualRow: number, col: number) => number {
return (visualRow, col) => {
if (visualRow < 0) {
return 0
}
if (visualRow >= rowStarts.length) {
return outerSource.length
}
const rowStart = rowStarts[visualRow]!
let rowEnd: number
if (visualRow + 1 < rowStarts.length) {
const next = rowStarts[visualRow + 1]!
// If the byte before `next` is a newline, that newline is the row
// separator and NOT part of either row's content. Step back to
// exclude it. For soft-wrap continuations (no intervening \n in
// source), next IS the end of the row.
rowEnd = next > rowStart && outerSource.charCodeAt(next - 1) === 10 ? next - 1 : next
} else {
rowEnd = outerSource.length
}
return Math.min(rowStart + Math.max(0, col), rowEnd)
}
}
/**
* One rendered segment on a visual row. `visualStart`/`visualEnd` are
* 0-indexed columns within the row (visualEnd exclusive). `sourceStart`/
* `sourceEnd` are byte offsets into outerSource (sourceEnd exclusive).
*
* For verbatim text (no formatting), visualEnd - visualStart ==
* sourceEnd - sourceStart. For rendered markdown like `**bold**`, the
* visual span is 4 cells and the source span is 8 bytes.
*
* Spans within a row must be contiguous and non-overlapping in visual
* coordinates. Source coordinates need not be contiguous (a `[link](url)`
* has rendered text "link" but the URL bytes are skipped in source span
* order). Spans are ordered by visualStart.
*/
export type InlineSpan = {
visualStart: number
visualEnd: number
sourceStart: number
sourceEnd: number
}
/**
* For each visual row, the ordered list of spans on that row. Empty
* array allowed (row with no content; e.g. blank inline section). Length
* is the row count.
*/
export type InlineSpanTable = readonly (readonly InlineSpan[])[]
export function inlineOffsetFor(
outerSource: string,
spansPerRow: InlineSpanTable
): (visualRow: number, col: number) => number {
return (visualRow, col) => {
if (visualRow < 0) {
return 0
}
if (visualRow >= spansPerRow.length) {
return outerSource.length
}
const spans = spansPerRow[visualRow]!
if (spans.length === 0) {
// Row had no source-mapped content. Best we can do is "first byte
// of the next row's content, or end of source."
for (let r = visualRow + 1; r < spansPerRow.length; r++) {
const nextSpans = spansPerRow[r]!
if (nextSpans.length > 0) {
return nextSpans[0]!.sourceStart
}
}
return outerSource.length
}
const c = Math.max(0, col)
// Before the first span on this row → snap to its source start.
if (c < spans[0]!.visualStart) {
return spans[0]!.sourceStart
}
// Find the span containing col.
for (let i = 0; i < spans.length; i++) {
const s = spans[i]!
if (c >= s.visualStart && c < s.visualEnd) {
// Within this span. The col offset within the span maps linearly
// into the source span ONLY when source-len == visual-len (verbatim).
// For rendered spans where they differ, we proportionally map:
// col == visualStart → sourceStart, col == visualEnd → sourceEnd.
const visualLen = s.visualEnd - s.visualStart
const sourceLen = s.sourceEnd - s.sourceStart
if (visualLen === sourceLen) {
return s.sourceStart + (c - s.visualStart)
}
// Proportional: round so that "all visual cells of the span have
// a source position" (no orphan cell falling between two source
// chars). For c == visualStart the formula gives sourceStart;
// for c == visualEnd - 1 the formula gives ~sourceEnd - 1.
const t = visualLen > 0 ? (c - s.visualStart) / visualLen : 0
return s.sourceStart + Math.round(t * sourceLen)
}
}
// Past the last span on this row → snap to its source end.
return spans[spans.length - 1]!.sourceEnd
}
}
/**
* Builder helper for the common case where you have an array of strings
* (visual rows) and want a SimpleOffsetMap assuming each row corresponds
* to one source line and rows are joined by '\n' in source.
*
* Returns a fresh Uint32Array. NOT for soft-wrap — callers that know
* about wrap should build the array themselves with duplicate row-starts
* for wrapped continuations.
*/
export function buildLineStartsFromRows(rows: readonly string[]): SimpleOffsetMap {
const out = new Uint32Array(rows.length)
let off = 0
for (let i = 0; i < rows.length; i++) {
out[i] = off
off += rows[i]!.length + 1
}
return out
}
/** Re-export for SourceRange consumers. */
export type GetOffset = SourceRange['getOffset']

View File

@@ -0,0 +1,142 @@
/**
* Source-range registry.
*
* Keyed on `${msgId}::${blockIndex}` so that re-mounting a range (e.g. when
* a virtual-scrolled message scrolls back into view) re-uses its previous
* RangeId. This means selection points anchored to a range survive
* unmount/remount cycles correctly.
*
* The registry outlives the DOM: a range stays registered until its
* message is evicted from the transcript history. The host app calls
* `evictMessage(msgId)` from the history-cap path.
*
* The registry is a module-level singleton. This is one piece of "global
* state" but it's confined to a single small module with a tiny API; any
* test that needs isolation calls `resetRegistry()`.
*/
import type { RangeId, SourceRange } from './types.js'
const ranges = new Map<RangeId, SourceRange>()
const byKey = new Map<string, RangeId>()
let nextId = 1
function rangeKey(msgId: string, blockIndex: number): string {
return `${msgId}\x00${blockIndex}`
}
export type RegisterInput = {
msgId: string
blockIndex: number
outerSource: string
/** Defaults to `outerSource`. */
innerSource?: string
/** Defaults to 0. */
innerOffset?: number
/**
* Total visual-row count this range rendered to. For unmounted /
* not-yet-measured ranges, pass 1 (placeholder — selection won't be
* able to anchor inside it until a real measurement arrives).
*/
visualLineCount: number
/**
* (visualRow, col) → byte offset into outerSource.
*
* Plain-text helper: see `simpleOffsetFor(outerSource, lineStarts)`.
* Inline-markdown helper: see `inlineOffsetFor(spansPerRow)`.
*
* For ranges that lack a measurement yet, pass `() => 0` and re-register
* later when measured — toCopyText will snap selections inside the range
* to offset 0 in the interim (no source leak; the range still emits its
* outerSource when fully covered).
*/
getOffset: (visualRow: number, col: number) => number
}
/**
* Register a source range. Returns the (possibly recycled) RangeId.
* If a range with the same (msgId, blockIndex) is already registered,
* the SourceRange is updated in place and the same id is returned —
* callers don't have to coordinate unmount/remount themselves.
*/
export function registerRange(input: RegisterInput): RangeId {
const key = rangeKey(input.msgId, input.blockIndex)
const existing = byKey.get(key)
const id = existing ?? nextId++
const innerSource = input.innerSource ?? input.outerSource
const innerOffset = input.innerOffset ?? 0
const range: SourceRange = {
id,
msgId: input.msgId,
blockIndex: input.blockIndex,
outerSource: input.outerSource,
innerSource,
innerOffset,
visualLineCount: input.visualLineCount,
getOffset: input.getOffset,
domNode: existing ? (ranges.get(existing)?.domNode ?? null) : null
}
ranges.set(id, range)
if (!existing) {
byKey.set(key, id)
}
return id
}
/** Update only the DOM node pointer (called from anchor.tsx ref). */
export function setRangeDom(id: RangeId, domNode: unknown): void {
const range = ranges.get(id)
if (range) {
range.domNode = domNode
}
}
/** Get a range by id. Returns undefined if it has been evicted. */
export function getRange(id: RangeId): SourceRange | undefined {
return ranges.get(id)
}
/**
* Evict all ranges belonging to a message. Called from the history-cap
* path when a message is dropped from the transcript. The msg's ranges
* are gone forever — any selection point still pointing at them is
* stale and must be repaired by the caller (truncate-to-survivor policy).
*/
export function evictMessage(msgId: string): RangeId[] {
const evicted: RangeId[] = []
for (const [key, id] of byKey) {
const range = ranges.get(id)
if (range && range.msgId === msgId) {
evicted.push(id)
ranges.delete(id)
byKey.delete(key)
}
}
return evicted
}
/**
* All currently-registered ranges. Used by toCopyText to assemble copy
* text in document order. The host app must provide a message-order
* function to break ties between ranges from different messages
* (insertion order isn't enough — messages can be popped from the
* middle on /undo).
*/
export function listRanges(): SourceRange[] {
return Array.from(ranges.values())
}
/** Test helper: wipe everything. Not used in production. */
export function resetRegistry(): void {
ranges.clear()
byKey.clear()
nextId = 1
}

View File

@@ -0,0 +1,443 @@
/**
* Assemble clipboard text from two SelectionPoints + the transcript.
*
* This is the entire copy pipeline. Pure function. No screen-buffer access,
* no DOM access, no globals — just point arithmetic over registered
* SourceRanges.
*
* Algorithm:
* 1. Order the two endpoints into (lo, hi).
* 2. Collect every SourceRange whose msg falls in [lo.msg .. hi.msg] in
* document order.
* 3. For each range, compute the source-byte slice it contributes:
* - middle ranges (not touched by either endpoint) → entire outerSource
* - lo's range → from lo's source offset to end
* - hi's range → from start to hi's source offset
* - same range (lo and hi point into it) → from lo to hi
* 4. Apply the fence-stripping rule: if BOTH endpoints land inside the
* inner body of the same range, swap outerSource for innerSource and
* adjust offsets accordingly.
* 5. Join the slices with newlines. The separator between two adjacent
* ranges is one newline; nothing else is added (the source already
* has its own trailing newlines if appropriate).
*/
import { getRange, listRanges } from './registry.js'
import type { MsgSnapshot, SelectionPoint, SourceRange } from './types.js'
type Point = SelectionPoint
/**
* Compare two ranges in document order using msg order then blockIndex.
* Used to sort the ranges between lo and hi.
*/
function compareRanges(
a: SourceRange,
b: SourceRange,
msgOrder: ReadonlyMap<string, number>
): number {
const oa = msgOrder.get(a.msgId) ?? Number.POSITIVE_INFINITY
const ob = msgOrder.get(b.msgId) ?? Number.POSITIVE_INFINITY
if (oa !== ob) {
return oa - ob
}
return a.blockIndex - b.blockIndex
}
/**
* Within a single range, convert (visualLine, col) to a source-byte offset.
*
* The map gives the source offset where each visual row begins. The column
* is added on top: visual columns map 1-to-1 to source bytes WITHIN a
* single row (assuming ASCII or single-codepoint chars). For unicode-aware
* handling, callers should pre-clamp `col` to the on-screen column index
* of the desired character. This function performs no width conversion.
*
* If `visualLine >= visualLineCount`, defers to `getOffset` with the
* last-row index — the offset map's own clamping kicks in there. This
* avoids snapping to `outerSource.length` when the block had no
* fragment hit and the visual row is just a soft-wrap continuation
* past the block's tracked source-line count (a common case: source
* line wraps to multiple visual rows, but the block was registered
* with `visualLineCount = source-line-count`).
*
* If `visualLine < 0`, returns 0.
*/
function pointToOffset(range: SourceRange, visualLine: number, col: number): number {
if (visualLine < 0) {
return 0
}
if (visualLine >= range.visualLineCount) {
// Defer to the last tracked row + the column. The offset map's
// per-row clamping will cap at the row's source-end. This is more
// useful than `outerSource.length`, which would copy the entire
// remaining block on what's likely just a wrap-continuation click.
return range.getOffset(Math.max(0, range.visualLineCount - 1), Math.max(0, col))
}
return range.getOffset(visualLine, Math.max(0, col))
}
/**
* Order two points so the smaller (earlier in the document) is first.
*
* Order rules:
* - before-all < anything < after-all
* - in-range comparison: by (msgOrder, blockIndex, visualLine, col)
* - gap with after/before refs falls between the two referenced ranges;
* gap.afterRangeId == X and another point on range X → the gap comes
* after range X.
*/
function orderPoints(
a: Point,
b: Point,
msgOrder: ReadonlyMap<string, number>
): [Point, Point] {
if (compareToA(a, b, msgOrder) <= 0) {
return [a, b]
}
return [b, a]
}
/**
* Negative when a < b, positive when a > b, 0 when equal in document order.
*/
function compareToA(a: Point, b: Point, msgOrder: ReadonlyMap<string, number>): number {
// Universal endpoints
if (a.kind === 'before-all') {
return b.kind === 'before-all' ? 0 : -1
}
if (b.kind === 'before-all') {
return 1
}
if (a.kind === 'after-all') {
return b.kind === 'after-all' ? 0 : 1
}
if (b.kind === 'after-all') {
return -1
}
// Reduce gap → range-anchored point for comparison: a gap "after X" is
// (X-end + epsilon), "before X" is (X-start - epsilon).
const ar = reducePoint(a)
const br = reducePoint(b)
const ag = msgOrder.get(ar.msgId) ?? Number.POSITIVE_INFINITY
const bg = msgOrder.get(br.msgId) ?? Number.POSITIVE_INFINITY
if (ag !== bg) {
return ag - bg
}
if (ar.blockIndex !== br.blockIndex) {
return ar.blockIndex - br.blockIndex
}
if (ar.visualLine !== br.visualLine) {
return ar.visualLine - br.visualLine
}
return ar.col - br.col
}
type Reduced = {
msgId: string
blockIndex: number
visualLine: number
col: number
}
/** Reduce in-range / gap into a Reduced shape for ordering. */
function reducePoint(p: Exclude<Point, { kind: 'before-all' | 'after-all' }>): Reduced {
if (p.kind === 'in-range') {
const r = getRange(p.rangeId)
if (!r) {
return { msgId: '\uFFFF', blockIndex: 0, visualLine: 0, col: 0 }
}
return { msgId: r.msgId, blockIndex: r.blockIndex, visualLine: p.visualLine, col: p.col }
}
// gap
const afterId = p.afterRangeId
const beforeId = p.beforeRangeId
if (afterId != null) {
const r = getRange(afterId)
if (r) {
return {
msgId: r.msgId,
blockIndex: r.blockIndex,
// After the last visual row → position AFTER it.
visualLine: r.visualLineCount,
col: 0
}
}
}
if (beforeId != null) {
const r = getRange(beforeId)
if (r) {
return { msgId: r.msgId, blockIndex: r.blockIndex, visualLine: -1, col: 0 }
}
}
// Truly empty gap (no neighbors known): treat as far-end so two empty
// gaps compare equal.
return { msgId: '\uFFFF', blockIndex: 0, visualLine: 0, col: 0 }
}
/**
* Resolve a point to either:
* - { rangeId, offset } when it falls inside a range (in-range) OR
* when it's a gap whose adjacency uniquely places it at a known
* range's start/end (gap-after-X → end of X, gap-before-X → start
* of X)
* - null when it's before/after/in-an-empty-gap (the point contributes
* nothing to the output; the ranges between the two points are what
* matters)
*
* The gap resolution is what lets selections that anchor on the blank
* line between two messages emit clean output. Without it, gap endpoints
* always fall through to the "include the whole adjacent range" path,
* which is what the user gets when they drag across a gap.
*/
/**
* Clamp a source byte offset to the range's outerSource bounds.
* Used to defensively bound a `sourceOffset` arriving from the hit-test
* (in theory always in-bounds, but range re-registration could have
* shrunk outerSource between hit-test time and copy time).
*/
function clampOffset(range: SourceRange, offset: number): number {
if (offset < 0) {
return 0
}
if (offset > range.outerSource.length) {
return range.outerSource.length
}
return offset
}
function resolvePoint(p: Point): { rangeId: number; offset: number } | null {
if (p.kind === 'in-range') {
const r = getRange(p.rangeId)
if (!r) {
return null
}
// Fast path: the hit-test already resolved the source byte for us
// via a per-segment copySourceFragment tag. Use it verbatim — this
// is the byte-exact path for inline-formatted markdown (math, bold,
// links, code spans, etc.) where rendered cells ≠ source bytes.
if (p.sourceOffset !== undefined) {
return { rangeId: p.rangeId, offset: clampOffset(r, p.sourceOffset) }
}
return { rangeId: p.rangeId, offset: pointToOffset(r, p.visualLine, p.col) }
}
if (p.kind === 'gap') {
// gap-after-X: the gap is past the end of range X → resolve to
// (X, X.outerSource.length). When X is the lo endpoint, this means
// X contributes nothing (from == to == end). When X is the hi
// endpoint, this means X contributes its entire source (from 0 to
// end).
if (p.afterRangeId != null) {
const r = getRange(p.afterRangeId)
if (r) {
return { rangeId: p.afterRangeId, offset: r.outerSource.length }
}
}
// gap-before-Y: the gap is just before the start of range Y →
// resolve to (Y, 0). When Y is the hi endpoint, Y contributes
// nothing (from == to == 0). When Y is the lo endpoint, Y
// contributes its entire source.
if (p.beforeRangeId != null) {
const r = getRange(p.beforeRangeId)
if (r) {
return { rangeId: p.beforeRangeId, offset: 0 }
}
}
}
return null
}
export type ToCopyTextInput = {
anchor: Point
focus: Point
transcript: readonly MsgSnapshot[]
}
/**
* Main entry. Returns the clipboard text for a selection.
*
* Empty when the selection is empty (anchor == focus AND both point at
* nothing meaningful), or when the transcript is empty.
*/
export function toCopyText(input: ToCopyTextInput): string {
const { anchor, focus, transcript } = input
if (transcript.length === 0) {
return ''
}
// Build msg-id → order map once for ordering.
const msgOrder = new Map<string, number>()
for (const m of transcript) {
msgOrder.set(m.id, m.order)
}
const [lo, hi] = orderPoints(anchor, focus, msgOrder)
// Filter to ranges that lie in [lo .. hi] inclusive.
const all = listRanges().sort((a, b) => compareRanges(a, b, msgOrder))
const loResolved = resolvePoint(lo)
const hiResolved = resolvePoint(hi)
// Find the range index window.
// Stale rangeIds (the range was evicted between selection time and now)
// order to the far-end of the document via reducePoint's '\uFFFF' msgId
// fallback. This makes findFirstAtOrAfter / findLastAtOrBefore yield -1
// for them, which short-circuits below into an empty result. The
// expected lifecycle is that the host repairs the selection via the
// truncate-to-survivor policy when a msg is evicted; toCopyText's
// behavior here is the graceful-degradation backstop.
const startIdx = lo.kind === 'before-all' ? 0 : findFirstAtOrAfter(all, lo, msgOrder)
const endIdx = hi.kind === 'after-all' ? all.length - 1 : findLastAtOrBefore(all, hi, msgOrder)
if (startIdx > endIdx || startIdx === -1 || endIdx === -1) {
return ''
}
// Fence-stripping rule: if BOTH points land inside the inner body of
// the SAME range, emit innerSource sliced by inner-relative offsets.
if (
loResolved &&
hiResolved &&
loResolved.rangeId === hiResolved.rangeId &&
startIdx === endIdx
) {
const r = all[startIdx]!
const a = loResolved.offset
const b = hiResolved.offset
const innerStart = r.innerOffset
const innerEnd = r.innerOffset + r.innerSource.length
if (a >= innerStart && a <= innerEnd && b >= innerStart && b <= innerEnd) {
const lo2 = Math.min(a, b) - innerStart
const hi2 = Math.max(a, b) - innerStart
return r.innerSource.slice(lo2, hi2)
}
}
// General path: walk ranges, slice each.
const parts: string[] = []
for (let i = startIdx; i <= endIdx; i++) {
const r = all[i]!
let from = 0
let to = r.outerSource.length
if (i === startIdx && loResolved && loResolved.rangeId === r.id) {
from = loResolved.offset
}
if (i === endIdx && hiResolved && hiResolved.rangeId === r.id) {
to = hiResolved.offset
}
if (from < to) {
parts.push(r.outerSource.slice(from, to))
} else if (from === to && i !== startIdx && i !== endIdx) {
// Empty middle range — still include as a separator-only entry
// so blank blocks (rare) survive the round-trip.
parts.push('')
}
}
// Join with single newline. Trailing newlines in sources already exist
// where appropriate; we don't add extra.
return parts.join('\n')
}
/** Find first range that is >= point in document order. */
function findFirstAtOrAfter(
ranges: readonly SourceRange[],
point: Point,
msgOrder: ReadonlyMap<string, number>
): number {
for (let i = 0; i < ranges.length; i++) {
const r = ranges[i]!
// Compare: is this range's END >= point.start?
const rEnd: Reduced = {
msgId: r.msgId,
blockIndex: r.blockIndex,
visualLine: r.visualLineCount,
col: 0
}
if (compareReducedToPoint(rEnd, point, msgOrder) >= 0) {
return i
}
}
return -1
}
/** Find last range that is <= point in document order. */
function findLastAtOrBefore(
ranges: readonly SourceRange[],
point: Point,
msgOrder: ReadonlyMap<string, number>
): number {
for (let i = ranges.length - 1; i >= 0; i--) {
const r = ranges[i]!
const rStart: Reduced = { msgId: r.msgId, blockIndex: r.blockIndex, visualLine: 0, col: 0 }
if (compareReducedToPoint(rStart, point, msgOrder) <= 0) {
return i
}
}
return -1
}
function compareReducedToPoint(
a: Reduced,
p: Point,
msgOrder: ReadonlyMap<string, number>
): number {
if (p.kind === 'before-all') {return 1}
if (p.kind === 'after-all') {return -1}
const b = reducePoint(p)
const ag = msgOrder.get(a.msgId) ?? Number.POSITIVE_INFINITY
const bg = msgOrder.get(b.msgId) ?? Number.POSITIVE_INFINITY
if (ag !== bg) {return ag - bg}
if (a.blockIndex !== b.blockIndex) {return a.blockIndex - b.blockIndex}
if (a.visualLine !== b.visualLine) {return a.visualLine - b.visualLine}
return a.col - b.col
}

View File

@@ -0,0 +1,124 @@
/**
* Transcript-virtual selection coordinates.
*
* The TUI's source-of-truth is the `Msg[]` array of conversation messages.
* Selection endpoints are anchored to source ranges within those messages,
* NOT to screen cells. This decouples copy/paste fidelity from rendering
* concerns like soft-wrap, viewport culling, and drag-scroll.
*
* A SourceRange represents one contiguous span of original source text that
* was rendered to a contiguous block of visual rows. Markdown messages emit
* one range per block (paragraph, heading, fence, list, etc.). Plain
* messages and tool output emit one range covering the whole message.
*
* Each range carries a `mapVisualToSource` table built at render time that
* lets hitTest translate (visualRow, col) into a position in the outer
* source string. toCopyText slices outer/inner source by these positions.
*/
/** Stable handle for a SourceRange. Allocated by the registry. */
export type RangeId = number
/**
* One contiguous block of source text + the visual rendering of that text.
*
* `outerSource` is the original source string including any wrapper syntax
* (fence markers, blockquote markers, etc.). `innerSource` is the body
* without the wrapper — equal to `outerSource` when there is no wrapper
* (paragraphs, plain text, tool output).
*
* `mapVisualToSource[v]` = byte offset into `outerSource` where the visual
* row `v` begins. The end of row v is `mapVisualToSource[v+1]` or
* `outerSource.length` for the last row. This handles soft-wrap correctly:
* one source line that wrapped to N visual rows has N entries, each
* pointing into the same source line at the right column.
*/
export type SourceRange = {
/** Stable id assigned by the registry. */
readonly id: RangeId
/** Message this range belongs to. Used for inter-range ordering. */
readonly msgId: string
/** 0 for whole-msg ranges; ≥1 for per-block ranges within a msg. */
readonly blockIndex: number
/** Full source including any wrapper (e.g. fence markers). */
readonly outerSource: string
/** Body without wrapper. Equals outerSource when there is no wrapper. */
readonly innerSource: string
/** Byte offset in outerSource where innerSource begins. */
readonly innerOffset: number
/**
* Number of visual rows this range rendered to. Used by toCopyText to
* compute "did the selection cover this whole range" and to know what
* range a `visualLine == visualLineCount` (after-end) point refers to.
*/
readonly visualLineCount: number
/**
* (visualRow, col) → byte offset into outerSource.
*
* For ranges where rendered text == source text (code fences, plain
* messages, tool output), this is `rowStart[visualRow] + col`, clamped
* to the row's source-byte length.
*
* For ranges where inline markdown rendering is applied (paragraphs,
* headings), the renderer attaches per-segment `copySourceFragment`
* tags directly to the DOM, and the Ink hit-test returns a precomputed
* `sourceOffset` on the SelectionPoint — toCopyText uses that
* directly and skips this function. This `getOffset` is only consulted
* for clicks that didn't land on a fragment (e.g. trailing whitespace
* past the last token on a row, or non-MdInline blocks like fences).
*
* Callers are expected to clamp visualRow ∈ [0, visualLineCount].
* visualRow == visualLineCount returns outerSource.length.
*/
readonly getOffset: (visualRow: number, col: number) => number
/**
* The DOM node currently rendering this range. Mutated by anchor.tsx
* on mount/unmount. Null when the range is registered but unmounted
* (e.g. scrolled out of viewport). Typed as `unknown` here because
* the registry is dom-agnostic; hitTest casts as needed.
*/
domNode: unknown
}
/**
* A point in transcript-virtual space. Used as the anchor and focus of a
* selection. Survives DOM unmount cycles — only depends on the registry,
* which outlives any individual render.
*/
export type SelectionPoint =
/**
* Inside a known range.
*
* When `sourceOffset` is set, toCopyText uses it directly as the byte
* offset into the range's outerSource — bypassing `visualLine`/`col`
* resolution via getOffset. This is set by the hit-test when the click
* landed inside a `copySourceFragment`-tagged DOM node (per-segment
* markdown rendering): the renderer attached the exact source byte
* range to that segment, so width-math is unnecessary.
*
* When `sourceOffset` is unset, toCopyText falls back to
* `getOffset(visualLine, col)`.
*/
| { kind: 'in-range'; rangeId: RangeId; visualLine: number; col: number; sourceOffset?: number }
/** Before any range we know about (e.g. above the first message). */
| { kind: 'before-all' }
/** After all known ranges (e.g. below the last message). */
| { kind: 'after-all' }
/**
* In a gap between ranges (blank row, chrome, prompt). The selection
* snaps to the appropriate side of the gap based on drag direction;
* both adjacents are tracked so extendSelection / toCopyText can pick
* the right side. Either side may be null at the document edges.
*/
| { kind: 'gap'; afterRangeId: RangeId | null; beforeRangeId: RangeId | null }
/** Snapshot of a transcript message minimally needed by toCopyText. */
export type MsgSnapshot = {
/** Stable id matching what each SourceRange records. */
readonly id: string
/**
* Insertion-order index. Used to order ranges from different messages
* when assembling copy text. The transcript array index serves directly.
*/
readonly order: number
}

View File

@@ -76,6 +76,20 @@ declare module '@hermes/ink' {
readonly cleanup: () => void
}
/**
* Live Ink instance reference passed to copy-text overrides + exposed
* by `useInkInstance()`. Surfaces the minimum API needed for
* transcript-virtual selection/copy.
*/
export type InkInstance = {
readonly setCopyTextFn: (fn: ((self: InkInstance) => string) | null) => void
readonly getRootDom: () => unknown
readonly getSelectionBoundsScreen: () =>
| { readonly start: { readonly col: number; readonly row: number }; readonly end: { readonly col: number; readonly row: number } }
| null
readonly hasTextSelection: () => boolean
}
export type ScrollBoxHandle = {
readonly scrollTo: (y: number) => void
readonly scrollBy: (dy: number) => void
@@ -134,6 +148,11 @@ declare module '@hermes/ink' {
export function evictInkCaches(level?: EvictLevel): InkCacheSizes
export function forceRedraw(stdout?: NodeJS.WriteStream): boolean
export function getInkForStdout(stdout?: NodeJS.WriteStream): InkInstance | null
export function copyPointAt(rootDom: unknown, col: number, row: number):
| { kind: 'in-range'; rangeId: number; visualLine: number; col: number; sourceOffset?: number }
| { kind: 'gap'; afterRangeId: null | number; beforeRangeId: null | number }
export function findRangeDom(rootDom: unknown, id: number): unknown
export function render(node: React.ReactNode, options?: NodeJS.WriteStream | RenderOptions): Instance
export function useApp(): { readonly exit: (error?: Error) => void }