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