fix(tui): byte-exact copy for inline-formatted markdown + thinking content
Two follow-up bugs from the initial transcript-virtual selection rewrite:
1. Inline math / bold / links etc copied wrong: selecting 'E = mc^2 or'
from '$E = mc^2$ or' dropped the 'or' because the block-level
simple-offset map assumed rendered cells == source bytes. For
inline-formatted content (math $x$, bold **x**, links [text](url),
code `x`, etc.) that assumption is wrong.
2. Reasoning text in expanded ToolTrail copied as empty (no CopySource
wrapper) — clicking ctrl-c gave nothing.
Fix: rather than recomputing visual->source via width math at the host
level (the broken v1 approach), let the RENDERER attach per-segment
source-byte ranges to the rendered nodes. MdInline already knows
exactly which source bytes each <Text> came from — we just thread
that info through as style.copySourceFragment.
How it flows:
- styles.ts: add copySourceFragment style with start/end/verbatim fields
- Text.tsx: accept copySourceFragment as a prop and forward to ink-text
- squash-text-nodes.ts: propagate copySourceFragment through segment list
(child fragments override parent — bold containing math => inner math
fragment wins)
- render-node-to-output.ts: compute per-row CachedFragment[] for any
ink-text whose segments carry copySourceFragment, attach to nodeCache
- node-cache.ts: CachedLayout gains optional fragments[] field
- copyPointHitTest.ts: when walking up DOM looking for copyRangeId, also
check each rect's fragments[] for one covering (col, row); when found,
return precomputed sourceOffset on the SelectionPoint
- toCopyText.ts: resolvePoint uses point.sourceOffset directly when set,
bypassing getOffset
MdInline now wraps each emitted segment in <Text copySourceFragment={...}>
recording the exact source byte range (with verbatim flag for plain text
+ code spans, false for tokens where rendered cells != source bytes).
Recursive MdInline calls thread sourceOffset down so nested formatting
keeps correct byte positions against the outer block source.
Block branches that go through MdInline (paragraph, heading, bullet,
numbered, setext heading, math fallback) pass the appropriate
sourceOffset of inner text within the block's outerSource so headings
('# Title' source vs 'Title' rendered) map correctly.
Thinking/reasoning fix: ToolTrail and Thinking accept an msgId prop;
when set, Thinking wraps its rendered content in CopySource with
blockIndex=-1 (sorts before reply blocks). outerSource is the full
reasoning text — copy returns full text even when the on-screen preview
is truncated.
Tests: +3 new tests in markdown.test.ts exercising the fragment path
end-to-end (paragraph with inline math, byte-exact partial copy across
formatted spans). All 730 tests pass.
Trade-offs accepted for v2:
- Soft-wrap of an inline-formatted line: fragments only emit on the
FIRST visual row of a wrapped paragraph. Clicks on wrap-continuation
rows fall through to block-level mapping. Adequate — full-paragraph
selections still byte-exact; partial selections that don't cross a
wrap boundary are byte-exact; the only degraded case is partial
selections through a wrap boundary on formatted content.
- Code spans treated as one non-verbatim fragment (snap to start/end on
partial click). Partial code-span selections rare.
- Table cells, quotes, footnotes, definition lists: not yet plumbed
with sourceOffset (still simple-offset). Bullet/numbered/heading/
setext/paragraph/math fallback ARE plumbed (the common cases).
This commit is contained in:
@@ -45,6 +45,15 @@ type BaseProps = {
|
||||
*/
|
||||
readonly wrap?: Styles['textWrap']
|
||||
readonly children?: ReactNode
|
||||
|
||||
/**
|
||||
* Per-segment source byte range, forwarded to the underlying ink-text
|
||||
* element's style so the copy-source hit-test can map clicks back to
|
||||
* exact source bytes. See styles.ts `copySourceFragment` for the full
|
||||
* semantics. Used by markdown inline rendering — most Text consumers
|
||||
* leave it undefined.
|
||||
*/
|
||||
readonly copySourceFragment?: Styles['copySourceFragment']
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,6 +176,7 @@ export default function Text(t0: Props) {
|
||||
strikethrough: t3,
|
||||
inverse: t4,
|
||||
wrap: t5,
|
||||
copySourceFragment,
|
||||
children
|
||||
} = t0
|
||||
|
||||
@@ -314,10 +324,25 @@ export default function Text(t0: Props) {
|
||||
}
|
||||
|
||||
const textStyles = t14
|
||||
const t15 = memoizedStylesForWrap[wrap]
|
||||
const baseWrapStyle = memoizedStylesForWrap[wrap]
|
||||
// When a copySourceFragment is set on this Text, we MUST emit a fresh
|
||||
// style object (not the memoized wrap-style) so the fragment lands on
|
||||
// the ink-text's style. The memoization above caches the children +
|
||||
// style + textStyles tuple; copySourceFragment values are unique per
|
||||
// segment so the memo would miss anyway. We skip the cache lookup
|
||||
// entirely in this case to keep the render correct.
|
||||
const t15 = copySourceFragment ? { ...baseWrapStyle, copySourceFragment } : baseWrapStyle
|
||||
let t16
|
||||
|
||||
if ($[25] !== children || $[26] !== t15 || $[27] !== textStyles) {
|
||||
if (copySourceFragment) {
|
||||
// Non-memoized path: always re-emit. Cheap for typical markdown
|
||||
// rendering (each segment renders ≤1x per parent re-render).
|
||||
t16 = (
|
||||
<ink-text style={t15} textStyles={textStyles}>
|
||||
{children}
|
||||
</ink-text>
|
||||
)
|
||||
} else if ($[25] !== children || $[26] !== t15 || $[27] !== textStyles) {
|
||||
t16 = (
|
||||
<ink-text style={t15} textStyles={textStyles}>
|
||||
{children}
|
||||
|
||||
@@ -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 <Text> by markdown inline
|
||||
* rendering), the SelectionPoint includes a precomputed `sourceOffset`
|
||||
* — the EXACT byte offset within the enclosing range's outerSource.
|
||||
* Host code uses this directly without consulting `getOffset`, sidestepping
|
||||
* the width-math that would otherwise be needed for formatted segments
|
||||
* like `**bold**` or `$math$` where rendered cells ≠ source bytes.
|
||||
*
|
||||
* The returned SelectionPoint is structurally identical to the
|
||||
* `lib/copySource/types.ts` SelectionPoint (host code), but this module
|
||||
* doesn't import from there to avoid a circular dependency (host depends
|
||||
@@ -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
|
||||
|
||||
@@ -1,11 +1,43 @@
|
||||
import type { DOMElement } from './dom.js'
|
||||
import type { Rectangle } from './layout/geometry.js'
|
||||
|
||||
/**
|
||||
* One source-fragment entry attached to an ink-text node's cached layout.
|
||||
*
|
||||
* After ink-text renders its (possibly multi-segment, possibly wrapped)
|
||||
* content, the renderer emits one entry per `<ink-virtual-text>` child
|
||||
* with a `copySourceFragment` style. Each entry says "rows[r] cols
|
||||
* [colStart, colEnd) on the screen rect render bytes [start, end) of
|
||||
* the enclosing copy-source range's outerSource."
|
||||
*
|
||||
* Multiple entries on the same row are allowed (one per virtual-text
|
||||
* child); they're scanned linearly by the copy hit-test for the cell
|
||||
* containing (col, row) and `start`/`end` are returned.
|
||||
*
|
||||
* `verbatim` mirrors the same field on `Styles.copySourceFragment`:
|
||||
* verbatim segments map visual col → source byte 1:1 via
|
||||
* `start + (col - colStart)`; formatted segments snap to either
|
||||
* `start` or `end` based on which half of the segment was clicked.
|
||||
*/
|
||||
export type CachedFragment = {
|
||||
row: number
|
||||
colStart: number
|
||||
colEnd: number
|
||||
start: number
|
||||
end: number
|
||||
verbatim: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached layout bounds for each rendered node (used for blit + clearing).
|
||||
* `top` is the yoga-local getComputedTop() — stored so ScrollBox viewport
|
||||
* culling can skip yoga reads for clean children whose position hasn't
|
||||
* shifted (O(dirty) instead of O(mounted) first-pass).
|
||||
*
|
||||
* `fragments` is set on ink-text nodes whose children carry
|
||||
* copySourceFragment styles; it gives the hit-test a per-row, per-col
|
||||
* lookup table for byte-exact source mapping. Unset for nodes with no
|
||||
* fragment children (the common case).
|
||||
*/
|
||||
export type CachedLayout = {
|
||||
x: number
|
||||
@@ -13,6 +45,7 @@ export type CachedLayout = {
|
||||
width: number
|
||||
height: number
|
||||
top?: number
|
||||
fragments?: CachedFragment[]
|
||||
}
|
||||
|
||||
export const nodeCache = new WeakMap<DOMElement, CachedLayout>()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,27 +1,43 @@
|
||||
import type { DOMElement } from './dom.js'
|
||||
import type { TextStyles } from './styles.js'
|
||||
import type { Styles, TextStyles } from './styles.js'
|
||||
|
||||
/**
|
||||
* A segment of text with its associated styles.
|
||||
* Used for structured rendering without ANSI string transforms.
|
||||
*
|
||||
* `copySourceFragment` is propagated from the deepest enclosing
|
||||
* `<ink-virtual-text>` (or `<ink-text>`) that carries one; this lets
|
||||
* the renderer attach per-segment source-byte ranges to the ink-text's
|
||||
* cached layout for the copy hit-test to use.
|
||||
*/
|
||||
export type StyledSegment = {
|
||||
text: string
|
||||
styles: TextStyles
|
||||
hyperlink?: string
|
||||
copySourceFragment?: Styles['copySourceFragment']
|
||||
}
|
||||
|
||||
/**
|
||||
* Squash text nodes into styled segments, propagating styles down through the tree.
|
||||
* This allows structured styling without relying on ANSI string transforms.
|
||||
* Squash text nodes into styled segments, propagating styles (and the
|
||||
* per-segment `copySourceFragment` tag) down through the tree. Allows
|
||||
* structured styling without ANSI string transforms.
|
||||
*
|
||||
* Fragment inheritance: a child's fragment OVERRIDES its parent's. This
|
||||
* matches MdInline's behavior — nested formatting (e.g. bold containing
|
||||
* inline math) emits a single outer fragment for the bold-source span
|
||||
* AND inner fragments for the math-source span; the inner ones are what
|
||||
* the user sees and clicks, so they win.
|
||||
*/
|
||||
export function squashTextNodesToSegments(
|
||||
node: DOMElement,
|
||||
inheritedStyles: TextStyles = {},
|
||||
inheritedHyperlink?: string,
|
||||
inheritedFragment?: Styles['copySourceFragment'],
|
||||
out: StyledSegment[] = []
|
||||
): StyledSegment[] {
|
||||
const mergedStyles = node.textStyles ? { ...inheritedStyles, ...node.textStyles } : inheritedStyles
|
||||
const ownFragment = (node.style as { copySourceFragment?: Styles['copySourceFragment'] }).copySourceFragment
|
||||
const effectiveFragment = ownFragment ?? inheritedFragment
|
||||
|
||||
for (const childNode of node.childNodes) {
|
||||
if (childNode === undefined) {
|
||||
@@ -33,14 +49,15 @@ export function squashTextNodesToSegments(
|
||||
out.push({
|
||||
text: childNode.nodeValue,
|
||||
styles: mergedStyles,
|
||||
hyperlink: inheritedHyperlink
|
||||
hyperlink: inheritedHyperlink,
|
||||
...(effectiveFragment && { copySourceFragment: effectiveFragment })
|
||||
})
|
||||
}
|
||||
} else if (childNode.nodeName === 'ink-text' || childNode.nodeName === 'ink-virtual-text') {
|
||||
squashTextNodesToSegments(childNode, mergedStyles, inheritedHyperlink, out)
|
||||
squashTextNodesToSegments(childNode, mergedStyles, inheritedHyperlink, effectiveFragment, out)
|
||||
} else if (childNode.nodeName === 'ink-link') {
|
||||
const href = childNode.attributes['href'] as string | undefined
|
||||
squashTextNodesToSegments(childNode, mergedStyles, href || inheritedHyperlink, out)
|
||||
squashTextNodesToSegments(childNode, mergedStyles, href || inheritedHyperlink, effectiveFragment, out)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 `<Text>` produced
|
||||
* by the inline regex dispatcher (link, bold, math, code, etc.) wraps
|
||||
* in `<Box copySourceFragment={{start, end, verbatim}}>` so the
|
||||
* hit-test can return the exact source byte the user clicked, without
|
||||
* needing width-math at the block level.
|
||||
*
|
||||
* `start`/`end` are byte offsets RELATIVE TO the enclosing
|
||||
* `copyRangeId`'s outerSource. The hit-test resolves up the DOM:
|
||||
* fragment found → use fragment.start + clampedCol when verbatim,
|
||||
* snap to fragment.start/end otherwise; no fragment → fall through to
|
||||
* the block's `getOffset`.
|
||||
*
|
||||
* `verbatim` is true when the rendered text width matches the source
|
||||
* byte length character-for-character (plain text between markdown
|
||||
* tokens, code spans). For those, col-within-fragment maps directly
|
||||
* to source byte = start + col. False for tokens where rendered
|
||||
* cells ≠ source bytes (`**bold**`, `$math$`, `[link](url)`); for
|
||||
* those, partial-fragment clicks snap to the nearer of start/end so
|
||||
* selections don't slice mid-glyph.
|
||||
*/
|
||||
readonly copySourceFragment?: {
|
||||
readonly start: number
|
||||
readonly end: number
|
||||
readonly verbatim: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const applyPositionStyles = (node: LayoutNode, style: Styles): void => {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <Text> children wrapped in <Box copySourceFragment>
|
||||
* 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 <Text> (not <Box>) 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 (
|
||||
<Text
|
||||
copySourceFragment={{ start: off + srcStart, end: off + srcEnd, verbatim }}
|
||||
key={parts.length}
|
||||
>
|
||||
{node}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
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 key={k}>{text.slice(last, i)}</Text>)
|
||||
// Plain text between matches. Verbatim: rendered cells == source bytes.
|
||||
parts.push(wrap(<Text key={k}>{text.slice(last, i)}</Text>, last, i, true))
|
||||
}
|
||||
|
||||
if (m[1] && m[2]) {
|
||||
// image: rendered "[image: ALT] URL", not verbatim
|
||||
parts.push(
|
||||
<Text color={t.color.muted} key={parts.length}>
|
||||
[image: {m[1]}] {m[2]}
|
||||
</Text>
|
||||
wrap(
|
||||
<Text color={t.color.muted} key={parts.length}>
|
||||
[image: {m[1]}] {m[2]}
|
||||
</Text>,
|
||||
i,
|
||||
matchEnd,
|
||||
false
|
||||
)
|
||||
)
|
||||
} else if (m[3] && m[4]) {
|
||||
// link: rendered "TEXT", not verbatim
|
||||
parts.push(
|
||||
<Link key={parts.length} url={m[4]}>
|
||||
<Text color={t.color.accent} underline>
|
||||
{m[3]}
|
||||
</Text>
|
||||
</Link>
|
||||
wrap(
|
||||
<Link key={parts.length} url={m[4]}>
|
||||
<Text color={t.color.accent} underline>
|
||||
{m[3]}
|
||||
</Text>
|
||||
</Link>,
|
||||
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(
|
||||
<Text key={parts.length} strikethrough>
|
||||
<MdInline t={t} text={m[6]} />
|
||||
</Text>
|
||||
wrap(
|
||||
<Text key={parts.length} strikethrough>
|
||||
<MdInline sourceOffset={off + innerStart} t={t} text={inner} />
|
||||
</Text>,
|
||||
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(
|
||||
<Text color={t.color.accent} dimColor key={parts.length}>
|
||||
{m[7]}
|
||||
</Text>
|
||||
wrap(
|
||||
<Text color={t.color.accent} dimColor key={parts.length}>
|
||||
{m[7]}
|
||||
</Text>,
|
||||
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 `<Text bold>`
|
||||
// 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(
|
||||
<Text bold key={parts.length}>
|
||||
<MdInline t={t} text={m[8] ?? m[9]!} />
|
||||
</Text>
|
||||
wrap(
|
||||
<Text bold key={parts.length}>
|
||||
<MdInline sourceOffset={off + innerStart} t={t} text={inner} />
|
||||
</Text>,
|
||||
i,
|
||||
matchEnd,
|
||||
false
|
||||
)
|
||||
)
|
||||
} else if (m[10] ?? m[11]) {
|
||||
// italic: not verbatim
|
||||
const inner = (m[10] ?? m[11])!
|
||||
const innerStart = i + 1
|
||||
|
||||
parts.push(
|
||||
<Text italic key={parts.length}>
|
||||
<MdInline t={t} text={m[10] ?? m[11]!} />
|
||||
</Text>
|
||||
wrap(
|
||||
<Text italic key={parts.length}>
|
||||
<MdInline sourceOffset={off + innerStart} t={t} text={inner} />
|
||||
</Text>,
|
||||
i,
|
||||
matchEnd,
|
||||
false
|
||||
)
|
||||
)
|
||||
} else if (m[12]) {
|
||||
// highlight ==x==: not verbatim
|
||||
const inner = m[12]
|
||||
const innerStart = i + 2
|
||||
|
||||
parts.push(
|
||||
<Text backgroundColor={t.color.diffAdded} color={t.color.diffAddedWord} key={parts.length}>
|
||||
<MdInline t={t} text={m[12]} />
|
||||
</Text>
|
||||
wrap(
|
||||
<Text backgroundColor={t.color.diffAdded} color={t.color.diffAddedWord} key={parts.length}>
|
||||
<MdInline sourceOffset={off + innerStart} t={t} text={inner} />
|
||||
</Text>,
|
||||
i,
|
||||
matchEnd,
|
||||
false
|
||||
)
|
||||
)
|
||||
} else if (m[13]) {
|
||||
// footnote [^N] → [N]: not verbatim
|
||||
parts.push(
|
||||
<Text color={t.color.muted} key={parts.length}>
|
||||
[{m[13]}]
|
||||
</Text>
|
||||
wrap(
|
||||
<Text color={t.color.muted} key={parts.length}>
|
||||
[{m[13]}]
|
||||
</Text>,
|
||||
i,
|
||||
matchEnd,
|
||||
false
|
||||
)
|
||||
)
|
||||
} else if (m[14]) {
|
||||
// super ^N^ → ^N: not verbatim
|
||||
parts.push(
|
||||
<Text color={t.color.muted} key={parts.length}>
|
||||
^{m[14]}
|
||||
</Text>
|
||||
wrap(
|
||||
<Text color={t.color.muted} key={parts.length}>
|
||||
^{m[14]}
|
||||
</Text>,
|
||||
i,
|
||||
matchEnd,
|
||||
false
|
||||
)
|
||||
)
|
||||
} else if (m[15]) {
|
||||
// sub ~N~ → _N: not verbatim
|
||||
parts.push(
|
||||
<Text color={t.color.muted} key={parts.length}>
|
||||
_{m[15]}
|
||||
</Text>
|
||||
wrap(
|
||||
<Text color={t.color.muted} key={parts.length}>
|
||||
_{m[15]}
|
||||
</Text>,
|
||||
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(<Text key={parts.length}>{m[16].slice(url.length)}</Text>)
|
||||
// Trailing punctuation: verbatim plain text.
|
||||
parts.push(wrap(<Text key={parts.length}>{m[16].slice(url.length)}</Text>, 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(
|
||||
<Text color={t.color.accent} italic key={parts.length}>
|
||||
{renderMath(texToUnicode(m[17] ?? m[18]!))}
|
||||
</Text>
|
||||
wrap(
|
||||
<Text color={t.color.accent} italic key={parts.length}>
|
||||
{renderMath(texToUnicode(m[17] ?? m[18]!))}
|
||||
</Text>,
|
||||
i,
|
||||
matchEnd,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
last = i + m[0].length
|
||||
last = matchEnd
|
||||
}
|
||||
|
||||
if (last < text.length) {
|
||||
parts.push(<Text key={parts.length}>{text.slice(last)}</Text>)
|
||||
parts.push(wrap(<Text key={parts.length}>{text.slice(last)}</Text>, last, text.length, true))
|
||||
}
|
||||
|
||||
return <Text wrap="wrap-trim">{parts.length ? parts : text}</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 <CopySource> uses a simple line-starts offset map.
|
||||
// For inline-formatted content (paragraphs, headings, etc.) MdInline
|
||||
// emits per-segment <CopyFragment> wrappers carrying the exact source
|
||||
// byte range each <Text> 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 (
|
||||
<Box flexDirection="column">
|
||||
{blocks.map((b, i) => {
|
||||
@@ -639,7 +763,7 @@ function parseToBlocks(text: string, compact: boolean | undefined, t: Theme): Md
|
||||
|
||||
if (closeIdx < 0) {
|
||||
start('paragraph')
|
||||
push(<MdInline key={key} t={t} text={line} />, line)
|
||||
push(<MdInline key={key} sourceOffset={0} t={t} text={line} />, 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(
|
||||
<Text bold color={t.color.accent} key={key} wrap="wrap-trim">
|
||||
<MdInline t={t} text={heading} />
|
||||
<MdInline sourceOffset={headingStart} t={t} text={heading} />
|
||||
</Text>,
|
||||
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(
|
||||
<Text bold color={t.color.accent} key={key} wrap="wrap-trim">
|
||||
<MdInline t={t} text={line.trim()} />
|
||||
<MdInline sourceOffset={setextOffset} t={t} text={trimmed} />
|
||||
</Text>,
|
||||
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(
|
||||
<Box key={key} paddingLeft={indentDepth(bullet[1]!) * 2}>
|
||||
<Text wrap="wrap-trim">
|
||||
<Text color={t.color.muted}>{marker} </Text>
|
||||
<MdInline t={t} text={task ? task[2]! : bullet[2]!} />
|
||||
<MdInline sourceOffset={bulletOffset} t={t} text={innerText} />
|
||||
</Text>
|
||||
</Box>,
|
||||
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(
|
||||
<Box key={key} paddingLeft={indentDepth(numbered[1]!) * 2}>
|
||||
<Text wrap="wrap-trim">
|
||||
<Text color={t.color.muted}>{numbered[2]}. </Text>
|
||||
<MdInline t={t} text={numbered[3]!} />
|
||||
<MdInline sourceOffset={numberedOffset} t={t} text={numberedInner} />
|
||||
</Text>
|
||||
</Box>,
|
||||
line
|
||||
@@ -918,7 +1059,7 @@ function parseToBlocks(text: string, compact: boolean | undefined, t: Theme): Md
|
||||
}
|
||||
|
||||
start('paragraph')
|
||||
push(<MdInline key={key} t={t} text={line} />, line)
|
||||
push(<MdInline key={key} sourceOffset={0} t={t} text={line} />, 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 <CopyFragment> 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
|
||||
|
||||
@@ -73,6 +73,7 @@ export const MessageLine = memo(function MessageLine({
|
||||
<ToolTrail
|
||||
commandOverride={detailsModeCommandOverride}
|
||||
detailsMode={detailsMode}
|
||||
msgId={msgId}
|
||||
reasoning={thinking}
|
||||
reasoningTokens={msg.thinkingTokens}
|
||||
sections={sections}
|
||||
@@ -194,6 +195,7 @@ export const MessageLine = memo(function MessageLine({
|
||||
<ToolTrail
|
||||
commandOverride={detailsModeCommandOverride}
|
||||
detailsMode={detailsMode}
|
||||
msgId={msgId}
|
||||
reasoning={thinking}
|
||||
reasoningTokens={msg.thinkingTokens}
|
||||
sections={sections}
|
||||
|
||||
@@ -4,6 +4,8 @@ import spinners, { type BrailleSpinnerName } from 'unicode-animations'
|
||||
|
||||
import { THINKING_COT_MAX } from '../config/limits.js'
|
||||
import { sectionMode } from '../domain/details.js'
|
||||
import { CopySource } from '../lib/copySource/CopySource.js'
|
||||
import { buildLineStartsFromRows, simpleOffsetFor } from '../lib/copySource/offsetMaps.js'
|
||||
import {
|
||||
buildSubagentTree,
|
||||
fmtCost,
|
||||
@@ -618,6 +620,7 @@ export const Thinking = memo(function Thinking({
|
||||
active = false,
|
||||
branch = 'last',
|
||||
mode = 'truncated',
|
||||
msgId,
|
||||
rails = [],
|
||||
reasoning,
|
||||
streaming = false,
|
||||
@@ -626,6 +629,11 @@ export const Thinking = memo(function Thinking({
|
||||
active?: boolean
|
||||
branch?: TreeBranch
|
||||
mode?: ThinkingMode
|
||||
/** Stable msg id for anchoring the reasoning text in the copy-source
|
||||
* registry. When set, the rendered content is wrapped in a CopySource
|
||||
* with blockIndex=-1 (reserved for thinking — kept negative so it
|
||||
* orders BEFORE the assistant's reply blocks at blockIndex≥0). */
|
||||
msgId?: string
|
||||
rails?: TreeRails
|
||||
reasoning: string
|
||||
streaming?: boolean
|
||||
@@ -643,31 +651,56 @@ export const Thinking = memo(function Thinking({
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<TreeRow branch={branch} rails={rails} t={t}>
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{preview ? (
|
||||
mode === 'full' ? (
|
||||
lines.map((line, index) => (
|
||||
<Text color={t.color.muted} key={index} wrap="wrap-trim">
|
||||
{line || ' '}
|
||||
{index === lines.length - 1 ? (
|
||||
<StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
|
||||
) : null}
|
||||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{preview}
|
||||
<StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
|
||||
const content = (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{preview ? (
|
||||
mode === 'full' ? (
|
||||
lines.map((line, index) => (
|
||||
<Text color={t.color.muted} key={index} wrap="wrap-trim">
|
||||
{line || ' '}
|
||||
{index === lines.length - 1 ? (
|
||||
<StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
|
||||
) : null}
|
||||
</Text>
|
||||
)
|
||||
))
|
||||
) : (
|
||||
<Text color={t.color.muted}>
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{preview}
|
||||
<StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
) : (
|
||||
<Text color={t.color.muted}>
|
||||
<StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
|
||||
// When we have an msgId, wrap the rendered content in a CopySource so
|
||||
// ctrl-c over the thinking text gives the user the raw reasoning. Use
|
||||
// blockIndex=-1 so this range sorts BEFORE any blockIndex≥0 (assistant
|
||||
// reply blocks). outerSource is the full reasoning string — even when
|
||||
// the rendered preview is truncated, copy returns the full text.
|
||||
// visualLineCount tracks the rendered preview's row count so clicks
|
||||
// on rendered rows resolve correctly; offset map is line-starts.
|
||||
const wrapped = msgId ? (
|
||||
<CopySource
|
||||
blockIndex={-1}
|
||||
getOffset={simpleOffsetFor(reasoning, buildLineStartsFromRows(reasoning.split('\n')))}
|
||||
msgId={msgId}
|
||||
outerSource={reasoning}
|
||||
visualLineCount={Math.max(1, lines.length)}
|
||||
>
|
||||
{content}
|
||||
</CopySource>
|
||||
) : (
|
||||
content
|
||||
)
|
||||
|
||||
return (
|
||||
<TreeRow branch={branch} rails={rails} t={t}>
|
||||
{wrapped}
|
||||
</TreeRow>
|
||||
)
|
||||
})
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -199,6 +199,24 @@ function reducePoint(p: Exclude<Point, { kind: 'before-all' | 'after-all' }>): 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) }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <Text>-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). */
|
||||
|
||||
Vendored
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user