Files
hermes-agent/ui-tui/src/lib/copySource/hitTestBridge.ts
T
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

44 lines
1.3 KiB
TypeScript

/**
* 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): SelectionPoint {
const raw = (inkCopyPointAt as (root: unknown, col: number, row: number) => RawPoint)(
rootDom,
col,
row
)
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 }
}