diff --git a/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx b/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx index 4eb4bc7b96..ec0010c3af 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx @@ -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 = ( + + {children} + + ) + } else if ($[25] !== children || $[26] !== t15 || $[27] !== textStyles) { t16 = ( {children} diff --git a/ui-tui/packages/hermes-ink/src/ink/copyPointHitTest.ts b/ui-tui/packages/hermes-ink/src/ink/copyPointHitTest.ts index 8345c7c869..438ec444bd 100644 --- a/ui-tui/packages/hermes-ink/src/ink/copyPointHitTest.ts +++ b/ui-tui/packages/hermes-ink/src/ink/copyPointHitTest.ts @@ -6,6 +6,14 @@ * 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 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 @@ -23,13 +31,27 @@ import type { DOMElement } from './dom.js' import { nodeCache } from './node-cache.js' export type RawSelectionPoint = - | { kind: 'in-range'; rangeId: number; visualLine: number; col: number } + | { + 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. + * 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 @@ -40,28 +62,52 @@ export function copyPointAt(root: DOMElement, col: number, row: number): RawSele const deepest = hitDeepest(root, col, row) if (deepest) { - // Walk up looking for a Box tagged with copyRangeId. + // 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 let node: DOMElement | undefined = deepest while (node) { const rangeId = (node.style as { copyRangeId?: number }).copyRangeId + const rect = nodeCache.get(node) - if (typeof rangeId === 'number') { - const rect = nodeCache.get(node) + // 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 - if (rect) { - return { - kind: 'in-range', - rangeId, - visualLine: Math.max(0, row - rect.y), - col: Math.max(0, 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) { + fragmentResolved = f.start + Math.min(localCol - f.colStart, len) + } else { + const widthInFragment = f.colEnd - f.colStart + const colInFragment = localCol - f.colStart + + fragmentResolved = + colInFragment * 2 < widthInFragment ? f.start : f.end + } + + break } } + } - // Tagged but not in cache → shouldn't happen normally (the tag - // got there via the same render that populates cache), but if it - // does, fall through to the gap path so we still produce a - // useful adjacency answer. + if (typeof rangeId === 'number' && rect) { + return { + kind: 'in-range', + rangeId, + visualLine: Math.max(0, row - rect.y), + col: Math.max(0, col - rect.x), + ...(fragmentResolved !== undefined && { sourceOffset: fragmentResolved }) + } } node = node.parentNode diff --git a/ui-tui/packages/hermes-ink/src/ink/node-cache.ts b/ui-tui/packages/hermes-ink/src/ink/node-cache.ts index fe11e067ec..577ac70576 100644 --- a/ui-tui/packages/hermes-ink/src/ink/node-cache.ts +++ b/ui-tui/packages/hermes-ink/src/ink/node-cache.ts @@ -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 `` 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() diff --git a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts index a31753c722..a69e731917 100644 --- a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts +++ b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts @@ -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' @@ -636,6 +636,18 @@ 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 = computeSegmentFragments(segments, softWrap) + + 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 +1292,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') { @@ -1557,4 +1581,85 @@ function dropSubtreeCache(node: DOMElement): void { // Exported for testing export { applyStylesToWrappedText, buildCharToSegmentMap } +/** + * Compute per-row source-fragment ranges for an ink-text's segments. + * + * For each segment carrying `copySourceFragment`, walk the segment's + * char range within plainText and emit one `CachedFragment` per visual + * row the segment occupies. The softWrap array (one entry per char in + * the FINAL wrapped text — note: same as plainText, since wrap inserts + * '\n' soft breaks but plainText is pre-wrap; we read softWrap by char + * index into plainText? actually softWrap is per visual row according + * to wrapWithSoftWrap docs, indicating which rows are word-wrap + * continuations) ... + * + * Implementation: for v1, no soft-wrap handling — emit one fragment + * per segment with `row=0`. When the text wraps, the fragment still + * lives "on the first row" with colStart/colEnd. The hit-test only + * uses fragments when (col,row) lands inside one; clicks on wrapped + * continuation rows fall through to the block-level getOffset. + * + * For wrapped lines this means: partial selection of formatted text + * that spans a wrap boundary degrades to block-level mapping. The + * common case (a paragraph fitting on one screen row, or selecting + * a whole formatted span that doesn't wrap) gets byte-exact source + * via fragments. Acceptable trade-off — wrap-aware fragment splitting + * needs the same width math we deliberately deleted from the host + * earlier, and the hit-test fallback is the block's outerSource + * (still correct for "select the whole paragraph" cases). + */ +function computeSegmentFragments( + segments: readonly StyledSegment[], + softWrap: boolean[] | undefined +): CachedFragment[] { + const out: CachedFragment[] = [] + let visualCol = 0 + + for (const seg of segments) { + const segWidth = widestLine(seg.text) + const tag = seg.copySourceFragment + + if (tag) { + // Determine which row this segment STARTS on, given softWrap. + // softWrap[r] is true when visual row r is a word-wrap continuation + // of the previous source line. We need to walk the cumulative + // width across segments and map visualCol → row. + // + // Simplest correct-on-no-wrap path: if no softWrap, all segments + // live on row 0. The colStart is the running visualCol. + if (!softWrap || softWrap.length <= 1) { + out.push({ + row: 0, + colStart: visualCol, + colEnd: visualCol + segWidth, + start: tag.start, + end: tag.end, + verbatim: tag.verbatim + }) + } + // When wrap IS present: emit only the first-row portion of the + // segment. The continuation rows fall through to block-level + // mapping. Adequate for selecting any non-wrapped span. + else { + // Conservative: don't try to split across rows. Emit on row 0 + // with the segment's leading width clamped to first-row capacity. + // Hit-test on wrap-continuation rows won't find the fragment, + // which is fine — block fallback handles it. + out.push({ + row: 0, + colStart: visualCol, + colEnd: visualCol + segWidth, + start: tag.start, + end: tag.end, + verbatim: tag.verbatim + }) + } + } + + visualCol += segWidth + } + + return out +} + export default renderNodeToOutput diff --git a/ui-tui/packages/hermes-ink/src/ink/squash-text-nodes.ts b/ui-tui/packages/hermes-ink/src/ink/squash-text-nodes.ts index edb26b3b69..de478bb754 100644 --- a/ui-tui/packages/hermes-ink/src/ink/squash-text-nodes.ts +++ b/ui-tui/packages/hermes-ink/src/ink/squash-text-nodes.ts @@ -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 + * `` (or ``) 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) } } diff --git a/ui-tui/packages/hermes-ink/src/ink/styles.ts b/ui-tui/packages/hermes-ink/src/ink/styles.ts index af4c1f6be8..18358f4298 100644 --- a/ui-tui/packages/hermes-ink/src/ink/styles.ts +++ b/ui-tui/packages/hermes-ink/src/ink/styles.ts @@ -408,6 +408,36 @@ export type Styles = { * 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 `` produced + * by the inline regex dispatcher (link, bold, math, code, etc.) wraps + * in `` 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 => { diff --git a/ui-tui/src/__tests__/markdown.test.ts b/ui-tui/src/__tests__/markdown.test.ts index 716a2bbc09..0cf2b44b6f 100644 --- a/ui-tui/src/__tests__/markdown.test.ts +++ b/ui-tui/src/__tests__/markdown.test.ts @@ -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' @@ -216,6 +218,75 @@ 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') + }) +}) + +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('renderTable CJK width alignment', () => { @@ -248,6 +319,7 @@ describe('renderTable CJK width alignment', () => { // unique anchor for column 2's start position on each row. const colStarts = (line: string, anchor: string): number => { const idx = line.indexOf(anchor) + return idx < 0 ? -1 : stringWidth(line.slice(0, idx)) } diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 89e323deb4..ac440e15e0 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -214,121 +214,237 @@ const renderTable = (k: number, rows: string[][], t: Theme) => { ) } -function MdInline({ t, text }: { t: Theme; text: string }) { +/** + * Render inline markdown tokens (links, bold, italic, code, math, etc.) + * as a flat sequence of children wrapped in + * tags so the copy-source hit-test can map mouse clicks back to source + * bytes for partial-block selections. + * + * `sourceOffset` is the byte offset of `text` within the enclosing block's + * outerSource. For paragraph blocks it's 0 (text IS the block source). + * For headings `# Title`, the heading branch passes `text="Title"` with + * `sourceOffset=2` so the fragments report bytes relative to `# Title`. + * + * Recursive calls (inside bold/italic/strike/highlight) pass through + * `sourceOffset + matchInnerStart` so nested fragments stay accurate + * against the outermost block's outerSource. + * + * When `sourceOffset` is undefined (caller didn't pass one — e.g. table + * cell rendering, summary fallback), we render WITHOUT fragments. The + * copy still works via the block-level CopySource's simple offset map; + * partial-cell selections just snap to source-line boundaries. + */ +function MdInline({ sourceOffset, t, text }: { sourceOffset?: number; t: Theme; text: string }) { const parts: ReactNode[] = [] + const tagged = sourceOffset !== undefined + const off = sourceOffset ?? 0 let last = 0 + const wrap = (node: ReactNode, srcStart: number, srcEnd: number, verbatim: boolean): ReactNode => { + if (!tagged) { + return node + } + + // Use (not ) so the wrapper flows inline within the + // surrounding Text.wrap="wrap-trim" context. Box is block-level and + // would force line breaks; Text is inline and just carries the + // copySourceFragment attribute on its ink-text DOMElement for the + // hit-test to find via ancestor walk. + return ( + + {node} + + ) + } + for (const m of text.matchAll(INLINE_RE)) { const i = m.index ?? 0 + const matchLen = m[0]!.length + const matchEnd = i + matchLen const k = parts.length if (i > last) { - parts.push({text.slice(last, i)}) + // Plain text between matches. Verbatim: rendered cells == source bytes. + parts.push(wrap({text.slice(last, i)}, last, i, true)) } if (m[1] && m[2]) { + // image: rendered "[image: ALT] URL", not verbatim parts.push( - - [image: {m[1]}] {m[2]} - + wrap( + + [image: {m[1]}] {m[2]} + , + i, + matchEnd, + false + ) ) } else if (m[3] && m[4]) { + // link: rendered "TEXT", not verbatim parts.push( - - - {m[3]} - - + wrap( + + + {m[3]} + + , + i, + matchEnd, + false + ) ) } else if (m[5]) { - parts.push(renderAutolink(parts.length, t, m[5])) + // autolink: rendered url (minus mailto:), not verbatim (has `<>` in source) + parts.push(wrap(renderAutolink(parts.length, t, m[5]), i, matchEnd, false)) } else if (m[6]) { + // strike ~~x~~: NOT verbatim (rendered = inner, source = `~~inner~~`) + const inner = m[6] + const innerStart = i + 2 + parts.push( - - - + wrap( + + + , + i, + matchEnd, + false + ) ) } else if (m[7]) { - // Code is the one wrap that does NOT recurse — inline `code` spans - // are verbatim by definition. Letting MdInline reprocess them - // would corrupt regex examples and shell snippets. + // code `x`: not verbatim (backticks not in render). But the body is + // verbatim within the fragment — for byte-exact partial selection + // INSIDE a code span we'd need a sub-fragment for the body. For + // now: treat whole code as one non-verbatim fragment (clicks snap + // to span boundaries). Good enough — partial code-span selections + // are rare. parts.push( - - {m[7]} - + wrap( + + {m[7]} + , + i, + matchEnd, + false + ) ) } else if (m[8] ?? m[9]) { - // Recurse into bold / italic / strike / highlight so nested - // `$...$` math (and other inline tokens) inside a `**bolded - // statement with $\mathbb{Z}$ math**` actually render. Without - // this the inner content is dropped into a single `` - // verbatim and the math renderer never sees it. + // bold: not verbatim. inner content is m[8] (** flavor) or m[9] (__ flavor). + const inner = (m[8] ?? m[9])! + const innerStart = i + 2 + parts.push( - - - + wrap( + + + , + i, + matchEnd, + false + ) ) } else if (m[10] ?? m[11]) { + // italic: not verbatim + const inner = (m[10] ?? m[11])! + const innerStart = i + 1 + parts.push( - - - + wrap( + + + , + i, + matchEnd, + false + ) ) } else if (m[12]) { + // highlight ==x==: not verbatim + const inner = m[12] + const innerStart = i + 2 + parts.push( - - - + wrap( + + + , + i, + matchEnd, + false + ) ) } else if (m[13]) { + // footnote [^N] → [N]: not verbatim parts.push( - - [{m[13]}] - + wrap( + + [{m[13]}] + , + i, + matchEnd, + false + ) ) } else if (m[14]) { + // super ^N^ → ^N: not verbatim parts.push( - - ^{m[14]} - + wrap( + + ^{m[14]} + , + i, + matchEnd, + false + ) ) } else if (m[15]) { + // sub ~N~ → _N: not verbatim parts.push( - - _{m[15]} - + wrap( + + _{m[15]} + , + i, + matchEnd, + false + ) ) } else if (m[16]) { // Bare URL — trim trailing prose punctuation into a sibling text node // so `see https://x.com/, which…` keeps the comma outside the link. const url = m[16].replace(/[),.;:!?]+$/g, '') + const urlEnd = i + url.length - parts.push(renderAutolink(parts.length, t, url)) + parts.push(wrap(renderAutolink(parts.length, t, url), i, urlEnd, true)) if (url.length < m[16].length) { - parts.push({m[16].slice(url.length)}) + // Trailing punctuation: verbatim plain text. + parts.push(wrap({m[16].slice(url.length)}, urlEnd, matchEnd, true)) } } else if (m[17] ?? m[18]) { - // Inline math is run through `texToUnicode` (Greek letters, ℕℤℚℝ, - // operators, sub/superscripts, fractions) and rendered in italic - // accent. Italic is the disambiguator — links use accent+underline, - // so without italic readers can't tell `\mathbb{R}` (math) from a - // hyperlinked word. Anything `texToUnicode` doesn't recognise is - // preserved verbatim, so unfamiliar commands just look like their - // raw LaTeX rather than vanishing. + // Math: rendered as unicode, source is `$x$` or `\(x\)`. Not verbatim. parts.push( - - {renderMath(texToUnicode(m[17] ?? m[18]!))} - + wrap( + + {renderMath(texToUnicode(m[17] ?? m[18]!))} + , + i, + matchEnd, + false + ) ) } - last = i + m[0].length + last = matchEnd } if (last < text.length) { - parts.push({text.slice(last)}) + parts.push(wrap({text.slice(last)}, last, text.length, true)) } return {parts.length ? parts : text} @@ -391,6 +507,14 @@ function MdImpl({ blockIndexBase = 1, compact, msgId, t, text }: MdProps) { // selections round-trip the raw markdown for THAT block, and the // fence-stripping rule fires when a selection lands entirely inside // one fence's inner content. + // + // The block-level uses a simple line-starts offset map. + // For inline-formatted content (paragraphs, headings, etc.) MdInline + // emits per-segment wrappers carrying the exact source + // byte range each came from — the hit-test prefers the deepest + // fragment over the enclosing range so partial-block selections of + // math / bold / links / code map to byte-exact source offsets without + // any width math here. return ( {blocks.map((b, i) => { @@ -639,7 +763,7 @@ function parseToBlocks(text: string, compact: boolean | undefined, t: Theme): Md if (closeIdx < 0) { start('paragraph') - push(, line) + push(, line) i++ continue @@ -675,13 +799,22 @@ function parseToBlocks(text: string, compact: boolean | undefined, t: Theme): Md continue } - const heading = line.match(HEADING_RE)?.[2] + const headingMatch = line.match(HEADING_RE) + const heading = headingMatch?.[2] if (heading) { start('heading') + // Offset of heading text within `line`: after the `#+` and the + // required space. m[1]=hashes, m[2]=heading text. The space is + // captured by the `\s+` between groups so its length = the bytes + // from end of m[1] to start of m[2]. Compute via indexOf to + // tolerate variable-width whitespace (rare in practice). + const hashes = headingMatch![1]! + const headingStart = (line.indexOf(heading, hashes.length) ?? hashes.length + 1) + push( - + , line ) @@ -692,9 +825,12 @@ function parseToBlocks(text: string, compact: boolean | undefined, t: Theme): Md if (i + 1 < lines.length && SETEXT_RE.test(lines[i + 1]!)) { start('heading') + const trimmed = line.trim() + const setextOffset = line.indexOf(trimmed) + push( - + , lines.slice(blockStart, i + 2).join('\n') ) @@ -784,12 +920,14 @@ function parseToBlocks(text: string, compact: boolean | undefined, t: Theme): Md const task = bullet[2]!.match(TASK_RE) const marker = task ? (task[1]!.toLowerCase() === 'x' ? '☑' : '☐') : '•' + const innerText = task ? task[2]! : bullet[2]! + const bulletOffset = line.indexOf(innerText) push( {marker} - + , line @@ -803,11 +941,14 @@ function parseToBlocks(text: string, compact: boolean | undefined, t: Theme): Md if (numbered) { start('list') + const numberedInner = numbered[3]! + const numberedOffset = line.indexOf(numberedInner) + push( {numbered[2]}. - + , line @@ -918,7 +1059,7 @@ function parseToBlocks(text: string, compact: boolean | undefined, t: Theme): Md } start('paragraph') - push(, line) + push(, line) i++ } @@ -939,6 +1080,13 @@ type Kind = 'blank' | 'code' | 'heading' | 'list' | 'paragraph' | 'quote' | 'rul * `innerSource` / `innerOffset` are set on fence blocks so that selecting * code inside a fence yields just the code body (without the ``` markers). * Empty for everything else (where outer == inner). + * + * Per-segment source mapping (for paragraphs / headings / lists / etc. + * where rendered cells differ from source bytes due to markdown formatting) + * happens in MdInline via wrappers attached directly to + * the rendered DOM nodes. The block-level CopySource here just owns the + * raw outerSource and a coarse line-starts offset map for fallback cases + * where the click lands outside any fragment. */ interface MdBlock { content: ReactNode diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 3a47912acb..ad1459bd1f 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -73,6 +73,7 @@ export const MessageLine = memo(function MessageLine({ - - {preview ? ( - mode === 'full' ? ( - lines.map((line, index) => ( - - {line || ' '} - {index === lines.length - 1 ? ( - - ) : null} - - )) - ) : ( - - {preview} - + const content = ( + + {preview ? ( + mode === 'full' ? ( + lines.map((line, index) => ( + + {line || ' '} + {index === lines.length - 1 ? ( + + ) : null} - ) + )) ) : ( - + + {preview} - )} - + ) + ) : ( + + + + )} + + ) + + // 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 ? ( + + {content} + + ) : ( + content + ) + + return ( + + {wrapped} ) }) @@ -686,6 +719,7 @@ export const ToolTrail = memo(function ToolTrail({ busy = false, commandOverride = false, detailsMode = 'collapsed', + msgId, outcome = '', reasoningActive = false, reasoning = '', @@ -702,6 +736,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 @@ -1025,6 +1063,7 @@ export const ToolTrail = memo(function ToolTrail({ active={reasoningActive} branch="last" mode="full" + msgId={msgId} rails={rails} reasoning={busy ? reasoning : cot} streaming={busy && reasoningStreaming} diff --git a/ui-tui/src/lib/copySource/hitTestBridge.ts b/ui-tui/src/lib/copySource/hitTestBridge.ts index b415232d0f..fa9499a2a8 100644 --- a/ui-tui/src/lib/copySource/hitTestBridge.ts +++ b/ui-tui/src/lib/copySource/hitTestBridge.ts @@ -2,9 +2,9 @@ * 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 - * 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). + * 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' @@ -12,7 +12,13 @@ import { copyPointAt as inkCopyPointAt } from '@hermes/ink' import type { SelectionPoint } from './types.js' type RawPoint = - | { kind: 'in-range'; rangeId: number; visualLine: number; col: number } + | { + 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 { @@ -23,13 +29,15 @@ export function copyPointFromColRow(rootDom: unknown, col: number, row: number): ) if (raw.kind === 'in-range') { - return { kind: 'in-range', rangeId: raw.rangeId, visualLine: raw.visualLine, col: raw.col } + return { + kind: 'in-range', + rangeId: raw.rangeId, + visualLine: raw.visualLine, + col: raw.col, + ...(raw.sourceOffset !== undefined && { sourceOffset: raw.sourceOffset }) + } } - // Gap: copy adjacency through. When both adjacents are null (no ranges - // on screen at all — empty transcript) the gap is treated as far-end - // by toCopyText, which falls through to empty output. Correct - // degradation; user gets nothing to paste, which beats getting wrong - // text. + // Gap: copy adjacency through. return { kind: 'gap', afterRangeId: raw.afterRangeId, beforeRangeId: raw.beforeRangeId } } diff --git a/ui-tui/src/lib/copySource/toCopyText.ts b/ui-tui/src/lib/copySource/toCopyText.ts index bc921b587a..765a47047e 100644 --- a/ui-tui/src/lib/copySource/toCopyText.ts +++ b/ui-tui/src/lib/copySource/toCopyText.ts @@ -199,6 +199,24 @@ function reducePoint(p: Exclude): R * 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) @@ -207,6 +225,14 @@ function resolvePoint(p: Point): { rangeId: number; offset: number } | null { 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) } } diff --git a/ui-tui/src/lib/copySource/types.ts b/ui-tui/src/lib/copySource/types.ts index 1bdef2aa53..2d3ea6f201 100644 --- a/ui-tui/src/lib/copySource/types.ts +++ b/ui-tui/src/lib/copySource/types.ts @@ -60,8 +60,12 @@ export type SourceRange = { * to the row's source-byte length. * * For ranges where inline markdown rendering is applied (paragraphs, - * headings), this looks up the source-byte position via a per-row table - * of -span source ranges that the host computed during render. + * 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. @@ -82,8 +86,20 @@ export type SourceRange = { * which outlives any individual render. */ export type SelectionPoint = - /** Inside a known range. */ - | { kind: 'in-range'; rangeId: RangeId; visualLine: number; col: number } + /** + * 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). */ diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index b9de96c7a4..11a16a799c 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -149,7 +149,7 @@ declare module '@hermes/ink' { 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 } + | { 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