From 46e2ff57f7f51cc7fd22083f749680ccfcda815c Mon Sep 17 00:00:00 2001 From: Packet Date: Fri, 15 May 2026 18:31:19 -0400 Subject: [PATCH] fix(tui): byte-exact copy for inline-formatted markdown + thinking content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 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). --- .../hermes-ink/src/ink/components/Text.tsx | 29 +- .../hermes-ink/src/ink/copyPointHitTest.ts | 76 ++++- .../packages/hermes-ink/src/ink/node-cache.ts | 33 +++ .../src/ink/render-node-to-output.ts | 111 ++++++- .../hermes-ink/src/ink/squash-text-nodes.ts | 29 +- ui-tui/packages/hermes-ink/src/ink/styles.ts | 30 ++ ui-tui/src/__tests__/markdown.test.ts | 74 ++++- ui-tui/src/components/markdown.tsx | 276 ++++++++++++++---- ui-tui/src/components/messageLine.tsx | 2 + ui-tui/src/components/thinking.tsx | 81 +++-- ui-tui/src/lib/copySource/hitTestBridge.ts | 28 +- ui-tui/src/lib/copySource/toCopyText.ts | 26 ++ ui-tui/src/lib/copySource/types.ts | 24 +- ui-tui/src/types/hermes-ink.d.ts | 2 +- 14 files changed, 694 insertions(+), 127 deletions(-) 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