mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 04:08:28 +08:00
Compare commits
14 Commits
fix/docker
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6832b910c2 | ||
|
|
30dfbd9b09 | ||
|
|
8b6ab220a6 | ||
|
|
079fc8727d | ||
|
|
cf53f4c88f | ||
|
|
797c84a5c5 | ||
|
|
637ec1f237 | ||
|
|
f42f211a84 | ||
|
|
46e2ff57f7 | ||
|
|
1306f234f4 | ||
|
|
bda9a22558 | ||
|
|
e922110ac3 | ||
|
|
734090a905 | ||
|
|
98aa6da414 |
3
ui-tui/packages/hermes-ink/index.d.ts
vendored
3
ui-tui/packages/hermes-ink/index.d.ts
vendored
@@ -31,7 +31,8 @@ export { useTerminalFocus } from './src/ink/hooks/use-terminal-focus.ts'
|
||||
export { useTerminalTitle } from './src/ink/hooks/use-terminal-title.ts'
|
||||
export { useTerminalViewport } from './src/ink/hooks/use-terminal-viewport.ts'
|
||||
export { default as measureElement } from './src/ink/measure-element.ts'
|
||||
export { createRoot, forceRedraw, default as render, renderSync } from './src/ink/root.ts'
|
||||
export { copyPointAt, findRangeDom } from './src/ink/copyPointHitTest.ts'
|
||||
export { createRoot, forceRedraw, getInkForStdout, default as render, renderSync } from './src/ink/root.ts'
|
||||
export type { Instance, RenderOptions, Root } from './src/ink/root.ts'
|
||||
export { stringWidth } from './src/ink/stringWidth.ts'
|
||||
export { wrapAnsi } from './src/ink/wrapAnsi.ts'
|
||||
|
||||
@@ -11,6 +11,7 @@ export { RawAnsi } from './ink/components/RawAnsi.js'
|
||||
export { default as ScrollBox } from './ink/components/ScrollBox.js'
|
||||
export { default as Spacer } from './ink/components/Spacer.js'
|
||||
export { default as Text } from './ink/components/Text.js'
|
||||
export { copyPointAt, findRangeDom } from './ink/copyPointHitTest.js'
|
||||
export { default as useApp } from './ink/hooks/use-app.js'
|
||||
export { useCursorAdvance } from './ink/hooks/use-cursor-advance.js'
|
||||
export { useDeclaredCursor } from './ink/hooks/use-declared-cursor.js'
|
||||
@@ -24,7 +25,7 @@ export { useTerminalTitle } from './ink/hooks/use-terminal-title.js'
|
||||
export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.js'
|
||||
export { default as measureElement } from './ink/measure-element.js'
|
||||
export { scrollFastPathStats, type ScrollFastPathStats } from './ink/render-node-to-output.js'
|
||||
export { createRoot, forceRedraw, default as render, renderSync } from './ink/root.js'
|
||||
export { createRoot, forceRedraw, getInkForStdout, default as render, renderSync } from './ink/root.js'
|
||||
export { stringWidth } from './ink/stringWidth.js'
|
||||
export { wrapAnsi } from './ink/wrapAnsi.js'
|
||||
export { isXtermJs } from './ink/terminal.js'
|
||||
|
||||
@@ -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}
|
||||
|
||||
455
ui-tui/packages/hermes-ink/src/ink/copyPointHitTest.test.ts
Normal file
455
ui-tui/packages/hermes-ink/src/ink/copyPointHitTest.test.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { copyPointAt } from './copyPointHitTest.js'
|
||||
import { appendChildNode, createNode, type DOMElement } from './dom.js'
|
||||
import { nodeCache } from './node-cache.js'
|
||||
|
||||
/**
|
||||
* Unit tests for `copyPointAt` — specifically the gap-adjacency
|
||||
* resolution path (`findAdjacentRanges`).
|
||||
*
|
||||
* Bug fixed here: `findAdjacentRanges` had `afterRangeId` and
|
||||
* `beforeRangeId` swapped — when a click landed in a blank row
|
||||
* between two ranges, the resulting SelectionPoint reported the
|
||||
* range ABOVE as `beforeRangeId` and the range BELOW as
|
||||
* `afterRangeId`, which is the opposite of the convention used
|
||||
* everywhere else in the copy-source pipeline:
|
||||
*
|
||||
* - `afterRangeId` = the range the gap comes AFTER (above)
|
||||
* - `beforeRangeId` = the range the gap comes BEFORE (below)
|
||||
*
|
||||
* Symptom: selecting from the blank line above a table to the blank
|
||||
* line below it would copy the entire message instead of just the
|
||||
* table (because reducePoint resolved both gap endpoints to the
|
||||
* wrong side and the resulting slice window grew unbounded).
|
||||
*/
|
||||
describe('copyPointAt gap adjacency', () => {
|
||||
/**
|
||||
* Build a minimal Ink-style DOM with N range-tagged boxes stacked
|
||||
* vertically, each at a specified y/height. Returns the root so
|
||||
* `copyPointAt(root, col, row)` can probe it.
|
||||
*/
|
||||
function buildRangeStack(
|
||||
ranges: ReadonlyArray<{ id: number; y: number; height: number }>
|
||||
): DOMElement {
|
||||
const root = createNode('ink-root')
|
||||
|
||||
// Root rect must cover everything so hitDeepest descends.
|
||||
const totalHeight = ranges.reduce(
|
||||
(acc, r) => Math.max(acc, r.y + r.height),
|
||||
0
|
||||
)
|
||||
|
||||
nodeCache.set(root, { x: 0, y: 0, width: 100, height: totalHeight })
|
||||
|
||||
for (const range of ranges) {
|
||||
const box = createNode('ink-box')
|
||||
box.style = { copyRangeId: range.id } as DOMElement['style']
|
||||
nodeCache.set(box, { x: 0, y: range.y, width: 100, height: range.height })
|
||||
appendChildNode(root, box)
|
||||
}
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
it('click in blank gap between two ranges: afterRangeId=above, beforeRangeId=below', () => {
|
||||
// Range 1 occupies rows 0-1. Gap at row 2. Range 2 occupies rows 3-4.
|
||||
const root = buildRangeStack([
|
||||
{ id: 1, y: 0, height: 2 },
|
||||
{ id: 2, y: 3, height: 2 }
|
||||
])
|
||||
|
||||
// Click at row 2, col 0 — but col 0 IS inside the root rect, so
|
||||
// hitDeepest will find the root and walk back without entering
|
||||
// either range box (their rects don't cover row 2). The walk-up
|
||||
// loop in copyPointAt finds no tagged ancestor → falls through
|
||||
// to findAdjacentRanges.
|
||||
const result = copyPointAt(root, 50, 2)
|
||||
expect(result.kind).toBe('gap')
|
||||
|
||||
if (result.kind === 'gap') {
|
||||
// The gap is AFTER range 1 (above) and BEFORE range 2 (below).
|
||||
expect(result.afterRangeId).toBe(1)
|
||||
expect(result.beforeRangeId).toBe(2)
|
||||
}
|
||||
})
|
||||
|
||||
it('click below all ranges: only afterRangeId set (to the last range above)', () => {
|
||||
const root = buildRangeStack([
|
||||
{ id: 1, y: 0, height: 2 },
|
||||
{ id: 2, y: 3, height: 2 }
|
||||
])
|
||||
|
||||
// Make root span further down so hitDeepest succeeds.
|
||||
nodeCache.set(root, { x: 0, y: 0, width: 100, height: 10 })
|
||||
|
||||
const result = copyPointAt(root, 50, 8)
|
||||
expect(result.kind).toBe('gap')
|
||||
|
||||
if (result.kind === 'gap') {
|
||||
expect(result.afterRangeId).toBe(2) // last range above
|
||||
expect(result.beforeRangeId).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('click above all ranges: only beforeRangeId set (to the first range below)', () => {
|
||||
const root = buildRangeStack([
|
||||
{ id: 1, y: 2, height: 2 },
|
||||
{ id: 2, y: 5, height: 2 }
|
||||
])
|
||||
|
||||
const result = copyPointAt(root, 50, 0)
|
||||
expect(result.kind).toBe('gap')
|
||||
|
||||
if (result.kind === 'gap') {
|
||||
expect(result.afterRangeId).toBeNull()
|
||||
expect(result.beforeRangeId).toBe(1) // first range below
|
||||
}
|
||||
})
|
||||
|
||||
it('ties broken by smaller rangeId (document order proxy)', () => {
|
||||
// Two ranges, both 2 rows above the click. The one with the
|
||||
// smaller id (= earlier mount order) wins.
|
||||
const root = buildRangeStack([
|
||||
{ id: 5, y: 0, height: 1 },
|
||||
{ id: 3, y: 0, height: 1 }
|
||||
])
|
||||
|
||||
nodeCache.set(root, { x: 0, y: 0, width: 100, height: 10 })
|
||||
|
||||
const result = copyPointAt(root, 50, 3)
|
||||
expect(result.kind).toBe('gap')
|
||||
|
||||
if (result.kind === 'gap') {
|
||||
expect(result.afterRangeId).toBe(3) // smaller id wins tie
|
||||
}
|
||||
})
|
||||
|
||||
it('click inside a tagged range: returns in-range, not gap', () => {
|
||||
const root = buildRangeStack([
|
||||
{ id: 1, y: 0, height: 3 }
|
||||
])
|
||||
|
||||
const result = copyPointAt(root, 50, 1)
|
||||
expect(result.kind).toBe('in-range')
|
||||
|
||||
if (result.kind === 'in-range') {
|
||||
expect(result.rangeId).toBe(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('wrap-continuation row: per-row fragment gives byte-exact sourceOffset, not whole-line', () => {
|
||||
// Regression for: dragging from mid-row 0 to col 0 of row 1 (a
|
||||
// wrap-continuation row of a single source line) was copying the
|
||||
// WHOLE source line because the block's visualLineCount was the
|
||||
// SOURCE-line count (1), not the WRAPPED count (2). visualLine=1
|
||||
// therefore clamped pointToOffset to outerSource.length.
|
||||
//
|
||||
// The fix: per-row fragments on the ink-text node carry the
|
||||
// source-byte slice for each wrapped row, so the hit-test on
|
||||
// continuation rows returns `sourceOffset` and toCopyText skips
|
||||
// the buggy pointToOffset path entirely.
|
||||
const root = createNode('ink-root')
|
||||
nodeCache.set(root, { x: 0, y: 0, width: 15, height: 2 })
|
||||
|
||||
const box = createNode('ink-box')
|
||||
box.style = { copyRangeId: 7 } as DOMElement['style']
|
||||
nodeCache.set(box, { x: 0, y: 0, width: 15, height: 2 })
|
||||
appendChildNode(root, box)
|
||||
|
||||
const text = createNode('ink-text')
|
||||
nodeCache.set(text, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 15,
|
||||
height: 2,
|
||||
// "the quick brown" on row 0 [source 0..15) +
|
||||
// "fox jumps over" on row 1 [source 16..30) (the space at byte
|
||||
// 15 is wrap-trimmed away).
|
||||
fragments: [
|
||||
{ row: 0, colStart: 0, colEnd: 15, start: 0, end: 15, verbatim: true },
|
||||
{ row: 1, colStart: 0, colEnd: 14, start: 16, end: 30, verbatim: true }
|
||||
]
|
||||
})
|
||||
appendChildNode(box, text)
|
||||
|
||||
// Click at col 0 of the wrap-continuation row.
|
||||
const result = copyPointAt(root, 0, 1)
|
||||
expect(result.kind).toBe('in-range')
|
||||
|
||||
if (result.kind === 'in-range') {
|
||||
expect(result.rangeId).toBe(7)
|
||||
// Critical: sourceOffset is set so toCopyText bypasses pointToOffset.
|
||||
// Without per-row fragments this was undefined and pointToOffset
|
||||
// returned outerSource.length, leaking the whole line.
|
||||
expect(result.sourceOffset).toBe(16)
|
||||
}
|
||||
})
|
||||
|
||||
it('triple-click sets focus at col=width-1 OUTSIDE content rect → falls back to same-row in-range', () => {
|
||||
// Reproduces the user-reported triple-click bug. selectLineAt sets
|
||||
// anchor=(0, row) focus=(width-1, row) using the SCREEN width, not
|
||||
// the content rect. When the message body is narrower than the
|
||||
// screen (typical: gutter on left, padding on right), focus lands
|
||||
// OUTSIDE the CopySource Box rect.
|
||||
//
|
||||
// hitDeepest returns null for col=119 if the box only spans col
|
||||
// 4..80. Without the same-row fallback, copyPointAt would return
|
||||
// a gap with no adjacency (the only range is on the same row, and
|
||||
// findAdjacentRanges only finds STRICTLY above/below ranges).
|
||||
// resolvePoint would then return null and toCopyText would emit
|
||||
// empty text.
|
||||
//
|
||||
// Fix: same-row fallback returns in-range with col clamped to the
|
||||
// box's right edge (or left edge if click was to the box's left).
|
||||
const root = createNode('ink-root')
|
||||
nodeCache.set(root, { x: 0, y: 0, width: 120, height: 5 })
|
||||
|
||||
// Message body box: not full width. Starts at col 4 (gutter), ends
|
||||
// at col 80. So col=119 (the triple-click focus) is OUTSIDE.
|
||||
const body = createNode('ink-box')
|
||||
body.style = { copyRangeId: 42 } as DOMElement['style']
|
||||
nodeCache.set(body, { x: 4, y: 1, width: 76, height: 1 })
|
||||
appendChildNode(root, body)
|
||||
|
||||
const text = createNode('ink-text')
|
||||
nodeCache.set(text, { x: 4, y: 1, width: 76, height: 1 })
|
||||
appendChildNode(body, text)
|
||||
|
||||
// Anchor click at col=0 row=1 — LEFT of the body box (in the gutter).
|
||||
// Without fix: gap with no adjacency. With fix: in-range, col=0
|
||||
// (clamped to body box's left edge, since col=0 - rect.x=4 < 0).
|
||||
const anchor = copyPointAt(root, 0, 1)
|
||||
expect(anchor.kind).toBe('in-range')
|
||||
|
||||
if (anchor.kind === 'in-range') {
|
||||
expect(anchor.rangeId).toBe(42)
|
||||
expect(anchor.visualLine).toBe(0)
|
||||
expect(anchor.col).toBe(0)
|
||||
}
|
||||
|
||||
// Focus click at col=119 row=1 — right edge of the screen, way past
|
||||
// body box's x+width=80. Without fix: empty gap. With fix:
|
||||
// in-range, col=75 (clamped to box.width - 1).
|
||||
const focus = copyPointAt(root, 119, 1)
|
||||
expect(focus.kind).toBe('in-range')
|
||||
|
||||
if (focus.kind === 'in-range') {
|
||||
expect(focus.rangeId).toBe(42)
|
||||
expect(focus.visualLine).toBe(0)
|
||||
// col clamped to box.width - 1 = 76 - 1 = 75.
|
||||
expect(focus.col).toBe(75)
|
||||
}
|
||||
})
|
||||
|
||||
it('same-row fallback picks the SMALLEST tagged box when ranges are nested', () => {
|
||||
// When multiple tagged boxes straddle the click row (e.g. a msg
|
||||
// box containing a fence block), the fallback should pick the
|
||||
// INNERMOST (smallest-area) one — that's what the user clicked.
|
||||
const root = createNode('ink-root')
|
||||
nodeCache.set(root, { x: 0, y: 0, width: 120, height: 10 })
|
||||
|
||||
// Outer msg box (large area).
|
||||
const msgBox = createNode('ink-box')
|
||||
msgBox.style = { copyRangeId: 100 } as DOMElement['style']
|
||||
nodeCache.set(msgBox, { x: 4, y: 1, width: 76, height: 5 })
|
||||
appendChildNode(root, msgBox)
|
||||
|
||||
// Inner fence block (smaller area, nested inside msg).
|
||||
const fenceBox = createNode('ink-box')
|
||||
fenceBox.style = { copyRangeId: 101 } as DOMElement['style']
|
||||
nodeCache.set(fenceBox, { x: 6, y: 2, width: 70, height: 3 })
|
||||
appendChildNode(msgBox, fenceBox)
|
||||
|
||||
// Click in the gutter on a row inside the fence.
|
||||
const result = copyPointAt(root, 0, 3)
|
||||
expect(result.kind).toBe('in-range')
|
||||
|
||||
if (result.kind === 'in-range') {
|
||||
// Should pick the smaller fence box, not the outer msg box.
|
||||
expect(result.rangeId).toBe(101)
|
||||
}
|
||||
})
|
||||
|
||||
it('wrap-continuation row with NO fragments: degrades to in-range with bad visualLine (documents the regression)', () => {
|
||||
// What happens when the renderer didn't emit fragments for the
|
||||
// wrap (e.g. paragraph rendered without the MdInline wrap()
|
||||
// wrapper, or fragments were stale-evicted). The hit-test still
|
||||
// returns in-range, but with `visualLine = row - rect.y` = the
|
||||
// visual row index relative to the ink-text rect.
|
||||
//
|
||||
// For a wrapped block whose CopySource was registered with
|
||||
// visualLineCount = source-line-count (1, not the wrapped count
|
||||
// 2), pointToOffset(visualLine=1, ...) clamps to outerSource.length
|
||||
// and toCopyText emits the whole source line. This test pins down
|
||||
// exactly what the host receives in that scenario so we can spot
|
||||
// it from logs.
|
||||
const root = createNode('ink-root')
|
||||
nodeCache.set(root, { x: 0, y: 0, width: 15, height: 2 })
|
||||
|
||||
const box = createNode('ink-box')
|
||||
box.style = { copyRangeId: 11 } as DOMElement['style']
|
||||
nodeCache.set(box, { x: 0, y: 0, width: 15, height: 2 })
|
||||
appendChildNode(root, box)
|
||||
|
||||
const text = createNode('ink-text')
|
||||
// NOTE: no `fragments` set — simulating the broken state.
|
||||
nodeCache.set(text, { x: 0, y: 0, width: 15, height: 2 })
|
||||
appendChildNode(box, text)
|
||||
|
||||
const result = copyPointAt(root, 0, 1)
|
||||
expect(result.kind).toBe('in-range')
|
||||
|
||||
if (result.kind === 'in-range') {
|
||||
expect(result.rangeId).toBe(11)
|
||||
expect(result.visualLine).toBe(1)
|
||||
expect(result.col).toBe(0)
|
||||
// sourceOffset is undefined → falls through to the
|
||||
// pointToOffset(visualLine=1, col=0) path in toCopyText, which
|
||||
// clamps to outerSource.length when visualLineCount=1.
|
||||
expect(result.sourceOffset).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('wrap-continuation row mid-fragment: sourceOffset uses verbatim cell→byte math', () => {
|
||||
// Same wrapped paragraph, click at col 5 of row 1 → should give
|
||||
// source byte 21 (16 + 5), not the whole-line clamp.
|
||||
const root = createNode('ink-root')
|
||||
nodeCache.set(root, { x: 0, y: 0, width: 15, height: 2 })
|
||||
|
||||
const box = createNode('ink-box')
|
||||
box.style = { copyRangeId: 9 } as DOMElement['style']
|
||||
nodeCache.set(box, { x: 0, y: 0, width: 15, height: 2 })
|
||||
appendChildNode(root, box)
|
||||
|
||||
const text = createNode('ink-text')
|
||||
nodeCache.set(text, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 15,
|
||||
height: 2,
|
||||
fragments: [
|
||||
{ row: 0, colStart: 0, colEnd: 15, start: 0, end: 15, verbatim: true },
|
||||
{ row: 1, colStart: 0, colEnd: 14, start: 16, end: 30, verbatim: true }
|
||||
]
|
||||
})
|
||||
appendChildNode(box, text)
|
||||
|
||||
const result = copyPointAt(root, 5, 1)
|
||||
expect(result.kind).toBe('in-range')
|
||||
|
||||
if (result.kind === 'in-range') {
|
||||
expect(result.sourceOffset).toBe(21)
|
||||
}
|
||||
})
|
||||
|
||||
it('endpoint="end" bumps verbatim sourceOffset by 1 (cell-INCLUSIVE → byte-EXCLUSIVE)', () => {
|
||||
// Regression: cell-INCLUSIVE selection bounds × byte-EXCLUSIVE
|
||||
// slice semantics dropped one char off the right edge of every
|
||||
// word/drag selection ("might" → "migh"). Fix: hit-test bumps the
|
||||
// verbatim cell→byte mapping by 1 when endpoint='end' is passed
|
||||
// (e.g. by buildCopyTextFromDom for the focus point of a selection),
|
||||
// clamped to the fragment's end byte.
|
||||
const root = createNode('ink-root')
|
||||
nodeCache.set(root, { x: 0, y: 0, width: 18, height: 1 })
|
||||
|
||||
const box = createNode('ink-box')
|
||||
box.style = { copyRangeId: 11 } as DOMElement['style']
|
||||
nodeCache.set(box, { x: 0, y: 0, width: 18, height: 1 })
|
||||
appendChildNode(root, box)
|
||||
|
||||
// "things might break" — single verbatim fragment, 18 cells = 18 bytes.
|
||||
const text = createNode('ink-text')
|
||||
nodeCache.set(text, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 18,
|
||||
height: 1,
|
||||
fragments: [
|
||||
{ row: 0, colStart: 0, colEnd: 18, start: 0, end: 18, verbatim: true }
|
||||
]
|
||||
})
|
||||
appendChildNode(box, text)
|
||||
|
||||
// Cell 11 = 't' of "might" (the last cell of the word).
|
||||
const startResult = copyPointAt(root, 11, 0, 'start')
|
||||
const endResult = copyPointAt(root, 11, 0, 'end')
|
||||
|
||||
expect(startResult.kind).toBe('in-range')
|
||||
expect(endResult.kind).toBe('in-range')
|
||||
|
||||
if (startResult.kind === 'in-range') {
|
||||
expect(startResult.sourceOffset).toBe(11) // cell start byte
|
||||
}
|
||||
|
||||
if (endResult.kind === 'in-range') {
|
||||
expect(endResult.sourceOffset).toBe(12) // one PAST cell — fixes "migh"
|
||||
}
|
||||
|
||||
// Sanity: default arg behaves like 'start' (backward compat).
|
||||
const defaultResult = copyPointAt(root, 11, 0)
|
||||
|
||||
if (defaultResult.kind === 'in-range') {
|
||||
expect(defaultResult.sourceOffset).toBe(11)
|
||||
}
|
||||
|
||||
// Clamp check: end-of-fragment click with endpoint='end' must not
|
||||
// over-read past the fragment's end byte.
|
||||
const endOfFragment = copyPointAt(root, 17, 0, 'end')
|
||||
|
||||
if (endOfFragment.kind === 'in-range') {
|
||||
expect(endOfFragment.sourceOffset).toBe(18) // == f.end, clamped
|
||||
}
|
||||
})
|
||||
|
||||
it('reports visualLine/col relative to inner padded content, not outer rangeId Box', () => {
|
||||
// Regression for ethie's report #2: a mermaid code fence renders
|
||||
//
|
||||
// <CopySource Box copyRangeId=N> rect.x=0
|
||||
// <Box paddingLeft=2> rect.x=2
|
||||
// <Text>graph LR</Text> rect.x=2
|
||||
// <Text> user[ethie] -->...</Text> rect.x=2
|
||||
// </Box>
|
||||
// </CopySource Box>
|
||||
//
|
||||
// Click on the 'e' of "ethie" (visual col 11 = source col 9 + 2
|
||||
// padding). The hit-test used to walk up to the rangeId Box and
|
||||
// report col = 11 - 0 = 11, but getOffset interprets col=11 as
|
||||
// source col 11 — shifted +2 (hits 'h'). Selecting 'ethie' →
|
||||
// copies 'hie]'.
|
||||
//
|
||||
// Fix: report col relative to the INNERMOST non-rangeId rect
|
||||
// (the padded inner box / text), so col = 11 - 2 = 9 = source 'e'.
|
||||
const root = createNode('ink-root')
|
||||
nodeCache.set(root, { x: 0, y: 0, width: 50, height: 5 })
|
||||
|
||||
const outerBox = createNode('ink-box')
|
||||
outerBox.style = { copyRangeId: 42 } as DOMElement['style']
|
||||
nodeCache.set(outerBox, { x: 0, y: 0, width: 50, height: 5 })
|
||||
appendChildNode(root, outerBox)
|
||||
|
||||
const paddedBox = createNode('ink-box')
|
||||
nodeCache.set(paddedBox, { x: 2, y: 0, width: 48, height: 5 }) // paddingLeft=2
|
||||
appendChildNode(outerBox, paddedBox)
|
||||
|
||||
const text = createNode('ink-text')
|
||||
nodeCache.set(text, { x: 2, y: 2, width: 48, height: 1 })
|
||||
appendChildNode(paddedBox, text)
|
||||
|
||||
// Click at visual col 11, row 2 — the 'e' of 'ethie'.
|
||||
const result = copyPointAt(root, 11, 2, 'start')
|
||||
|
||||
expect(result.kind).toBe('in-range')
|
||||
|
||||
if (result.kind === 'in-range') {
|
||||
expect(result.rangeId).toBe(42)
|
||||
// col is reported RELATIVE TO INNER content (innerX=2):
|
||||
// 11 - 2 = 9, which is source col 9 = 'e' of ethie.
|
||||
// visualLine STAYS relative to the rangeId Box (rect.y=0):
|
||||
// 2 - 0 = 2, which is the third source row of the block —
|
||||
// matching the registered rowStarts that count from block start.
|
||||
expect(result.col).toBe(9)
|
||||
expect(result.visualLine).toBe(2)
|
||||
}
|
||||
})
|
||||
})
|
||||
407
ui-tui/packages/hermes-ink/src/ink/copyPointHitTest.ts
Normal file
407
ui-tui/packages/hermes-ink/src/ink/copyPointHitTest.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* Map (col, row) screen coordinates to a copy-source SelectionPoint.
|
||||
*
|
||||
* Used by the new transcript-virtual selection pipeline: when a mouse
|
||||
* event fires at (col, row), this walks the DOM to find the nearest
|
||||
* 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
|
||||
* on hermes-ink, not vice versa). Host code reinterprets the returned
|
||||
* object via a duck-typed cast.
|
||||
*
|
||||
* Gap handling: when (col,row) isn't inside any tagged region, we walk
|
||||
* the entire DOM looking for ranges and return the rangeIds of the
|
||||
* nearest ranges above (`beforeRangeId`) and below (`afterRangeId`).
|
||||
* This lets toCopyText anchor the gap-endpoint correctly between two
|
||||
* known messages instead of degrading to far-end-of-doc.
|
||||
*/
|
||||
|
||||
import type { DOMElement } from './dom.js'
|
||||
import { nodeCache } from './node-cache.js'
|
||||
|
||||
export type RawSelectionPoint =
|
||||
| {
|
||||
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 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
|
||||
* scrollTop translation — so a click on a visually-on-screen row that
|
||||
* came from a virtually-scrolled ScrollBox is hit correctly.
|
||||
*
|
||||
* `endpoint` controls how the per-cell click maps to a source-byte
|
||||
* offset on verbatim fragments. Selection bounds are stored as
|
||||
* CELL-INCLUSIVE coords (anchor/focus both point AT the cell containing
|
||||
* the character), but `String.slice(from, to)` is `to`-EXCLUSIVE. So
|
||||
* for the END of a selection we must add 1 to skip past the clicked
|
||||
* cell; for the START we use the cell's start byte as-is.
|
||||
*
|
||||
* - 'start' (default): start-of-clicked-cell.
|
||||
* Used for the anchor of a selection, and for mouse-click probes
|
||||
* where there's no anchor/focus context yet.
|
||||
* - 'end': one past the clicked cell, clamped to the fragment end.
|
||||
* Used for the focus of a finalized selection (where the cell is
|
||||
* the LAST included cell, and slice(from, to) needs `to` past it).
|
||||
*
|
||||
* Non-verbatim fragments already use the half-cell heuristic (left
|
||||
* half → fragment start, right half → fragment end) which is
|
||||
* endpoint-agnostic; `endpoint` is ignored for them.
|
||||
*/
|
||||
export function copyPointAt(
|
||||
root: DOMElement,
|
||||
col: number,
|
||||
row: number,
|
||||
endpoint: 'start' | 'end' = 'start'
|
||||
): RawSelectionPoint {
|
||||
const deepest = hitDeepest(root, col, row)
|
||||
|
||||
if (deepest) {
|
||||
// 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
|
||||
// Track the deepest non-rangeId X-offset so we can report col
|
||||
// relative to the INNERMOST rendered content, not the outer
|
||||
// copyRangeId-carrying Box. This matters when CopySource wraps a
|
||||
// Box with paddingLeft (code fences, tables, blockquotes, lists):
|
||||
// the outer Box's rect.x = 0 but the inner content lives at
|
||||
// rect.x = paddingLeft. Without this, a click on the rendered char
|
||||
// at visual col 11 (= source col 9 + 2 padding) returns col=11,
|
||||
// which getOffset interprets as source col 11 — shifted +2.
|
||||
//
|
||||
// We only adjust X — visualLine (Y) is reported relative to the
|
||||
// rangeId Box's rect, because that's the coordinate system that
|
||||
// matches the registered visualLineCount + rowStarts (which are
|
||||
// counted from the START of the rendered block, not the start of
|
||||
// any sub-text element).
|
||||
let innerX: number | undefined
|
||||
let node: DOMElement | undefined = deepest
|
||||
|
||||
while (node) {
|
||||
const rangeId = (node.style as { copyRangeId?: number }).copyRangeId
|
||||
const rect = nodeCache.get(node)
|
||||
|
||||
if (rect && innerX === undefined && rangeId === undefined) {
|
||||
// First rect we see that is NOT the rangeId Box becomes the
|
||||
// anchor for col reporting. We walk from deepest upward, so
|
||||
// this is the innermost text container.
|
||||
innerX = rect.x
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
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) {
|
||||
// Cell-INCLUSIVE click coord → byte offset. For an end-of-
|
||||
// selection point we want one past the clicked cell so
|
||||
// slice(from, to) includes it; for start-of-selection we
|
||||
// want the cell's start byte. Bumped offset is clamped to
|
||||
// the fragment's end so we never read past it.
|
||||
const cellsIn = localCol - f.colStart
|
||||
const bump = endpoint === 'end' ? 1 : 0
|
||||
|
||||
fragmentResolved = f.start + Math.min(cellsIn + bump, len)
|
||||
} else {
|
||||
const widthInFragment = f.colEnd - f.colStart
|
||||
const colInFragment = localCol - f.colStart
|
||||
|
||||
fragmentResolved =
|
||||
colInFragment * 2 < widthInFragment ? f.start : f.end
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof rangeId === 'number' && rect) {
|
||||
// Report col relative to innermost rendered content (innerX)
|
||||
// when available, falling back to the rangeId Box's rect.x.
|
||||
// visualLine stays relative to the rangeId Box (rect.y),
|
||||
// matching the registered rowStarts / visualLineCount.
|
||||
const reportX = innerX ?? rect.x
|
||||
|
||||
return {
|
||||
kind: 'in-range',
|
||||
rangeId,
|
||||
visualLine: Math.max(0, row - rect.y),
|
||||
col: Math.max(0, col - reportX),
|
||||
...(fragmentResolved !== undefined && { sourceOffset: fragmentResolved })
|
||||
}
|
||||
}
|
||||
|
||||
node = node.parentNode
|
||||
}
|
||||
}
|
||||
|
||||
// No tagged ancestor at (col, row). Before falling through to gap
|
||||
// resolution, check for a tagged box on the SAME row whose
|
||||
// horizontal extent we missed (click was in the gutter on the left
|
||||
// or past the content on the right). triple-click selectLineAt sets
|
||||
// focus=(width-1, row) using the SCREEN width, not the content rect;
|
||||
// when the message body is narrower than the screen the focus lands
|
||||
// outside the box and otherwise resolves to an empty gap, which
|
||||
// toCopyText turns into empty output.
|
||||
//
|
||||
// For each tagged box whose y-range covers `row`, return an
|
||||
// in-range point at the nearest edge of the box (left edge if click
|
||||
// was to its left, right edge if click was to its right). Snap to
|
||||
// the SMALLEST such box (deepest tagged) when multiple straddle the
|
||||
// row — that's the user's intent (the specific block they clicked
|
||||
// on, not its enclosing container).
|
||||
const sameRow = findSameRowRange(root, col, row)
|
||||
|
||||
if (sameRow) {
|
||||
return {
|
||||
kind: 'in-range',
|
||||
rangeId: sameRow.rangeId,
|
||||
visualLine: row - sameRow.rect.y,
|
||||
col: sameRow.col
|
||||
}
|
||||
}
|
||||
|
||||
// No tagged ancestor at (col, row) and nothing on the same row.
|
||||
// Scan the WHOLE DOM for tagged boxes, partition them into "above
|
||||
// row" and "below row" by their cached y bounds, and pick the
|
||||
// nearest each direction. This gives toCopyText enough info to
|
||||
// slot the gap between two known ranges.
|
||||
const { afterRangeId, beforeRangeId } = findAdjacentRanges(root, row)
|
||||
|
||||
return { kind: 'gap', afterRangeId, beforeRangeId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the SMALLEST tagged box whose y-extent contains `row`, even
|
||||
* when `col` is outside its x-extent. Returns the rangeId and the
|
||||
* snapped column (clamped to the box's x-extent). Returns null when
|
||||
* no tagged box straddles `row`.
|
||||
*
|
||||
* Used as a recovery path for clicks in the row gutter / past the
|
||||
* content — the user's intent is "select this row's content," and a
|
||||
* gap point with same-row-only adjacency would otherwise resolve to
|
||||
* nothing.
|
||||
*/
|
||||
function findSameRowRange(
|
||||
root: DOMElement,
|
||||
col: number,
|
||||
row: number
|
||||
): { rangeId: number; rect: { x: number; y: number; width: number; height: number }; col: number } | null {
|
||||
let best: { rangeId: number; rect: { x: number; y: number; width: number; height: number }; col: number; area: number } | null =
|
||||
null
|
||||
|
||||
const visit = (node: DOMElement): void => {
|
||||
const rangeId = (node.style as { copyRangeId?: number }).copyRangeId
|
||||
|
||||
if (typeof rangeId === 'number') {
|
||||
const rect = nodeCache.get(node)
|
||||
|
||||
if (rect && row >= rect.y && row < rect.y + rect.height) {
|
||||
// y matches; snap col into the box's x-extent.
|
||||
const snappedCol = Math.max(0, Math.min(col - rect.x, rect.width - 1))
|
||||
const area = rect.width * rect.height
|
||||
|
||||
if (!best || area < best.area) {
|
||||
best = { rangeId, rect, col: snappedCol, area }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeName === '#text') {
|
||||
continue
|
||||
}
|
||||
|
||||
visit(child as DOMElement)
|
||||
}
|
||||
}
|
||||
|
||||
visit(root)
|
||||
|
||||
if (!best) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { rangeId: (best as { rangeId: number }).rangeId, rect: (best as { rect: { x: number; y: number; width: number; height: number } }).rect, col: (best as { col: number }).col }
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursive depth-first hit test. Returns the deepest element whose
|
||||
* cached rect contains (col, row). Mirrors the existing hit-test.ts
|
||||
* implementation but without the side effects (no event dispatch, no
|
||||
* hover tracking).
|
||||
*/
|
||||
function hitDeepest(node: DOMElement, col: number, row: number): DOMElement | null {
|
||||
const rect = nodeCache.get(node)
|
||||
|
||||
if (!rect) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (col < rect.x || col >= rect.x + rect.width || row < rect.y || row >= rect.y + rect.height) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Reverse iteration: later siblings paint over earlier (so they win on
|
||||
// overlap). Matches existing hit-test.ts.
|
||||
for (let i = node.childNodes.length - 1; i >= 0; i--) {
|
||||
const child = node.childNodes[i]
|
||||
|
||||
if (!child || child.nodeName === '#text') {
|
||||
continue
|
||||
}
|
||||
|
||||
const hit = hitDeepest(child, col, row)
|
||||
|
||||
if (hit) {
|
||||
return hit
|
||||
}
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the tree collecting every node with `copyRangeId`, then bucket
|
||||
* each by whether its rect ends strictly above `row` (→ candidate for
|
||||
* `afterRangeId`: the gap is AFTER this range) or starts strictly
|
||||
* below `row` (→ candidate for `beforeRangeId`: the gap is BEFORE
|
||||
* this range). Ranges straddling `row` are ignored — they would have
|
||||
* been picked up by the in-range path before us.
|
||||
*
|
||||
* Naming convention (matches SelectionPoint.kind === 'gap' in
|
||||
* lib/copySource/types.ts):
|
||||
* - `afterRangeId` = the range the gap comes AFTER (i.e. the range
|
||||
* ABOVE the click, in document order BEFORE the gap)
|
||||
* - `beforeRangeId` = the range the gap comes BEFORE (i.e. the range
|
||||
* BELOW the click, in document order AFTER the gap)
|
||||
*
|
||||
* "Nearest" is measured by row distance (Manhattan-y). Ties are broken
|
||||
* by the smaller rangeId, which approximates document order (ids are
|
||||
* allocated in mount order).
|
||||
*/
|
||||
function findAdjacentRanges(root: DOMElement, row: number): { afterRangeId: null | number; beforeRangeId: null | number } {
|
||||
let afterRangeId: null | number = null
|
||||
let afterDist = Number.POSITIVE_INFINITY
|
||||
let beforeRangeId: null | number = null
|
||||
let beforeDist = Number.POSITIVE_INFINITY
|
||||
|
||||
const visit = (node: DOMElement): void => {
|
||||
const rangeId = (node.style as { copyRangeId?: number }).copyRangeId
|
||||
|
||||
if (typeof rangeId === 'number') {
|
||||
const rect = nodeCache.get(node)
|
||||
|
||||
if (rect) {
|
||||
const top = rect.y
|
||||
const bottom = rect.y + rect.height // exclusive
|
||||
|
||||
if (bottom <= row) {
|
||||
// Range is ABOVE the click → the gap comes AFTER this range
|
||||
// → it's a candidate for `afterRangeId`.
|
||||
const d = row - (bottom - 1)
|
||||
|
||||
if (d < afterDist || (d === afterDist && (afterRangeId === null || rangeId < afterRangeId))) {
|
||||
afterDist = d
|
||||
afterRangeId = rangeId
|
||||
}
|
||||
} else if (top > row) {
|
||||
// Range is BELOW the click → the gap comes BEFORE this range
|
||||
// → it's a candidate for `beforeRangeId`.
|
||||
const d = top - row
|
||||
|
||||
if (d < beforeDist || (d === beforeDist && (beforeRangeId === null || rangeId < beforeRangeId))) {
|
||||
beforeDist = d
|
||||
beforeRangeId = rangeId
|
||||
}
|
||||
}
|
||||
// Straddling row — leave to the in-range path; we wouldn't be
|
||||
// here if it had hit, so the rect's hit-test failed (likely
|
||||
// because col was outside). Treat as neither above nor below.
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeName === '#text') {
|
||||
continue
|
||||
}
|
||||
|
||||
visit(child as DOMElement)
|
||||
}
|
||||
}
|
||||
|
||||
visit(root)
|
||||
|
||||
return { afterRangeId, beforeRangeId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the DOM node currently rendering a given rangeId by walking the
|
||||
* tree top-down. Returns null if no node has `style.copyRangeId === id`
|
||||
* (e.g. the range is registered but its rendering is unmounted due to
|
||||
* virtual scrolling).
|
||||
*
|
||||
* Used by the host's selection-overlay path to translate a virtual
|
||||
* anchor/focus point back to screen coordinates for highlight rendering.
|
||||
*/
|
||||
export function findRangeDom(root: DOMElement, id: number): DOMElement | null {
|
||||
if ((root.style as { copyRangeId?: number }).copyRangeId === id) {
|
||||
return root
|
||||
}
|
||||
|
||||
for (const child of root.childNodes) {
|
||||
if (child.nodeName === '#text') {
|
||||
continue
|
||||
}
|
||||
|
||||
// The cast through `unknown` is to dodge a TS quirk: when this file
|
||||
// is re-exported from the package's `index.d.ts` shim, the recursive
|
||||
// `findRangeDom` call's return is inferred as `unknown` rather than
|
||||
// the explicit `DOMElement | null` signature.
|
||||
const found = findRangeDom(child as DOMElement, id) as DOMElement | null
|
||||
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -177,6 +177,16 @@ export default class Ink {
|
||||
// Ignore last render after unmounting a tree to prevent empty output before exit
|
||||
private isUnmounted = false
|
||||
private isPaused = false
|
||||
/**
|
||||
* Optional host-provided override for selection→clipboard-text. When set,
|
||||
* `copySelection*` calls this instead of the default cell-extracting
|
||||
* getSelectedText. Used by the transcript-virtual copy-source pipeline
|
||||
* to emit raw markdown / source text instead of rendered cells.
|
||||
*
|
||||
* Receives the Ink instance so the override can read `this.rootNode`,
|
||||
* `this.selection`, `this.frontFrame.screen`, etc.
|
||||
*/
|
||||
private copyTextFn: ((self: Ink) => string) | null = null
|
||||
private readonly container: FiberRoot
|
||||
private rootNode: dom.DOMElement
|
||||
readonly focusManager: FocusManager
|
||||
@@ -1460,7 +1470,9 @@ export default class Ink {
|
||||
return ''
|
||||
}
|
||||
|
||||
const text = getSelectedText(this.selection, this.frontFrame.screen)
|
||||
const text = this.copyTextFn
|
||||
? this.copyTextFn(this)
|
||||
: getSelectedText(this.selection, this.frontFrame.screen)
|
||||
|
||||
if (text) {
|
||||
try {
|
||||
@@ -1759,6 +1771,30 @@ export default class Ink {
|
||||
hasTextSelection(): boolean {
|
||||
return hasSelection(this.selection)
|
||||
}
|
||||
/**
|
||||
* Install (or clear) the host-provided copy-text override. Called by
|
||||
* the TUI host once on mount to wire up the transcript-virtual
|
||||
* copy-source pipeline. Pass null to revert to the default cell-text
|
||||
* extraction behavior.
|
||||
*/
|
||||
setCopyTextFn(fn: ((self: Ink) => string) | null): void {
|
||||
this.copyTextFn = fn
|
||||
}
|
||||
/**
|
||||
* Read access to the rendered root DOM tree. Used by the copy-text
|
||||
* override to walk for `copyRangeId` tagged boxes.
|
||||
*/
|
||||
getRootDom(): dom.DOMElement {
|
||||
return this.rootNode
|
||||
}
|
||||
/**
|
||||
* Read access to the current selection's screen-coord bounds. Used by
|
||||
* the copy-text override to know which ranges/cells the selection
|
||||
* covers. Returns null when no selection.
|
||||
*/
|
||||
getSelectionBoundsScreen(): { start: { col: number; row: number }; end: { col: number; row: number } } | null {
|
||||
return selectionBounds(this.selection)
|
||||
}
|
||||
|
||||
getSelectionVersion(): number {
|
||||
return this.selectionVersion
|
||||
|
||||
@@ -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>()
|
||||
|
||||
212
ui-tui/packages/hermes-ink/src/ink/render-node-to-output.test.ts
Normal file
212
ui-tui/packages/hermes-ink/src/ink/render-node-to-output.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { computeFragmentsForWrappedText } from './render-node-to-output.js'
|
||||
import type { StyledSegment } from './squash-text-nodes.js'
|
||||
|
||||
/**
|
||||
* Unit tests for `computeFragmentsForWrappedText` — the helper that
|
||||
* emits per-row CachedFragment entries for an ink-text whose segments
|
||||
* carry `copySourceFragment` style tags.
|
||||
*
|
||||
* This is the core of the wrap-aware copy fix: pre-fix, fragments only
|
||||
* emitted on row 0, so partial selections across wrap boundaries
|
||||
* degraded to block-level mapping. Post-fix, every (segment × row)
|
||||
* intersection emits a fragment with the per-row source slice (for
|
||||
* verbatim segments) or the whole-segment bounds (for formatted spans).
|
||||
*/
|
||||
describe('computeFragmentsForWrappedText', () => {
|
||||
const mkSeg = (text: string, tag?: { start: number; end: number; verbatim: boolean }): StyledSegment => ({
|
||||
text,
|
||||
styles: {} as StyledSegment['styles'],
|
||||
...(tag ? { copySourceFragment: tag } : {})
|
||||
})
|
||||
|
||||
it('emits no fragments when no segments carry copySourceFragment', () => {
|
||||
const segments = [mkSeg('hello world')]
|
||||
const charToSegment = Array(11).fill(0)
|
||||
|
||||
const fragments = computeFragmentsForWrappedText(
|
||||
'hello world',
|
||||
segments,
|
||||
charToSegment,
|
||||
'hello world',
|
||||
false
|
||||
)
|
||||
|
||||
expect(fragments).toEqual([])
|
||||
})
|
||||
|
||||
it('verbatim segment: row 0 fragment maps cells 1:1 to source bytes', () => {
|
||||
const segments = [mkSeg('hello', { start: 10, end: 15, verbatim: true })]
|
||||
const charToSegment = [0, 0, 0, 0, 0]
|
||||
|
||||
const fragments = computeFragmentsForWrappedText(
|
||||
'hello',
|
||||
segments,
|
||||
charToSegment,
|
||||
'hello',
|
||||
false
|
||||
)
|
||||
|
||||
expect(fragments).toEqual([
|
||||
{ row: 0, colStart: 0, colEnd: 5, start: 10, end: 15, verbatim: true }
|
||||
])
|
||||
})
|
||||
|
||||
it('verbatim segment spanning wrap: row 0 + row 1 each get per-row source slice', () => {
|
||||
// Single segment "abcdefgh" with source bytes [10, 18) wraps to two
|
||||
// rows of width 4. Row 0 = "abcd" → [10, 14). Row 1 = "efgh" → [14, 18).
|
||||
const segments = [mkSeg('abcdefgh', { start: 10, end: 18, verbatim: true })]
|
||||
const charToSegment = [0, 0, 0, 0, 0, 0, 0, 0]
|
||||
|
||||
const fragments = computeFragmentsForWrappedText(
|
||||
'abcd\nefgh',
|
||||
segments,
|
||||
charToSegment,
|
||||
'abcdefgh',
|
||||
false
|
||||
)
|
||||
|
||||
expect(fragments).toHaveLength(2)
|
||||
expect(fragments[0]).toEqual({
|
||||
row: 0,
|
||||
colStart: 0,
|
||||
colEnd: 4,
|
||||
start: 10,
|
||||
end: 14,
|
||||
verbatim: true
|
||||
})
|
||||
expect(fragments[1]).toEqual({
|
||||
row: 1,
|
||||
colStart: 0,
|
||||
colEnd: 4,
|
||||
start: 14,
|
||||
end: 18,
|
||||
verbatim: true
|
||||
})
|
||||
})
|
||||
|
||||
it('formatted segment spanning wrap: every row emits whole-segment bounds', () => {
|
||||
// Formatted (non-verbatim) segment "XYZWQR" with source bytes [20, 30)
|
||||
// wraps across two rows. Both rows should report start=20, end=30 so
|
||||
// the copyPointAt snap-rule maps clicks to start or end based on
|
||||
// half-width within the on-row part, regardless of which row was hit.
|
||||
const segments = [mkSeg('XYZWQR', { start: 20, end: 30, verbatim: false })]
|
||||
const charToSegment = [0, 0, 0, 0, 0, 0]
|
||||
|
||||
const fragments = computeFragmentsForWrappedText(
|
||||
'XYZ\nWQR',
|
||||
segments,
|
||||
charToSegment,
|
||||
'XYZWQR',
|
||||
false
|
||||
)
|
||||
|
||||
expect(fragments).toHaveLength(2)
|
||||
expect(fragments[0]).toEqual({
|
||||
row: 0,
|
||||
colStart: 0,
|
||||
colEnd: 3,
|
||||
start: 20,
|
||||
end: 30,
|
||||
verbatim: false
|
||||
})
|
||||
expect(fragments[1]).toEqual({
|
||||
row: 1,
|
||||
colStart: 0,
|
||||
colEnd: 3,
|
||||
start: 20,
|
||||
end: 30,
|
||||
verbatim: false
|
||||
})
|
||||
})
|
||||
|
||||
it('mixed verbatim + formatted segments wrapping mid-paragraph', () => {
|
||||
// verbatim "abcdefgh" source [10, 18) followed by formatted
|
||||
// "XYZWQRSTUV" source [20, 30). Wrap at 5 cols:
|
||||
// row 0: "abcde" → verbatim seg, source [10, 15)
|
||||
// row 1: "fghXY" → verbatim part [15, 18) + formatted [20, 30)
|
||||
// row 2: "ZWQRS" → formatted whole-seg [20, 30)
|
||||
// row 3: "TUV" → formatted whole-seg [20, 30)
|
||||
const segments = [
|
||||
mkSeg('abcdefgh', { start: 10, end: 18, verbatim: true }),
|
||||
mkSeg('XYZWQRSTUV', { start: 20, end: 30, verbatim: false })
|
||||
]
|
||||
|
||||
const charToSegment = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
|
||||
|
||||
const fragments = computeFragmentsForWrappedText(
|
||||
'abcde\nfghXY\nZWQRS\nTUV',
|
||||
segments,
|
||||
charToSegment,
|
||||
'abcdefghXYZWQRSTUV',
|
||||
false
|
||||
)
|
||||
|
||||
// Row 0: one verbatim fragment for "abcde".
|
||||
const row0 = fragments.filter(f => f.row === 0)
|
||||
expect(row0).toHaveLength(1)
|
||||
expect(row0[0]).toEqual({ row: 0, colStart: 0, colEnd: 5, start: 10, end: 15, verbatim: true })
|
||||
|
||||
// Row 1: two fragments — verbatim "fgh" then formatted "XY".
|
||||
const row1 = fragments.filter(f => f.row === 1).sort((a, b) => a.colStart - b.colStart)
|
||||
expect(row1).toHaveLength(2)
|
||||
expect(row1[0]).toEqual({ row: 1, colStart: 0, colEnd: 3, start: 15, end: 18, verbatim: true })
|
||||
expect(row1[1]).toEqual({ row: 1, colStart: 3, colEnd: 5, start: 20, end: 30, verbatim: false })
|
||||
|
||||
// Row 2 & 3: formatted segment only, whole-segment bounds each row.
|
||||
const row2 = fragments.filter(f => f.row === 2)
|
||||
expect(row2).toHaveLength(1)
|
||||
expect(row2[0]).toEqual({ row: 2, colStart: 0, colEnd: 5, start: 20, end: 30, verbatim: false })
|
||||
|
||||
const row3 = fragments.filter(f => f.row === 3)
|
||||
expect(row3).toHaveLength(1)
|
||||
expect(row3[0]).toEqual({ row: 3, colStart: 0, colEnd: 3, start: 20, end: 30, verbatim: false })
|
||||
})
|
||||
|
||||
it('hard newlines in original advance charIndex correctly across rows', () => {
|
||||
// Two-line source separated by \n. Each line is its own visual row
|
||||
// — no wrap, but the function still walks them line by line.
|
||||
const segments = [mkSeg('hi\nbye', { start: 0, end: 6, verbatim: true })]
|
||||
const charToSegment = [0, 0, 0, 0, 0, 0]
|
||||
|
||||
const fragments = computeFragmentsForWrappedText(
|
||||
'hi\nbye',
|
||||
segments,
|
||||
charToSegment,
|
||||
'hi\nbye',
|
||||
false
|
||||
)
|
||||
|
||||
expect(fragments).toHaveLength(2)
|
||||
expect(fragments[0]).toEqual({ row: 0, colStart: 0, colEnd: 2, start: 0, end: 2, verbatim: true })
|
||||
// After row 0, charIndex skips the '\n' so row 1 starts at byte 3.
|
||||
expect(fragments[1]).toEqual({ row: 1, colStart: 0, colEnd: 3, start: 3, end: 6, verbatim: true })
|
||||
})
|
||||
|
||||
it('wrap-trim eats inter-row whitespace: row 1 maps past the eaten char', () => {
|
||||
// Single source line "the quick brown fox jumps over" wraps at 15
|
||||
// cols. wrap-trim removes the space at byte 15 from the visual
|
||||
// output but it's still in originalPlain. The function must skip
|
||||
// that char when advancing charIndex between rows, otherwise the
|
||||
// row-1 fragment would think it covers bytes 15+ instead of 16+
|
||||
// and clicks on row 1 would map to the wrong source position.
|
||||
const source = 'the quick brown fox jumps over'
|
||||
const segments = [mkSeg(source, { start: 0, end: 30, verbatim: true })]
|
||||
const charToSegment = Array.from({ length: 30 }, () => 0)
|
||||
|
||||
const fragments = computeFragmentsForWrappedText(
|
||||
'the quick brown\nfox jumps over',
|
||||
segments,
|
||||
charToSegment,
|
||||
source,
|
||||
/* trimEnabled */ true
|
||||
)
|
||||
|
||||
expect(fragments).toHaveLength(2)
|
||||
expect(fragments[0]).toEqual({ row: 0, colStart: 0, colEnd: 15, start: 0, end: 15, verbatim: true })
|
||||
// CRITICAL: row 1's source byte starts at 16 (past the eaten space
|
||||
// at byte 15), not at 15.
|
||||
expect(fragments[1]).toEqual({ row: 1, colStart: 0, colEnd: 14, start: 16, end: 30, verbatim: true })
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
@@ -586,12 +586,19 @@ function renderNodeToOutput(
|
||||
|
||||
let text: string
|
||||
let softWrap: boolean[] | undefined
|
||||
let wrappedPlain: string
|
||||
let charToSegmentForFragments: number[]
|
||||
let trimEnabledForFragments = false
|
||||
|
||||
if (needsWrapping && segments.length === 1) {
|
||||
// Single segment: wrap plain text first, then apply styles to each line
|
||||
const segment = segments[0]!
|
||||
const w = wrapWithSoftWrap(plainText, maxWidth, textWrap)
|
||||
softWrap = w.softWrap
|
||||
wrappedPlain = w.wrapped
|
||||
trimEnabledForFragments = textWrap === 'wrap-trim'
|
||||
// Single-segment case: every char in the wrapped output maps to segment 0.
|
||||
charToSegmentForFragments = new Array(plainText.length).fill(0)
|
||||
text = w.wrapped
|
||||
.split('\n')
|
||||
.map(line => {
|
||||
@@ -614,12 +621,17 @@ function renderNodeToOutput(
|
||||
// per-segment styles even when text wraps across lines.
|
||||
const w = wrapWithSoftWrap(plainText, maxWidth, textWrap)
|
||||
softWrap = w.softWrap
|
||||
wrappedPlain = w.wrapped
|
||||
trimEnabledForFragments = textWrap === 'wrap-trim'
|
||||
const charToSegment = buildCharToSegmentMap(segments)
|
||||
charToSegmentForFragments = charToSegment
|
||||
text = applyStylesToWrappedText(w.wrapped, segments, charToSegment, plainText, textWrap === 'wrap-trim')
|
||||
// Hyperlinks are handled per-run in applyStylesToWrappedText via
|
||||
// wrapWithOsc8Link, similar to how styles are applied per-run.
|
||||
} else {
|
||||
// No wrapping needed: apply styles directly
|
||||
wrappedPlain = plainText
|
||||
charToSegmentForFragments = buildCharToSegmentMap(segments)
|
||||
text = segments
|
||||
.map(segment => {
|
||||
let styledText = applyTextStyles(segment.text, segment.styles)
|
||||
@@ -636,6 +648,24 @@ 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 = computeFragmentsForWrappedText(
|
||||
wrappedPlain,
|
||||
segments,
|
||||
charToSegmentForFragments,
|
||||
plainText,
|
||||
trimEnabledForFragments
|
||||
)
|
||||
|
||||
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 +1310,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') {
|
||||
@@ -1555,6 +1597,135 @@ function dropSubtreeCache(node: DOMElement): void {
|
||||
}
|
||||
|
||||
// Exported for testing
|
||||
export { applyStylesToWrappedText, buildCharToSegmentMap }
|
||||
export { applyStylesToWrappedText, buildCharToSegmentMap, computeFragmentsForWrappedText }
|
||||
|
||||
/**
|
||||
* Compute per-row CachedFragment[] for an ink-text that has wrapped
|
||||
* across multiple visual rows. Each segment carrying `copySourceFragment`
|
||||
* emits one entry per visual row it touches.
|
||||
*
|
||||
* The `start`/`end` fields on each emitted fragment are the source byte
|
||||
* range corresponding to JUST THIS ROW'S slice of the segment:
|
||||
*
|
||||
* - For verbatim segments (rendered == source 1:1): `start` is the
|
||||
* segment's source-start plus the segment-relative offset where this
|
||||
* row begins; `end` is start + this-row's plain-text width. The
|
||||
* hit-test then maps within-row col linearly to source bytes via
|
||||
* `start + col` (clamped to end).
|
||||
*
|
||||
* - For non-verbatim (formatted) segments: `start`/`end` are the WHOLE
|
||||
* segment's source byte range on every row. The hit-test snaps clicks
|
||||
* to start or end based on which half of the on-row width was hit,
|
||||
* so per-row identical bounds yields the same behavior — selections
|
||||
* inside formatted spans land on the span's source boundaries
|
||||
* regardless of which wrap-row was clicked. (Slicing source bytes
|
||||
* proportionally per row for formatted spans would be wrong: clicking
|
||||
* mid-row of a wrapped `$\sum_{i=1}^{n}$` math span has no defined
|
||||
* source-byte for that cell because the rendered glyph and source
|
||||
* char counts differ.)
|
||||
*
|
||||
* Mirrors `applyStylesToWrappedText`'s charIndex bookkeeping so the
|
||||
* visual-cell ↔ source-char mapping stays exact through wrap-trim
|
||||
* whitespace eating and through hard newlines in the original.
|
||||
*/
|
||||
function computeFragmentsForWrappedText(
|
||||
wrappedPlain: string,
|
||||
segments: readonly StyledSegment[],
|
||||
charToSegment: readonly number[],
|
||||
originalPlain: string,
|
||||
trimEnabled: boolean
|
||||
): CachedFragment[] {
|
||||
const out: CachedFragment[] = []
|
||||
const lines = wrappedPlain.split('\n')
|
||||
|
||||
// Pre-compute each segment's char-start in originalPlain so we can
|
||||
// convert (segment, charIndex) → segment-relative offset → source byte.
|
||||
const segPlainStarts: number[] = []
|
||||
|
||||
{
|
||||
let acc = 0
|
||||
|
||||
for (const seg of segments) {
|
||||
segPlainStarts.push(acc)
|
||||
acc += seg.text.length
|
||||
}
|
||||
}
|
||||
|
||||
let charIndex = 0
|
||||
|
||||
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
||||
const line = lines[lineIdx]!
|
||||
|
||||
let runStart = 0
|
||||
let runSegIdx = charToSegment[charIndex] ?? 0
|
||||
let runCharStart = charIndex
|
||||
|
||||
const flushRun = (visualEnd: number): void => {
|
||||
if (visualEnd <= runStart) {
|
||||
return
|
||||
}
|
||||
|
||||
const seg = segments[runSegIdx]
|
||||
const tag = seg?.copySourceFragment
|
||||
|
||||
if (tag) {
|
||||
let start: number
|
||||
let end: number
|
||||
|
||||
if (tag.verbatim) {
|
||||
// For verbatim segments, the on-row source slice is the
|
||||
// segment-relative offset (runCharStart - segPlainStart) +
|
||||
// segment-source-start, length = visualEnd - runStart.
|
||||
const segPlainStart = segPlainStarts[runSegIdx] ?? 0
|
||||
const offsetInSeg = runCharStart - segPlainStart
|
||||
const runLen = visualEnd - runStart
|
||||
|
||||
start = tag.start + offsetInSeg
|
||||
end = Math.min(tag.end, start + runLen)
|
||||
} else {
|
||||
// Formatted segments use whole-segment bounds; the snap rule
|
||||
// in copyPointAt picks start vs end based on which half of
|
||||
// the on-row width was clicked.
|
||||
start = tag.start
|
||||
end = tag.end
|
||||
}
|
||||
|
||||
out.push({
|
||||
row: lineIdx,
|
||||
colStart: runStart,
|
||||
colEnd: visualEnd,
|
||||
start,
|
||||
end,
|
||||
verbatim: tag.verbatim
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const curSeg = charToSegment[charIndex] ?? runSegIdx
|
||||
|
||||
if (curSeg !== runSegIdx) {
|
||||
flushRun(i)
|
||||
runStart = i
|
||||
runSegIdx = curSeg
|
||||
runCharStart = charIndex
|
||||
}
|
||||
|
||||
charIndex++
|
||||
}
|
||||
|
||||
flushRun(line.length)
|
||||
|
||||
// Skip the inter-line char in originalPlain (real \n or wrap-trim
|
||||
// whitespace) — same logic as applyStylesToWrappedText.
|
||||
if (charIndex < originalPlain.length && originalPlain[charIndex] === '\n') {
|
||||
charIndex++
|
||||
} else if (trimEnabled && lineIdx < lines.length - 1 && /\s/.test(originalPlain[charIndex] ?? '')) {
|
||||
charIndex++
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export default renderNodeToOutput
|
||||
|
||||
@@ -101,6 +101,16 @@ export const forceRedraw = (stdout: NodeJS.WriteStream = process.stdout): boolea
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the live Ink instance for a given output stream. Returns null if
|
||||
* no Ink is mounted for that stdout. Used by host code (TUI) to install
|
||||
* copy-text overrides + read selection state for the transcript-virtual
|
||||
* copy-source pipeline.
|
||||
*/
|
||||
export const getInkForStdout = (stdout: NodeJS.WriteStream = process.stdout): Ink | null => {
|
||||
return instances.get(stdout) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a component and render the output.
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -396,6 +396,48 @@ export type Styles = {
|
||||
* doesn't pick up leading whitespace from middle rows.
|
||||
*/
|
||||
readonly noSelect?: boolean | 'from-left-edge'
|
||||
|
||||
/**
|
||||
* Source-range id from the copySource registry.
|
||||
*
|
||||
* When set on an ink-box, the rendered DOMElement carries this rangeId
|
||||
* on its `attributes` and the copy-source hitTest can map (col, row)
|
||||
* mouse positions back to a transcript-virtual SelectionPoint. The Box
|
||||
* itself has no visual effect from this prop — it's purely a data
|
||||
* carrier for the selection/copy pipeline (mirrors how `noSelect` only
|
||||
* 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 => {
|
||||
|
||||
@@ -310,7 +310,10 @@ let linuxCopy: 'wl-copy' | 'xclip' | 'xsel' | null | undefined
|
||||
|
||||
/** Internal: probe once and cache — wl-copy first, then xclip, then xsel. */
|
||||
async function probeLinuxCopy(): Promise<'wl-copy' | 'xclip' | 'xsel' | null> {
|
||||
const opts = { useCwd: false, timeout: 500 }
|
||||
// resolveOnExit: wl-copy daemonizes and the daemon inherits stdio pipes,
|
||||
// so 'close' never fires and the await would hang past the timeout.
|
||||
// 'exit' fires on the immediate child's exit — what we actually care about.
|
||||
const opts = { useCwd: false, timeout: 500, resolveOnExit: true }
|
||||
|
||||
const r = await execFileNoThrow('wl-copy', [], opts)
|
||||
|
||||
@@ -347,7 +350,11 @@ async function probeLinuxCopy(): Promise<'wl-copy' | 'xclip' | 'xsel' | null> {
|
||||
* we skip probing entirely and treat linuxCopy as permanently null.
|
||||
*/
|
||||
function copyNative(text: string): boolean {
|
||||
const opts = { input: text, useCwd: false, timeout: 2000 }
|
||||
// resolveOnExit: pbcopy/wl-copy/xclip/xsel/clip all daemonize or hold
|
||||
// the system selection live in a forked process. Without resolveOnExit,
|
||||
// the inherited stdio pipes keep node from seeing 'close' → the
|
||||
// fire-and-forget await never resolves and the actual copy never runs.
|
||||
const opts = { input: text, useCwd: false, timeout: 2000, resolveOnExit: true }
|
||||
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
|
||||
111
ui-tui/packages/hermes-ink/src/utils/debug.test.ts
Normal file
111
ui-tui/packages/hermes-ink/src/utils/debug.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { logForDebugging } from './debug.js'
|
||||
|
||||
let logDir: string
|
||||
let logPath: string
|
||||
|
||||
const ENV_KEYS = [
|
||||
'HERMES_TUI_DEBUG',
|
||||
'HERMES_TUI_DEBUG_CLIPBOARD',
|
||||
'HERMES_TUI_DEBUG_INPUT',
|
||||
'HERMES_TUI_DEBUG_RENDER',
|
||||
'HERMES_TUI_DEBUG_SELECTION',
|
||||
'HERMES_TUI_DEBUG_LOG'
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
logDir = join(tmpdir(), `hermes-debug-test-${process.pid}-${Date.now()}`)
|
||||
mkdirSync(logDir, { recursive: true })
|
||||
logPath = join(logDir, 'tui-stderr.log')
|
||||
|
||||
// Clean slate every test — env vars from this test must not leak
|
||||
// into the next, and vice versa.
|
||||
for (const k of ENV_KEYS) {
|
||||
delete process.env[k]
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const k of ENV_KEYS) {
|
||||
delete process.env[k]
|
||||
}
|
||||
|
||||
rmSync(logDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('logForDebugging', () => {
|
||||
it('drops messages on the floor when no debug flag is set', () => {
|
||||
process.env.HERMES_TUI_DEBUG_LOG = logPath
|
||||
logForDebugging('should not appear')
|
||||
|
||||
expect(existsSync(logPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('writes to HERMES_TUI_DEBUG_LOG when HERMES_TUI_DEBUG=1', () => {
|
||||
process.env.HERMES_TUI_DEBUG = '1'
|
||||
process.env.HERMES_TUI_DEBUG_LOG = logPath
|
||||
|
||||
logForDebugging('hello world')
|
||||
|
||||
const contents = readFileSync(logPath, 'utf8')
|
||||
expect(contents).toMatch(/\[info\] hello world/)
|
||||
})
|
||||
|
||||
it('honors level option', () => {
|
||||
process.env.HERMES_TUI_DEBUG = '1'
|
||||
process.env.HERMES_TUI_DEBUG_LOG = logPath
|
||||
|
||||
logForDebugging('something went wrong', { level: 'error' })
|
||||
|
||||
const contents = readFileSync(logPath, 'utf8')
|
||||
expect(contents).toMatch(/\[error\] something went wrong/)
|
||||
})
|
||||
|
||||
it('activates on any HERMES_TUI_DEBUG_* flag', () => {
|
||||
process.env.HERMES_TUI_DEBUG_CLIPBOARD = '1'
|
||||
process.env.HERMES_TUI_DEBUG_LOG = logPath
|
||||
|
||||
logForDebugging('clipboard probe done')
|
||||
|
||||
const contents = readFileSync(logPath, 'utf8')
|
||||
expect(contents).toMatch(/clipboard probe done/)
|
||||
})
|
||||
|
||||
it('appends rather than overwriting', () => {
|
||||
process.env.HERMES_TUI_DEBUG = '1'
|
||||
process.env.HERMES_TUI_DEBUG_LOG = logPath
|
||||
|
||||
logForDebugging('first')
|
||||
logForDebugging('second')
|
||||
|
||||
const lines = readFileSync(logPath, 'utf8').trim().split('\n')
|
||||
expect(lines).toHaveLength(2)
|
||||
expect(lines[0]).toMatch(/first/)
|
||||
expect(lines[1]).toMatch(/second/)
|
||||
})
|
||||
|
||||
it('prefixes each line with an ISO timestamp', () => {
|
||||
process.env.HERMES_TUI_DEBUG = '1'
|
||||
process.env.HERMES_TUI_DEBUG_LOG = logPath
|
||||
|
||||
logForDebugging('marker')
|
||||
|
||||
const contents = readFileSync(logPath, 'utf8').trim()
|
||||
// ISO 8601 prefix: 2026-05-11T22:30:45.123Z
|
||||
expect(contents).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z /)
|
||||
})
|
||||
|
||||
it('does not throw when the log directory cannot be created', () => {
|
||||
process.env.HERMES_TUI_DEBUG = '1'
|
||||
// Path under /proc/1 is read-only on Linux — unwritable for tests.
|
||||
// Falls back to silent failure rather than crashing the TUI.
|
||||
process.env.HERMES_TUI_DEBUG_LOG = '/proc/1/cant-write-here.log'
|
||||
|
||||
expect(() => logForDebugging('boom')).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,93 @@
|
||||
import { appendFileSync, mkdirSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
/**
|
||||
* Capture stderr/console.error writes that the alt-screen patcher would
|
||||
* otherwise drop on the floor.
|
||||
*
|
||||
* Why this exists: ink's `patchStderr()` rewrites `process.stderr.write`
|
||||
* to call `logForDebugging()` so stray writes don't corrupt the
|
||||
* alt-screen diff buffer. Historically this function was a no-op, which
|
||||
* meant `console.error` (and any module that wrote diagnostics to stderr)
|
||||
* was completely silent inside the TUI. That made bugs like the wl-copy
|
||||
* daemonization hang impossible to diagnose without rebuilding and
|
||||
* trial-and-erroring with strategic edits.
|
||||
*
|
||||
* Now: when `HERMES_TUI_DEBUG=1` (or any of the more specific
|
||||
* `HERMES_TUI_DEBUG_*` flags) is set, drop messages into
|
||||
* `~/.hermes/logs/tui-stderr.log`. Best-effort — failures are swallowed
|
||||
* because we'd rather lose a debug message than crash the TUI.
|
||||
*
|
||||
* Override the destination via `HERMES_TUI_DEBUG_LOG=<path>` when you
|
||||
* want a one-off log file (e.g. `/tmp/clip.log`).
|
||||
*/
|
||||
|
||||
const HERMES_DEBUG_FLAGS = [
|
||||
'HERMES_TUI_DEBUG',
|
||||
'HERMES_TUI_DEBUG_CLIPBOARD',
|
||||
'HERMES_TUI_DEBUG_INPUT',
|
||||
'HERMES_TUI_DEBUG_RENDER',
|
||||
'HERMES_TUI_DEBUG_SELECTION'
|
||||
]
|
||||
|
||||
function isDebugEnabled(): boolean {
|
||||
for (const flag of HERMES_DEBUG_FLAGS) {
|
||||
if (process.env[flag]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function resolveLogPath(): string {
|
||||
const override = process.env.HERMES_TUI_DEBUG_LOG?.trim()
|
||||
|
||||
if (override) {
|
||||
return override
|
||||
}
|
||||
|
||||
return join(homedir(), '.hermes', 'logs', 'tui-stderr.log')
|
||||
}
|
||||
|
||||
let logPathReady = false
|
||||
|
||||
function ensureLogPath(path: string): void {
|
||||
if (logPathReady) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
logPathReady = true
|
||||
} catch {
|
||||
// Best-effort — a missing/unwritable parent dir means we'll try
|
||||
// appendFileSync below and silently lose this message. Caller is
|
||||
// already in an error path; we don't surface the issue.
|
||||
}
|
||||
}
|
||||
|
||||
export function logForDebugging(
|
||||
_message: string,
|
||||
_options: {
|
||||
message: string,
|
||||
options: {
|
||||
level?: string
|
||||
} = {}
|
||||
): void {}
|
||||
): void {
|
||||
if (!isDebugEnabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
const path = resolveLogPath()
|
||||
ensureLogPath(path)
|
||||
|
||||
const level = options.level ?? 'info'
|
||||
const ts = new Date().toISOString()
|
||||
const line = `${ts} [${level}] ${message}\n`
|
||||
|
||||
try {
|
||||
appendFileSync(path, line)
|
||||
} catch {
|
||||
// Lost message — the alternative is crashing the TUI from a logger.
|
||||
}
|
||||
}
|
||||
|
||||
110
ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.test.ts
Normal file
110
ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { chmodSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { execFileNoThrow } from './execFileNoThrow.js'
|
||||
|
||||
// We simulate `wl-copy`'s daemonization behavior with a tiny shell script:
|
||||
// 1. Fork a long-lived background sleeper that inherits stdio (so the
|
||||
// parent process's pipes can never close).
|
||||
// 2. Exit immediately with status 0.
|
||||
//
|
||||
// Without resolveOnExit, the await on `'close'` hangs until SIGTERM at
|
||||
// timeout — exactly the production wl-copy bug. With resolveOnExit, the
|
||||
// promise settles on `'exit'` regardless of the inherited pipes.
|
||||
|
||||
let scriptDir: string
|
||||
let daemonScript: string
|
||||
|
||||
beforeEach(() => {
|
||||
scriptDir = join(tmpdir(), `hermes-execfile-test-${process.pid}-${Date.now()}`)
|
||||
mkdirSync(scriptDir, { recursive: true })
|
||||
daemonScript = join(scriptDir, 'fake-daemonizer.sh')
|
||||
// Posix sh: the `sleep 30 &` child inherits stdin/stdout/stderr from the
|
||||
// shell, which inherited them from `spawn(stdio: 'pipe')`. The shell
|
||||
// exits but its child (the sleeper) keeps the pipes open. Mirrors how
|
||||
// wl-copy double-forks then exits while the daemon holds the selection.
|
||||
writeFileSync(daemonScript, '#!/bin/sh\nsleep 30 &\nexit 0\n')
|
||||
chmodSync(daemonScript, 0o755)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(scriptDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('execFileNoThrow with daemon-style children', () => {
|
||||
// Skipped because the bug it documents is a forever-hang. Without
|
||||
// resolveOnExit, the 'close' event doesn't fire when the immediate
|
||||
// child has exited but a forked daemon still holds stdio open. Even
|
||||
// SIGTERM at the timeout doesn't help — the daemon survives it. To
|
||||
// verify by hand: remove `it.skip` and watch the test timeout. This
|
||||
// test is here so a reviewer reading the resolveOnExit option knows
|
||||
// *why* every clipboard-tool spawn in osc.ts wires it on.
|
||||
it.skip("(documented hang) without resolveOnExit, await never resolves when daemon inherits stdio", async () => {
|
||||
const result = await execFileNoThrow(daemonScript, [], { timeout: 300 })
|
||||
|
||||
expect(result.code).toBe(124)
|
||||
})
|
||||
|
||||
it("settles immediately on 'exit' when resolveOnExit is true, regardless of daemon stdio", async () => {
|
||||
const start = Date.now()
|
||||
|
||||
const result = await execFileNoThrow(daemonScript, [], {
|
||||
timeout: 2000,
|
||||
resolveOnExit: true
|
||||
})
|
||||
|
||||
const elapsed = Date.now() - start
|
||||
|
||||
// The shell exits in a few ms. resolveOnExit lets us return on exit
|
||||
// (code 0) instead of waiting for the orphaned sleeper to release
|
||||
// stdio. Should be well under 200ms even on slow CI.
|
||||
expect(result.code).toBe(0)
|
||||
expect(elapsed).toBeLessThan(500)
|
||||
})
|
||||
|
||||
it("still surfaces the right code when resolveOnExit'd child exits non-zero", async () => {
|
||||
const failScript = join(scriptDir, 'fail.sh')
|
||||
writeFileSync(failScript, '#!/bin/sh\nsleep 30 &\nexit 7\n')
|
||||
chmodSync(failScript, 0o755)
|
||||
|
||||
const result = await execFileNoThrow(failScript, [], {
|
||||
timeout: 2000,
|
||||
resolveOnExit: true
|
||||
})
|
||||
|
||||
expect(result.code).toBe(7)
|
||||
})
|
||||
|
||||
it('settles on timeout=124 when the child itself never exits, even with resolveOnExit', async () => {
|
||||
const slowScript = join(scriptDir, 'slow.sh')
|
||||
writeFileSync(slowScript, '#!/bin/sh\nsleep 30\n')
|
||||
chmodSync(slowScript, 0o755)
|
||||
|
||||
const result = await execFileNoThrow(slowScript, [], {
|
||||
timeout: 200,
|
||||
resolveOnExit: true
|
||||
})
|
||||
|
||||
// Child process never exits on its own → timer fires → SIGTERM →
|
||||
// child exits → 'exit' fires with non-null signal. The settle()
|
||||
// call from the timer registers code=124 first. Either way: 124.
|
||||
expect(result.code).toBe(124)
|
||||
})
|
||||
|
||||
it('does not double-resolve when both timer and exit fire', async () => {
|
||||
// Race: child happens to exit right around the timeout. The settled
|
||||
// guard ensures only the first resolution wins.
|
||||
const result = await execFileNoThrow(daemonScript, [], {
|
||||
timeout: 50, // very tight
|
||||
resolveOnExit: true
|
||||
})
|
||||
|
||||
// Either code=0 (exit beat timer) or code=124 (timer beat exit).
|
||||
// Both are valid outcomes; the contract is that the promise settles
|
||||
// exactly once and doesn't throw.
|
||||
expect([0, 124]).toContain(result.code)
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,14 @@ type ExecFileOptions = {
|
||||
timeout?: number
|
||||
useCwd?: boolean
|
||||
env?: NodeJS.ProcessEnv
|
||||
/** Resolve as soon as the child *exits*, instead of waiting for its
|
||||
* stdio streams to close. Use this for tools that fork a daemon and
|
||||
* let the daemon inherit the parent's stdio (e.g. `wl-copy`): the
|
||||
* child exits immediately, but `'close'` never fires because the
|
||||
* daemon holds the pipes open. The caller must not depend on
|
||||
* collecting stdout/stderr from that point on — only what arrived
|
||||
* before exit is included in the resolved value. */
|
||||
resolveOnExit?: boolean
|
||||
}
|
||||
|
||||
export function execFileNoThrow(
|
||||
@@ -26,11 +34,33 @@ export function execFileNoThrow(
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let timedOut = false
|
||||
let settled = false
|
||||
|
||||
const settle = (code: number, error?: string) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
resolve({ stdout, stderr, code, ...(error ? { error } : {}) })
|
||||
}
|
||||
|
||||
const timer = options.timeout
|
||||
? setTimeout(() => {
|
||||
timedOut = true
|
||||
child.kill('SIGTERM')
|
||||
|
||||
// When resolving on exit, SIGTERM-ing a child that has already
|
||||
// exited is a no-op and `'exit'` won't fire again — settle here
|
||||
// so the promise doesn't leak. Safe under settled-guard.
|
||||
if (options.resolveOnExit) {
|
||||
settle(124)
|
||||
}
|
||||
}, options.timeout)
|
||||
: null
|
||||
|
||||
@@ -41,19 +71,20 @@ export function execFileNoThrow(
|
||||
stderr += String(chunk)
|
||||
})
|
||||
child.on('error', error => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
resolve({ stdout, stderr, code: 1, error: String(error) })
|
||||
settle(1, String(error))
|
||||
})
|
||||
child.on('close', code => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
resolve({ stdout, stderr, code: timedOut ? 124 : (code ?? 0) })
|
||||
})
|
||||
if (options.resolveOnExit) {
|
||||
// 'exit' fires when the child process itself exits — even if the
|
||||
// daemon it forked still holds the inherited stdio pipes open.
|
||||
child.on('exit', code => {
|
||||
settle(timedOut ? 124 : (code ?? 0))
|
||||
})
|
||||
} else {
|
||||
child.on('close', code => {
|
||||
settle(timedOut ? 124 : (code ?? 0))
|
||||
})
|
||||
}
|
||||
|
||||
if (options.input) {
|
||||
child.stdin?.write(options.input)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -228,6 +230,16 @@ 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')
|
||||
})
|
||||
|
||||
it('renders Python dunder identifiers literally outside code fences', () => {
|
||||
const lines = renderPlain(
|
||||
React.createElement(
|
||||
@@ -247,6 +259,65 @@ describe('Md wrapping', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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('Md link labels', () => {
|
||||
it('renders bare URLs with readable slug labels', () => {
|
||||
const lines = renderPlain(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useApp, useHasSelection, useSelection, useStdout, useTerminalTitle, type ScrollBoxHandle } from '@hermes/ink'
|
||||
import { getInkForStdout, type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@hermes/ink'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
@@ -16,6 +16,9 @@ import type {
|
||||
} from '../gatewayTypes.js'
|
||||
import { useGitBranch } from '../hooks/useGitBranch.js'
|
||||
import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
|
||||
import { makeCopyTextFn } from '../lib/copySource/buildCopyTextFromDom.js'
|
||||
import { evictMessage } from '../lib/copySource/registry.js'
|
||||
import type { MsgSnapshot } from '../lib/copySource/types.js'
|
||||
import { composerPromptWidth } from '../lib/inputMetrics.js'
|
||||
import { appendTranscriptMessage } from '../lib/messages.js'
|
||||
import { DEFAULT_VOICE_RECORD_KEY, isMac, type ParsedVoiceRecordKey } from '../lib/platform.js'
|
||||
@@ -238,6 +241,67 @@ export function useMainApp(gw: GatewayClient) {
|
||||
[historyItems, messageId]
|
||||
)
|
||||
|
||||
// ── Copy-source pipeline wiring ────────────────────────────────────────
|
||||
// The transcript-virtual copy-source registry needs three things from
|
||||
// the host (`useMainApp`):
|
||||
// 1. A getter for the current msg ordering, so toCopyText can sort
|
||||
// ranges in document order.
|
||||
// 2. Eviction of registry entries whose msgs have been popped from
|
||||
// history (history-cap, /undo, /clear). Without this, stale
|
||||
// ranges accumulate forever.
|
||||
// 3. Installation of the copy-text override on the live Ink instance,
|
||||
// once at mount, so ctrl-c uses the transcript-virtual pipeline
|
||||
// instead of cell extraction.
|
||||
//
|
||||
// The transcriptRef + makeCopyTextFn pattern decouples the closure-
|
||||
// captured transcript at install time from the live transcript at copy
|
||||
// time — without the ref the override would always see the empty
|
||||
// initial array.
|
||||
const transcriptRef = useRef<MsgSnapshot[]>([])
|
||||
|
||||
// Keep transcriptRef in sync with the latest virtualRows. Runs in an
|
||||
// effect rather than the render body so react-compiler is happy (writing
|
||||
// to a ref outside an effect is a foot-gun in concurrent React even
|
||||
// though refs don't trigger re-renders).
|
||||
useEffect(() => {
|
||||
transcriptRef.current = virtualRows.map((row, idx) => ({ id: row.key, order: idx }))
|
||||
}, [virtualRows])
|
||||
|
||||
// Track which msgIds are currently mounted so eviction fires for the
|
||||
// delta on each render. Plain string Set is enough — msgIds are stable
|
||||
// strings allocated by `messageId()`.
|
||||
const liveMsgIdsRef = useRef<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
const current = new Set(virtualRows.map(r => r.key))
|
||||
|
||||
for (const id of liveMsgIdsRef.current) {
|
||||
if (!current.has(id)) {
|
||||
evictMessage(id)
|
||||
}
|
||||
}
|
||||
|
||||
liveMsgIdsRef.current = current
|
||||
}, [virtualRows])
|
||||
|
||||
// One-time install of the copy-text override on the Ink instance. The
|
||||
// override reads transcriptRef so it always sees the latest msg list
|
||||
// even though it's installed only once.
|
||||
useEffect(() => {
|
||||
const ink = getInkForStdout(stdout)
|
||||
|
||||
if (!ink) {
|
||||
return
|
||||
}
|
||||
|
||||
const fn = makeCopyTextFn(() => transcriptRef.current)
|
||||
ink.setCopyTextFn(fn)
|
||||
|
||||
return () => {
|
||||
ink.setCopyTextFn(null)
|
||||
}
|
||||
}, [stdout])
|
||||
|
||||
const detailsLayoutKey = useMemo(() => {
|
||||
const thinking = sectionMode('thinking', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride)
|
||||
const tools = sectionMode('tools', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride)
|
||||
@@ -736,10 +800,13 @@ export function useMainApp(gw: GatewayClient) {
|
||||
const anyPanelVisible = SECTION_NAMES.some(
|
||||
s => sectionMode(s, ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
|
||||
)
|
||||
|
||||
const thinkingPanelVisible =
|
||||
sectionMode('thinking', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
|
||||
|
||||
const toolsPanelVisible =
|
||||
sectionMode('tools', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
|
||||
|
||||
const activityPanelVisible =
|
||||
sectionMode('activity', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
|
||||
|
||||
|
||||
@@ -125,6 +125,7 @@ const TranscriptPane = memo(function TranscriptPane({
|
||||
detailsMode={ui.detailsMode}
|
||||
detailsModeCommandOverride={ui.detailsModeCommandOverride}
|
||||
msg={row.msg}
|
||||
msgId={row.key}
|
||||
sections={ui.sections}
|
||||
t={ui.theme}
|
||||
/>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,12 @@
|
||||
import { Ansi, Box, NoSelect, Text } from '@hermes/ink'
|
||||
import { memo, useState } from 'react'
|
||||
import { memo, type ReactNode, useState } from 'react'
|
||||
|
||||
import { LONG_MSG } from '../config/limits.js'
|
||||
import { sectionMode } from '../domain/details.js'
|
||||
import { userDisplay } from '../domain/messages.js'
|
||||
import { ROLE } from '../domain/roles.js'
|
||||
import { CopySource } from '../lib/copySource/CopySource.js'
|
||||
import { buildLineStartsFromRows, simpleOffsetFor } from '../lib/copySource/offsetMaps.js'
|
||||
import { transcriptBodyWidth, transcriptGutterWidth } from '../lib/inputMetrics.js'
|
||||
import {
|
||||
boundedLiveRenderText,
|
||||
@@ -32,6 +34,7 @@ export const MessageLine = memo(function MessageLine({
|
||||
detailsModeCommandOverride = false,
|
||||
isStreaming = false,
|
||||
msg,
|
||||
msgId,
|
||||
sections,
|
||||
t,
|
||||
tools = []
|
||||
@@ -69,6 +72,7 @@ export const MessageLine = memo(function MessageLine({
|
||||
<ToolTrail
|
||||
commandOverride={detailsModeCommandOverride}
|
||||
detailsMode={detailsMode}
|
||||
msgId={msgId}
|
||||
reasoning={thinking}
|
||||
reasoningTokens={msg.thinkingTokens}
|
||||
sections={sections}
|
||||
@@ -87,17 +91,19 @@ export const MessageLine = memo(function MessageLine({
|
||||
const safeAnsi = hasAnsi(msg.text) ? sanitizeAnsiForRender(msg.text) : msg.text
|
||||
const preview = compactPreview(stripped, maxChars) || '(empty tool result)'
|
||||
|
||||
const previewNode = hasAnsi(msg.text) ? (
|
||||
<Text wrap="truncate-end">
|
||||
<Ansi>{safeAnsi}</Ansi>
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{preview}
|
||||
</Text>
|
||||
)
|
||||
|
||||
return (
|
||||
<Box alignSelf="flex-start" borderColor={t.color.muted} borderStyle="round" marginLeft={3} paddingX={1}>
|
||||
{hasAnsi(msg.text) ? (
|
||||
<Text wrap="truncate-end">
|
||||
<Ansi>{safeAnsi}</Ansi>
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{preview}
|
||||
</Text>
|
||||
)}
|
||||
{wrapCopySource(msgId, msg.text, previewNode)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -110,7 +116,7 @@ export const MessageLine = memo(function MessageLine({
|
||||
|
||||
const content = (() => {
|
||||
if (msg.kind === 'slash') {
|
||||
return <Text color={t.color.muted}>{msg.text}</Text>
|
||||
return wrapCopySource(msgId, msg.text, <Text color={t.color.muted}>{msg.text}</Text>)
|
||||
}
|
||||
|
||||
// ── Collapsible long system message (system prompt, AGENTS.md, etc.) ──
|
||||
@@ -129,13 +135,13 @@ export const MessageLine = memo(function MessageLine({
|
||||
{msg.text.length.toLocaleString()} chars
|
||||
</Text>
|
||||
</Box>
|
||||
{systemOpen && <Ansi>{sanitizeAnsiForRender(msg.text)}</Ansi>}
|
||||
{systemOpen && wrapCopySource(msgId, msg.text, <Ansi>{sanitizeAnsiForRender(msg.text)}</Ansi>)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (msg.role !== 'user' && hasAnsi(msg.text)) {
|
||||
return <Ansi>{sanitizeAnsiForRender(msg.text)}</Ansi>
|
||||
return wrapCopySource(msgId, msg.text, <Ansi>{sanitizeAnsiForRender(msg.text)}</Ansi>)
|
||||
}
|
||||
|
||||
if (msg.role === 'assistant') {
|
||||
@@ -145,16 +151,22 @@ export const MessageLine = memo(function MessageLine({
|
||||
// Incremental markdown: split at the last stable block boundary so
|
||||
// only the in-flight tail re-tokenizes per delta. See
|
||||
// streamingMarkdown.tsx for the cost model.
|
||||
<StreamingMd cols={bodyWidth} compact={compact} t={t} text={boundedLiveRenderText(msg.text)} />
|
||||
<StreamingMd cols={bodyWidth} compact={compact} msgId={msgId} t={t} text={boundedLiveRenderText(msg.text)} />
|
||||
) : (
|
||||
<Md cols={bodyWidth} compact={compact} t={t} text={msg.text} />
|
||||
<Md cols={bodyWidth} compact={compact}
|
||||
msgId={msgId}
|
||||
t={t}
|
||||
text={msg.text}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (msg.role === 'user' && msg.text.length > LONG_MSG && isPasteBackedText(msg.text)) {
|
||||
const [head, ...rest] = userDisplay(msg.text).split('[long message]')
|
||||
|
||||
return (
|
||||
return wrapCopySource(
|
||||
msgId,
|
||||
msg.text,
|
||||
<Text color={body}>
|
||||
{head}
|
||||
<Text color={t.color.muted} dimColor>
|
||||
@@ -165,7 +177,7 @@ export const MessageLine = memo(function MessageLine({
|
||||
)
|
||||
}
|
||||
|
||||
return <Text {...(body ? { color: body } : {})}>{msg.text}</Text>
|
||||
return wrapCopySource(msgId, msg.text, <Text {...(body ? { color: body } : {})}>{msg.text}</Text>)
|
||||
})()
|
||||
|
||||
// Diff segments (emitted by pushInlineDiffSegment between narration
|
||||
@@ -184,6 +196,7 @@ export const MessageLine = memo(function MessageLine({
|
||||
<ToolTrail
|
||||
commandOverride={detailsModeCommandOverride}
|
||||
detailsMode={detailsMode}
|
||||
msgId={msgId}
|
||||
reasoning={thinking}
|
||||
reasoningTokens={msg.thinkingTokens}
|
||||
sections={sections}
|
||||
@@ -214,7 +227,50 @@ interface MessageLineProps {
|
||||
detailsModeCommandOverride?: boolean
|
||||
isStreaming?: boolean
|
||||
msg: Msg
|
||||
/** Stable id used to anchor copy-source ranges in the registry. When
|
||||
* unset, the message isn't covered by the copy-source pipeline — its
|
||||
* text won't survive partial-selection round-trip. Set this for any
|
||||
* message in the transcript that the user might copy. Trail / intro /
|
||||
* panel messages don't need it (no copyable body text). */
|
||||
msgId?: string
|
||||
sections?: SectionVisibility
|
||||
t: Theme
|
||||
tools?: ActiveTool[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a rendered node in a whole-message CopySource so partial selection
|
||||
* of plain (non-markdown) message content round-trips the raw source text.
|
||||
*
|
||||
* blockIndex=0 is reserved for whole-msg ranges (markdown blocks use ≥1
|
||||
* via Md's `blockIndexBase`). visualLineCount = source line count; the
|
||||
* simple offset map maps each visual row (relative to the wrapping box)
|
||||
* to the byte offset of the corresponding source line. Soft-wrap
|
||||
* continuations at the Ink layer fall past `visualLineCount`, which
|
||||
* clamps to `outerSource.length` — copying a selection that ends inside
|
||||
* a soft-wrapped continuation snaps to the end of that source line.
|
||||
*
|
||||
* When `msgId` is undefined (trail / intro / panel etc.), returns the
|
||||
* raw node — those msgs aren't covered by the copy pipeline and don't
|
||||
* need to be.
|
||||
*/
|
||||
function wrapCopySource(msgId: string | undefined, source: string, node: ReactNode): ReactNode {
|
||||
if (!msgId) {
|
||||
return node
|
||||
}
|
||||
|
||||
const lineRows = source.split('\n')
|
||||
const rowStarts = buildLineStartsFromRows(lineRows)
|
||||
|
||||
return (
|
||||
<CopySource
|
||||
blockIndex={0}
|
||||
getOffset={simpleOffsetFor(source, rowStarts)}
|
||||
msgId={msgId}
|
||||
outerSource={source}
|
||||
visualLineCount={Math.max(1, lineRows.length)}
|
||||
>
|
||||
{node}
|
||||
</CopySource>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ export const StreamingAssistant = memo(function StreamingAssistant({
|
||||
detailsModeCommandOverride={detailsModeCommandOverride}
|
||||
key={`seg:${i}`}
|
||||
msg={msg}
|
||||
msgId={`streaming:seg:${i}`}
|
||||
sections={sections}
|
||||
t={ui.theme}
|
||||
/>
|
||||
@@ -72,6 +73,7 @@ export const StreamingAssistant = memo(function StreamingAssistant({
|
||||
text: streaming,
|
||||
...(streamPendingTools.length && { tools: streamPendingTools })
|
||||
}}
|
||||
msgId="streaming:live"
|
||||
sections={sections}
|
||||
t={ui.theme}
|
||||
/>
|
||||
|
||||
@@ -128,7 +128,7 @@ export const findStableBoundary = (text: string) => {
|
||||
return -1
|
||||
}
|
||||
|
||||
export const StreamingMd = memo(function StreamingMd({ cols, compact, t, text }: StreamingMdProps) {
|
||||
export const StreamingMd = memo(function StreamingMd({ cols, compact, msgId, t, text }: StreamingMdProps) {
|
||||
const stablePrefixRef = useRef('')
|
||||
|
||||
// Reset if the text no longer starts with our recorded prefix (defensive;
|
||||
@@ -150,18 +150,25 @@ export const StreamingMd = memo(function StreamingMd({ cols, compact, t, text }:
|
||||
const stablePrefix = stablePrefixRef.current
|
||||
const unstableSuffix = text.slice(stablePrefix.length)
|
||||
|
||||
// Suffix blockIndexBase is offset by SUFFIX_BLOCK_OFFSET so its blocks
|
||||
// order AFTER the prefix's in document order, regardless of how many
|
||||
// blocks the prefix has. 1_000_000 is comfortably above any realistic
|
||||
// prefix block count (would need a million top-level markdown blocks
|
||||
// in one message to collide; chat messages cap at thousands of lines).
|
||||
const SUFFIX_BLOCK_OFFSET = 1_000_000
|
||||
|
||||
if (!stablePrefix) {
|
||||
return <Md cols={cols} compact={compact} t={t} text={unstableSuffix} />
|
||||
return <Md cols={cols} compact={compact} msgId={msgId} t={t} text={unstableSuffix} />
|
||||
}
|
||||
|
||||
if (!unstableSuffix) {
|
||||
return <Md cols={cols} compact={compact} t={t} text={stablePrefix} />
|
||||
return <Md cols={cols} compact={compact} msgId={msgId} t={t} text={stablePrefix} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Md cols={cols} compact={compact} t={t} text={stablePrefix} />
|
||||
<Md cols={cols} compact={compact} t={t} text={unstableSuffix} />
|
||||
<Md cols={cols} compact={compact} msgId={msgId} t={t} text={stablePrefix} />
|
||||
<Md blockIndexBase={SUFFIX_BLOCK_OFFSET} cols={cols} compact={compact} msgId={msgId} t={t} text={unstableSuffix} />
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
@@ -169,6 +176,11 @@ export const StreamingMd = memo(function StreamingMd({ cols, compact, t, text }:
|
||||
interface StreamingMdProps {
|
||||
cols?: number
|
||||
compact?: boolean
|
||||
/** Message id this stream belongs to. Threaded into both Md subtrees so
|
||||
* the prefix and suffix blocks register under the same msgId in the
|
||||
* copy-source registry. Selection that spans both halves copies the raw
|
||||
* source seamlessly across the boundary. */
|
||||
msgId?: string
|
||||
t: Theme
|
||||
text: string
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -622,6 +624,7 @@ export const Thinking = memo(function Thinking({
|
||||
active = false,
|
||||
branch = 'last',
|
||||
mode = 'truncated',
|
||||
msgId,
|
||||
rails = [],
|
||||
reasoning,
|
||||
streaming = false,
|
||||
@@ -630,6 +633,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
|
||||
@@ -647,31 +655,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>
|
||||
)
|
||||
})
|
||||
@@ -690,6 +723,7 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
busy = false,
|
||||
commandOverride = false,
|
||||
detailsMode = 'collapsed',
|
||||
msgId,
|
||||
outcome = '',
|
||||
reasoningActive = false,
|
||||
reasoning = '',
|
||||
@@ -706,6 +740,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
|
||||
@@ -1029,6 +1067,7 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
active={reasoningActive}
|
||||
branch="last"
|
||||
mode="full"
|
||||
msgId={msgId}
|
||||
rails={rails}
|
||||
reasoning={busy ? reasoning : cot}
|
||||
streaming={busy && reasoningStreaming}
|
||||
|
||||
87
ui-tui/src/lib/copySource/CopySource.tsx
Normal file
87
ui-tui/src/lib/copySource/CopySource.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* React component that wraps content with a source-range association.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* <CopySource msgId={msg.id} blockIndex={0} outerSource={msg.text}>
|
||||
* <Text>{rendered}</Text>
|
||||
* </CopySource>
|
||||
*
|
||||
* The component:
|
||||
* 1. Registers the range with the copySource registry on mount.
|
||||
* 2. Renders its children inside an <ink-box copyRangeId={id}>, which
|
||||
* causes the underlying DOMElement to carry the rangeId so the
|
||||
* hit-test pipeline can map mouse coords back to a SelectionPoint.
|
||||
* 3. Updates the registry's domNode pointer via a ref so hit-test can
|
||||
* find the DOM node from a rangeId.
|
||||
* 4. On unmount, clears the domNode pointer but DOES NOT evict the
|
||||
* range from the registry — virtual-scroll unmounts and remounts
|
||||
* should reuse the same rangeId. The host calls `evictMessage()`
|
||||
* from the history-cap path when a message is dropped entirely.
|
||||
*
|
||||
* The component re-registers whenever `outerSource` / `innerSource` /
|
||||
* `innerOffset` / `visualLineCount` / `getOffset` change so the
|
||||
* registered range always reflects the current render.
|
||||
*/
|
||||
|
||||
import { Box } from '@hermes/ink'
|
||||
import { type ReactNode, useEffect, useRef } from 'react'
|
||||
|
||||
import { registerRange, setRangeDom } from './registry.js'
|
||||
import type { RangeId, SourceRange } from './types.js'
|
||||
|
||||
export type CopySourceProps = {
|
||||
children?: ReactNode
|
||||
msgId: string
|
||||
/** 0 for whole-msg, ≥1 for per-block. */
|
||||
blockIndex: number
|
||||
/** Full source including any wrapper (e.g. fence markers). */
|
||||
outerSource: string
|
||||
/** Body without wrapper. Defaults to `outerSource`. */
|
||||
innerSource?: string
|
||||
/** Byte offset of innerSource within outerSource. Defaults to 0. */
|
||||
innerOffset?: number
|
||||
/** Total visual rows this content renders to. */
|
||||
visualLineCount: number
|
||||
/** Source-mapping function. See offsetMaps.ts for builders. */
|
||||
getOffset: SourceRange['getOffset']
|
||||
}
|
||||
|
||||
export function CopySource(props: CopySourceProps): ReactNode {
|
||||
const idRef = useRef<RangeId | null>(null)
|
||||
const boxRef = useRef<unknown>(null)
|
||||
|
||||
// Register / update the range every render. registerRange is keyed on
|
||||
// (msgId, blockIndex) so it returns the same id when those don't change.
|
||||
// This is intentionally NOT inside a useEffect: the rangeId needs to
|
||||
// exist on the FIRST render so the <Box copyRangeId={id}> below picks
|
||||
// it up; useEffect runs post-mount which is too late.
|
||||
const id = registerRange({
|
||||
msgId: props.msgId,
|
||||
blockIndex: props.blockIndex,
|
||||
outerSource: props.outerSource,
|
||||
innerSource: props.innerSource,
|
||||
innerOffset: props.innerOffset,
|
||||
visualLineCount: props.visualLineCount,
|
||||
getOffset: props.getOffset
|
||||
})
|
||||
|
||||
idRef.current = id
|
||||
|
||||
// After mount, point the registry at the live DOMElement so hit-test
|
||||
// can walk DOM → rangeId → SourceRange. Cleanup nulls it out on unmount
|
||||
// (virtual-scroll cycle) without evicting the range (still in registry).
|
||||
useEffect(() => {
|
||||
setRangeDom(id, boxRef.current)
|
||||
|
||||
return () => {
|
||||
setRangeDom(id, null)
|
||||
}
|
||||
}, [id])
|
||||
|
||||
return (
|
||||
<Box copyRangeId={id} ref={boxRef as never}>
|
||||
{props.children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
91
ui-tui/src/lib/copySource/__tests__/codeFencePadding.test.ts
Normal file
91
ui-tui/src/lib/copySource/__tests__/codeFencePadding.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Regression test for ethie's report #2:
|
||||
*
|
||||
* ```mermaid
|
||||
* graph LR
|
||||
* user[ethie] -->|asks| packet[packet >w<]
|
||||
* ```
|
||||
*
|
||||
* Double-click "ethie" → copied "hie]". Selection shifted right by 2
|
||||
* at start AND extended past `]` at end.
|
||||
*
|
||||
* Root cause: code fences render their content inside a `<Box
|
||||
* paddingLeft={2}>` nested inside the `<CopySource>` Box. The hit-test
|
||||
* reported visualLine/col relative to the OUTER (rangeId) Box, which
|
||||
* has rect.x=0 — so the visual col (which includes the +2 padding) was
|
||||
* passed through to `simpleOffsetFor`, which adds it to rowStart as if
|
||||
* it were a source col. Every char shifted +2 in source space.
|
||||
*
|
||||
* Fix: `copyPointAt` now reports visualLine/col relative to the
|
||||
* INNERMOST non-rangeId rect found during the walk-up. For inline
|
||||
* content (no padded wrapper) this equals the rangeId Box's rect, so
|
||||
* no behavior change. For code fences / tables / lists / blockquotes
|
||||
* (anything wrapped in a padded Box), the col is now relative to the
|
||||
* actual rendered content rect — matching `simpleOffsetFor`'s
|
||||
* assumption that col=0 maps to start-of-source-line.
|
||||
*
|
||||
* This test verifies the SLICING is correct end-to-end given the
|
||||
* post-fix col reporting. The hit-test-layer test for the col
|
||||
* computation lives in copyPointHitTest.test.ts.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
|
||||
import { buildLineStartsFromRows, simpleOffsetFor } from '../offsetMaps.js'
|
||||
import { registerRange, resetRegistry } from '../registry.js'
|
||||
import { toCopyText } from '../toCopyText.js'
|
||||
|
||||
describe('code fence padding off-by-N (ethie report #2)', () => {
|
||||
beforeEach(() => {
|
||||
resetRegistry()
|
||||
})
|
||||
|
||||
it('selecting "ethie" inside a fence yields exactly "ethie"', () => {
|
||||
const blockSource = [
|
||||
'```mermaid',
|
||||
'graph LR',
|
||||
' user[ethie] -->|asks| packet[packet >w<]',
|
||||
'```'
|
||||
].join('\n')
|
||||
|
||||
const lineRows = blockSource.split('\n')
|
||||
const rowStarts = buildLineStartsFromRows(lineRows)
|
||||
const row2Start = rowStarts[2]!
|
||||
// Sanity-check the source layout.
|
||||
expect(blockSource[row2Start + 9]).toBe('e')
|
||||
expect(blockSource[row2Start + 13]).toBe('e')
|
||||
expect(blockSource[row2Start + 14]).toBe(']')
|
||||
|
||||
const rangeId = registerRange({
|
||||
msgId: 'm1',
|
||||
blockIndex: 1,
|
||||
outerSource: blockSource,
|
||||
visualLineCount: lineRows.length,
|
||||
getOffset: simpleOffsetFor(blockSource, rowStarts)
|
||||
})
|
||||
|
||||
// The hit-test gives col=9 (first 'e') for anchor and col=13 (last
|
||||
// 'e' — cell-INCLUSIVE) for focus. The BRIDGE in buildCopyTextFromDom
|
||||
// does the +1 cell→byte-exclusive bump on the focus before handing
|
||||
// to toCopyText (when no sourceOffset is set, which is the case for
|
||||
// code fences with no fragments). We simulate that here by passing
|
||||
// col=14 as the focus.
|
||||
const copied = toCopyText({
|
||||
anchor: {
|
||||
kind: 'in-range',
|
||||
rangeId,
|
||||
visualLine: 2,
|
||||
col: 9 // first 'e' of ethie, source col 9
|
||||
},
|
||||
focus: {
|
||||
kind: 'in-range',
|
||||
rangeId,
|
||||
visualLine: 2,
|
||||
col: 14 // last 'e' + 1 (bridge bumped from 13 → 14 for end)
|
||||
},
|
||||
transcript: [{ id: 'm1', order: 0 }]
|
||||
})
|
||||
|
||||
expect(copied).toBe('ethie')
|
||||
})
|
||||
})
|
||||
|
||||
399
ui-tui/src/lib/copySource/__tests__/integration.test.ts
Normal file
399
ui-tui/src/lib/copySource/__tests__/integration.test.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildLineStartsFromRows, simpleOffsetFor } from '../offsetMaps.js'
|
||||
import { evictMessage, getRange, listRanges, registerRange, resetRegistry } from '../registry.js'
|
||||
import { toCopyText } from '../toCopyText.js'
|
||||
import type { MsgSnapshot, SelectionPoint } from '../types.js'
|
||||
|
||||
/**
|
||||
* Integration tests: exercise the full transcript-virtual copy-source
|
||||
* pipeline end-to-end. Each test sets up a fake transcript by registering
|
||||
* one or more ranges, builds two SelectionPoints, and asserts the
|
||||
* resulting copy text is the byte-exact slice the user expects.
|
||||
*
|
||||
* These are the "design contract" tests from the rewrite plan. The unit
|
||||
* tests in offsetMaps.test.ts / toCopyText.test.ts exercise the building
|
||||
* blocks in isolation; these tests verify they compose correctly under
|
||||
* the real wiring pattern (one CopySource per block, per-block offset
|
||||
* maps, fence inner/outer registration).
|
||||
*/
|
||||
|
||||
function registerWholeMsg(msgId: string, source: string, blockIndex = 0): number {
|
||||
const rows = source.split('\n')
|
||||
|
||||
return registerRange({
|
||||
msgId,
|
||||
blockIndex,
|
||||
outerSource: source,
|
||||
visualLineCount: Math.max(1, rows.length),
|
||||
getOffset: simpleOffsetFor(source, buildLineStartsFromRows(rows))
|
||||
})
|
||||
}
|
||||
|
||||
function registerFenceBlock(msgId: string, blockIndex: number, outerSource: string, innerSource: string): number {
|
||||
// innerOffset = position of inner content in outer (just past the opener line)
|
||||
const innerOffset = outerSource.indexOf(innerSource)
|
||||
expect(innerOffset).toBeGreaterThan(0) // sanity: inner should not start at 0
|
||||
|
||||
const rows = outerSource.split('\n')
|
||||
|
||||
return registerRange({
|
||||
msgId,
|
||||
blockIndex,
|
||||
outerSource,
|
||||
innerSource,
|
||||
innerOffset,
|
||||
visualLineCount: Math.max(1, rows.length),
|
||||
getOffset: simpleOffsetFor(outerSource, buildLineStartsFromRows(rows))
|
||||
})
|
||||
}
|
||||
|
||||
const makeTranscript = (...ids: string[]): MsgSnapshot[] =>
|
||||
ids.map((id, order) => ({ id, order }))
|
||||
|
||||
beforeEach(() => {
|
||||
resetRegistry()
|
||||
})
|
||||
|
||||
describe('integration: byte-exact copy text from selection', () => {
|
||||
it('whole-message selection emits the entire source', () => {
|
||||
const text = 'hello world\nsecond line'
|
||||
registerWholeMsg('m1', text)
|
||||
const transcript = makeTranscript('m1')
|
||||
const anchor: SelectionPoint = { kind: 'before-all' }
|
||||
const focus: SelectionPoint = { kind: 'after-all' }
|
||||
|
||||
expect(toCopyText({ anchor, focus, transcript })).toBe(text)
|
||||
})
|
||||
|
||||
it('selection spanning two messages joins their sources with a newline', () => {
|
||||
const m1Text = 'msg one line a\nmsg one line b'
|
||||
const m2Text = 'msg two line a'
|
||||
registerWholeMsg('m1', m1Text)
|
||||
registerWholeMsg('m2', m2Text)
|
||||
const transcript = makeTranscript('m1', 'm2')
|
||||
|
||||
expect(
|
||||
toCopyText({
|
||||
anchor: { kind: 'before-all' },
|
||||
focus: { kind: 'after-all' },
|
||||
transcript
|
||||
})
|
||||
).toBe(`${m1Text}\n${m2Text}`)
|
||||
})
|
||||
|
||||
it('partial selection within a single range emits the inner slice', () => {
|
||||
const text = 'abcdefghij'
|
||||
const id = registerWholeMsg('m1', text)
|
||||
const transcript = makeTranscript('m1')
|
||||
|
||||
// 'cdefg' spans cols [2..7) of the single visual line.
|
||||
const anchor: SelectionPoint = { kind: 'in-range', rangeId: id, visualLine: 0, col: 2 }
|
||||
const focus: SelectionPoint = { kind: 'in-range', rangeId: id, visualLine: 0, col: 7 }
|
||||
|
||||
expect(toCopyText({ anchor, focus, transcript })).toBe('cdefg')
|
||||
})
|
||||
|
||||
it('fence-strip: both endpoints inside fence body yield bare code', () => {
|
||||
const outer = '```py\nprint("hello")\nprint("world")\n```'
|
||||
const inner = 'print("hello")\nprint("world")'
|
||||
const id = registerFenceBlock('m1', 1, outer, inner)
|
||||
const transcript = makeTranscript('m1')
|
||||
const range = getRange(id)!
|
||||
|
||||
// Selection: from start of first inner line to end of second inner line.
|
||||
// visualLine 1 = first inner content row in the rendered fence (row 0
|
||||
// is the ```py opener), col 0 = first byte.
|
||||
const innerLine1Start = range.innerOffset
|
||||
const innerLine2End = range.innerOffset + inner.length
|
||||
|
||||
// Build points that resolve to those exact source offsets:
|
||||
// visualLine 1 col 0 → offset = rowStart(1) = innerLine1Start (because
|
||||
// simpleOffsetFor with one row per source line gives rowStarts[1] =
|
||||
// length of row 0 + 1 = "```py".length + 1 = 6 = innerOffset).
|
||||
expect(innerLine1Start).toBe(6)
|
||||
|
||||
const anchor: SelectionPoint = { kind: 'in-range', rangeId: id, visualLine: 1, col: 0 }
|
||||
// visualLine 2 col 14 → row 2 starts at offset 21, col 14 → 35 = innerLine2End.
|
||||
const focus: SelectionPoint = { kind: 'in-range', rangeId: id, visualLine: 2, col: 14 }
|
||||
|
||||
expect(toCopyText({ anchor, focus, transcript })).toBe(inner)
|
||||
expect(toCopyText({ anchor, focus, transcript })).not.toContain('```')
|
||||
})
|
||||
|
||||
it('fence: selection extending past the closer keeps the fence markers', () => {
|
||||
const outer = '```py\nprint("hello")\n```'
|
||||
const inner = 'print("hello")'
|
||||
const id = registerFenceBlock('m1', 1, outer, inner)
|
||||
const transcript = makeTranscript('m1')
|
||||
|
||||
// Anchor at start of OPENER line (visualLine 0 col 0), focus past end.
|
||||
const anchor: SelectionPoint = { kind: 'in-range', rangeId: id, visualLine: 0, col: 0 }
|
||||
const focus: SelectionPoint = { kind: 'after-all' }
|
||||
|
||||
expect(toCopyText({ anchor, focus, transcript })).toBe(outer)
|
||||
})
|
||||
|
||||
it('two messages, partial selection: anchor mid-msg1, focus mid-msg2', () => {
|
||||
const m1 = 'hello world'
|
||||
const m2 = 'second message'
|
||||
const id1 = registerWholeMsg('m1', m1)
|
||||
const id2 = registerWholeMsg('m2', m2)
|
||||
const transcript = makeTranscript('m1', 'm2')
|
||||
|
||||
// Anchor: col 6 of m1 (start of "world").
|
||||
// Focus: col 6 of m2 (after "second").
|
||||
const anchor: SelectionPoint = { kind: 'in-range', rangeId: id1, visualLine: 0, col: 6 }
|
||||
const focus: SelectionPoint = { kind: 'in-range', rangeId: id2, visualLine: 0, col: 6 }
|
||||
|
||||
expect(toCopyText({ anchor, focus, transcript })).toBe('world\nsecond')
|
||||
})
|
||||
|
||||
it('eviction: msg dropped from history → range gone → stale point gives empty', () => {
|
||||
const m1 = 'doomed msg'
|
||||
const id1 = registerWholeMsg('m1', m1)
|
||||
// Even with the transcript still listing m1, eviction wipes the range
|
||||
// from the registry. The selection point's rangeId no longer resolves.
|
||||
evictMessage('m1')
|
||||
const transcript = makeTranscript('m1')
|
||||
|
||||
const anchor: SelectionPoint = { kind: 'in-range', rangeId: id1, visualLine: 0, col: 0 }
|
||||
const focus: SelectionPoint = { kind: 'in-range', rangeId: id1, visualLine: 0, col: 10 }
|
||||
|
||||
expect(toCopyText({ anchor, focus, transcript })).toBe('')
|
||||
expect(listRanges()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('re-registration preserves the rangeId (virtual-scroll unmount/remount)', () => {
|
||||
const text = 'abc'
|
||||
const id1 = registerWholeMsg('m1', text)
|
||||
const id2 = registerWholeMsg('m1', text)
|
||||
|
||||
expect(id2).toBe(id1)
|
||||
expect(listRanges()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('gap point between msgs slots correctly in document order', () => {
|
||||
const m1 = 'first'
|
||||
const m2 = 'second'
|
||||
const id1 = registerWholeMsg('m1', m1)
|
||||
const id2 = registerWholeMsg('m2', m2)
|
||||
const transcript = makeTranscript('m1', 'm2')
|
||||
|
||||
// Gap between m1 (end) and m2 (start) — like clicking on a blank
|
||||
// spacer row. afterRangeId=id1 means the gap is AFTER range id1.
|
||||
// beforeRangeId=id2 means the gap is BEFORE range id2.
|
||||
const anchor: SelectionPoint = { kind: 'in-range', rangeId: id1, visualLine: 0, col: 0 }
|
||||
const focus: SelectionPoint = { kind: 'gap', afterRangeId: id1, beforeRangeId: id2 }
|
||||
|
||||
// Should slice from col 0 of m1 to end of m1; m2 is past the gap.
|
||||
expect(toCopyText({ anchor, focus, transcript })).toBe('first')
|
||||
})
|
||||
|
||||
it('gap → in-range covers everything from gap-before-range through the focus', () => {
|
||||
const m1 = 'first'
|
||||
const m2 = 'second'
|
||||
const id1 = registerWholeMsg('m1', m1)
|
||||
const id2 = registerWholeMsg('m2', m2)
|
||||
const transcript = makeTranscript('m1', 'm2')
|
||||
|
||||
// Gap BEFORE m2 (so positioned right after m1's end). Focus mid-m2.
|
||||
const anchor: SelectionPoint = { kind: 'gap', afterRangeId: id1, beforeRangeId: id2 }
|
||||
const focus: SelectionPoint = { kind: 'in-range', rangeId: id2, visualLine: 0, col: 3 }
|
||||
|
||||
// Gap-after-m1 == position past m1's last visual line, so m1 isn't
|
||||
// included. Output is just the prefix of m2.
|
||||
expect(toCopyText({ anchor, focus, transcript })).toBe('sec')
|
||||
})
|
||||
|
||||
it('reversed selection (focus before anchor) produces the same text', () => {
|
||||
const text = 'abcdefgh'
|
||||
const id = registerWholeMsg('m1', text)
|
||||
const transcript = makeTranscript('m1')
|
||||
|
||||
const forward = toCopyText({
|
||||
anchor: { kind: 'in-range', rangeId: id, visualLine: 0, col: 1 },
|
||||
focus: { kind: 'in-range', rangeId: id, visualLine: 0, col: 5 },
|
||||
transcript
|
||||
})
|
||||
|
||||
const reversed = toCopyText({
|
||||
anchor: { kind: 'in-range', rangeId: id, visualLine: 0, col: 5 },
|
||||
focus: { kind: 'in-range', rangeId: id, visualLine: 0, col: 1 },
|
||||
transcript
|
||||
})
|
||||
|
||||
expect(forward).toBe('bcde')
|
||||
expect(reversed).toBe('bcde')
|
||||
})
|
||||
|
||||
it('multi-block msg: per-block ranges concat correctly on full-msg selection', () => {
|
||||
// Simulate a markdown msg with three blocks: heading, paragraph, fence.
|
||||
const headingSrc = '# Title'
|
||||
const paraSrc = 'Some text with `inline` code.'
|
||||
const fenceOuter = '```js\nconst x = 1;\n```'
|
||||
const fenceInner = 'const x = 1;'
|
||||
|
||||
registerRange({
|
||||
msgId: 'm1',
|
||||
blockIndex: 1,
|
||||
outerSource: headingSrc,
|
||||
visualLineCount: 1,
|
||||
getOffset: simpleOffsetFor(headingSrc, buildLineStartsFromRows([headingSrc]))
|
||||
})
|
||||
registerRange({
|
||||
msgId: 'm1',
|
||||
blockIndex: 2,
|
||||
outerSource: paraSrc,
|
||||
visualLineCount: 1,
|
||||
getOffset: simpleOffsetFor(paraSrc, buildLineStartsFromRows([paraSrc]))
|
||||
})
|
||||
const innerOffset = fenceOuter.indexOf(fenceInner)
|
||||
const fenceRows = fenceOuter.split('\n')
|
||||
registerRange({
|
||||
msgId: 'm1',
|
||||
blockIndex: 3,
|
||||
outerSource: fenceOuter,
|
||||
innerSource: fenceInner,
|
||||
innerOffset,
|
||||
visualLineCount: fenceRows.length,
|
||||
getOffset: simpleOffsetFor(fenceOuter, buildLineStartsFromRows(fenceRows))
|
||||
})
|
||||
const transcript = makeTranscript('m1')
|
||||
|
||||
expect(
|
||||
toCopyText({
|
||||
anchor: { kind: 'before-all' },
|
||||
focus: { kind: 'after-all' },
|
||||
transcript
|
||||
})
|
||||
).toBe(`${headingSrc}\n${paraSrc}\n${fenceOuter}`)
|
||||
})
|
||||
|
||||
it('selection mid-paragraph through mid-next-paragraph: byte-exact across blocks', () => {
|
||||
const para1 = 'first paragraph'
|
||||
const para2 = 'second paragraph'
|
||||
|
||||
const id1 = registerRange({
|
||||
msgId: 'm1',
|
||||
blockIndex: 1,
|
||||
outerSource: para1,
|
||||
visualLineCount: 1,
|
||||
getOffset: simpleOffsetFor(para1, buildLineStartsFromRows([para1]))
|
||||
})
|
||||
|
||||
const id2 = registerRange({
|
||||
msgId: 'm1',
|
||||
blockIndex: 2,
|
||||
outerSource: para2,
|
||||
visualLineCount: 1,
|
||||
getOffset: simpleOffsetFor(para2, buildLineStartsFromRows([para2]))
|
||||
})
|
||||
|
||||
const transcript = makeTranscript('m1')
|
||||
|
||||
// Anchor: 6 chars into para1 (start of "paragraph").
|
||||
// Focus: 7 chars into para2 (after "second ").
|
||||
const anchor: SelectionPoint = { kind: 'in-range', rangeId: id1, visualLine: 0, col: 6 }
|
||||
const focus: SelectionPoint = { kind: 'in-range', rangeId: id2, visualLine: 0, col: 7 }
|
||||
|
||||
expect(toCopyText({ anchor, focus, transcript })).toBe('paragraph\nsecond ')
|
||||
})
|
||||
|
||||
it('python fence: selecting a single code line via in-range points returns just that line', () => {
|
||||
// Regression: selecting the docstring line ` """packet says hi !!"""`
|
||||
// inside a python fence should copy exactly that line, not the
|
||||
// surrounding code or the whole fence.
|
||||
const fenceOuter = [
|
||||
'```python',
|
||||
'def greet(name: str) -> str:',
|
||||
' """packet says hi !!"""',
|
||||
' return f"awaaaaa hi {name} >w<"',
|
||||
'',
|
||||
'print(greet("ethie"))',
|
||||
'```'
|
||||
].join('\n')
|
||||
|
||||
const fenceLines = fenceOuter.split('\n')
|
||||
const innerSource = fenceLines.slice(1, -1).join('\n')
|
||||
const innerOffset = fenceLines[0]!.length + 1
|
||||
|
||||
const id = registerRange({
|
||||
msgId: 'm1',
|
||||
blockIndex: 1,
|
||||
outerSource: fenceOuter,
|
||||
innerSource,
|
||||
innerOffset,
|
||||
visualLineCount: fenceLines.length,
|
||||
getOffset: simpleOffsetFor(fenceOuter, buildLineStartsFromRows(fenceLines))
|
||||
})
|
||||
|
||||
const transcript = [{ id: 'm1', order: 0 }]
|
||||
|
||||
// Visual row 2 = docstring line (row 0 = opener / chrome label,
|
||||
// row 1 = def line, row 2 = docstring). col 0 = line start, col
|
||||
// 27 = end of ` """packet says hi !!"""`.
|
||||
const anchor: SelectionPoint = { kind: 'in-range', rangeId: id, visualLine: 2, col: 0 }
|
||||
const focus: SelectionPoint = { kind: 'in-range', rangeId: id, visualLine: 2, col: 27 }
|
||||
|
||||
// Fence-stripping rule applies: both endpoints land in innerSource
|
||||
// bounds → output is the innerSource slice, not the outerSource
|
||||
// slice (which would include the line with no opener/closer
|
||||
// adjustment).
|
||||
expect(toCopyText({ anchor, focus, transcript })).toBe(' """packet says hi !!"""')
|
||||
})
|
||||
|
||||
it('python fence: selecting one wrapped code line past visualLineCount clamps to last source row', () => {
|
||||
// What happens if the docstring is the LAST tracked source row and
|
||||
// the hit-test reports visualLine past visualLineCount (e.g.
|
||||
// because the renderer wrapped the line to multiple visual rows
|
||||
// but the block was registered with source-line-count only).
|
||||
//
|
||||
// Defensive fallback in pointToOffset clamps to last-row getOffset,
|
||||
// bounded by the line's source-end. So the slice is at MOST the
|
||||
// docstring line itself, never spilling into post-fence content.
|
||||
const fenceOuter = [
|
||||
'```python',
|
||||
' """packet says hi !!"""',
|
||||
'```'
|
||||
].join('\n')
|
||||
|
||||
const fenceLines = fenceOuter.split('\n')
|
||||
const innerSource = fenceLines.slice(1, -1).join('\n')
|
||||
const innerOffset = fenceLines[0]!.length + 1
|
||||
|
||||
const id = registerRange({
|
||||
msgId: 'm1',
|
||||
blockIndex: 1,
|
||||
outerSource: fenceOuter,
|
||||
innerSource,
|
||||
innerOffset,
|
||||
visualLineCount: fenceLines.length,
|
||||
getOffset: simpleOffsetFor(fenceOuter, buildLineStartsFromRows(fenceLines))
|
||||
})
|
||||
|
||||
const transcript = [{ id: 'm1', order: 0 }]
|
||||
|
||||
// visualLine=99 simulates a click past the tracked visual rows
|
||||
// (e.g. wrap-continuation row beyond visualLineCount). The
|
||||
// defensive clamp in pointToOffset defers to the last-row offset,
|
||||
// which gets clamped to the row's source-end by simpleOffsetFor.
|
||||
const anchor: SelectionPoint = { kind: 'in-range', rangeId: id, visualLine: 1, col: 0 }
|
||||
const focus: SelectionPoint = { kind: 'in-range', rangeId: id, visualLine: 99, col: 0 }
|
||||
|
||||
// Last tracked row is the closer line `\`\`\`` at visualLine=2,
|
||||
// not the docstring at visualLine=1. visualLine=99 clamps to
|
||||
// start of closer row. Slice covers docstring + trailing \n.
|
||||
// (Pre-fix this would have clamped to outerSource.length,
|
||||
// returning everything from the docstring to end of fence.)
|
||||
const result = toCopyText({ anchor, focus, transcript })
|
||||
// The fence-stripping rule requires BOTH points inside [innerOffset, innerEnd];
|
||||
// visualLine=99 → clamp to byte 38 (start of closer). innerEnd = 38
|
||||
// (innerOffset 10 + innerSource.length 28). So 38 <= 38 is at the
|
||||
// boundary — fence-stripping kicks in if `<= innerEnd`. Either way,
|
||||
// the result must NOT include the closer ``` line.
|
||||
expect(result).not.toContain('```')
|
||||
// And it must contain the docstring content.
|
||||
expect(result).toContain('packet says hi !!')
|
||||
})
|
||||
})
|
||||
163
ui-tui/src/lib/copySource/__tests__/offsetMaps.test.ts
Normal file
163
ui-tui/src/lib/copySource/__tests__/offsetMaps.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
buildLineStartsFromRows,
|
||||
inlineOffsetFor,
|
||||
type InlineSpanTable,
|
||||
simpleOffsetFor
|
||||
} from '../offsetMaps.js'
|
||||
|
||||
describe('buildLineStartsFromRows', () => {
|
||||
test('three single-line rows', () => {
|
||||
const out = buildLineStartsFromRows(['abc', 'def', 'ghi'])
|
||||
expect(Array.from(out)).toEqual([0, 4, 8])
|
||||
})
|
||||
|
||||
test('empty array', () => {
|
||||
expect(buildLineStartsFromRows([]).length).toBe(0)
|
||||
})
|
||||
|
||||
test('rows with varying length', () => {
|
||||
const out = buildLineStartsFromRows(['', 'longer', 'x'])
|
||||
expect(Array.from(out)).toEqual([0, 1, 8])
|
||||
})
|
||||
})
|
||||
|
||||
describe('simpleOffsetFor', () => {
|
||||
test('col within first row returns rowStart + col', () => {
|
||||
const get = simpleOffsetFor('abc\ndef\nghi', new Uint32Array([0, 4, 8]))
|
||||
expect(get(0, 0)).toBe(0)
|
||||
expect(get(0, 2)).toBe(2)
|
||||
expect(get(1, 1)).toBe(5)
|
||||
expect(get(2, 2)).toBe(10)
|
||||
})
|
||||
|
||||
test('col past end of row clamps at row end (excludes newline)', () => {
|
||||
const get = simpleOffsetFor('abc\ndef', new Uint32Array([0, 4]))
|
||||
expect(get(0, 99)).toBe(3) // end of "abc", before the \n
|
||||
expect(get(1, 99)).toBe(7) // end of "def" (end of source)
|
||||
})
|
||||
|
||||
test('visualRow beyond end clamps to outerSource.length', () => {
|
||||
const get = simpleOffsetFor('abc\ndef', new Uint32Array([0, 4]))
|
||||
expect(get(5, 0)).toBe(7)
|
||||
})
|
||||
|
||||
test('soft-wrap: two rows pointing into same source line', () => {
|
||||
// "abcdefghij" wrapped at col 5: row 0 → "abcde", row 1 → "fghij"
|
||||
const get = simpleOffsetFor('abcdefghij', new Uint32Array([0, 5]))
|
||||
expect(get(0, 0)).toBe(0)
|
||||
expect(get(0, 5)).toBe(5) // past row 0, clamped to row 1 start
|
||||
expect(get(1, 0)).toBe(5)
|
||||
expect(get(1, 4)).toBe(9)
|
||||
// col past end of row 1: clamp to source end
|
||||
expect(get(1, 99)).toBe(10)
|
||||
})
|
||||
|
||||
test('negative visualRow returns 0', () => {
|
||||
const get = simpleOffsetFor('abc', new Uint32Array([0]))
|
||||
expect(get(-1, 0)).toBe(0)
|
||||
})
|
||||
|
||||
test('negative col treated as 0', () => {
|
||||
const get = simpleOffsetFor('abc', new Uint32Array([0]))
|
||||
expect(get(0, -5)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('inlineOffsetFor — verbatim spans (visual == source length)', () => {
|
||||
test('single span: col offsets map 1:1 to source bytes', () => {
|
||||
const get = inlineOffsetFor('hello world', [
|
||||
[{ visualStart: 0, visualEnd: 11, sourceStart: 0, sourceEnd: 11 }]
|
||||
])
|
||||
|
||||
expect(get(0, 0)).toBe(0)
|
||||
expect(get(0, 5)).toBe(5)
|
||||
expect(get(0, 11)).toBe(11)
|
||||
})
|
||||
|
||||
test('col before first span snaps to sourceStart of that span', () => {
|
||||
// Row starts with 2 columns of non-source-mapped prefix (e.g. gutter).
|
||||
const get = inlineOffsetFor('text', [
|
||||
[{ visualStart: 2, visualEnd: 6, sourceStart: 0, sourceEnd: 4 }]
|
||||
])
|
||||
|
||||
expect(get(0, 0)).toBe(0)
|
||||
expect(get(0, 1)).toBe(0)
|
||||
expect(get(0, 2)).toBe(0)
|
||||
expect(get(0, 4)).toBe(2)
|
||||
expect(get(0, 6)).toBe(4) // past end → sourceEnd of last span
|
||||
})
|
||||
})
|
||||
|
||||
describe('inlineOffsetFor — rendered spans (visual != source length)', () => {
|
||||
test('bold span: 4 visual cells for 8 source chars (**bold**)', () => {
|
||||
// outerSource is "**bold**" (8 bytes), rendered as "bold" (4 cells).
|
||||
const get = inlineOffsetFor('**bold**', [
|
||||
[{ visualStart: 0, visualEnd: 4, sourceStart: 0, sourceEnd: 8 }]
|
||||
])
|
||||
|
||||
// col 0 → sourceStart (0)
|
||||
expect(get(0, 0)).toBe(0)
|
||||
// col 4 (past end) → sourceEnd (8)
|
||||
expect(get(0, 4)).toBe(8)
|
||||
// mid: col 2 → proportional (2/4) * 8 = 4
|
||||
expect(get(0, 2)).toBe(4)
|
||||
})
|
||||
|
||||
test('link span: rendered "text" for source "[text](url)" — source byte length differs', () => {
|
||||
const outerSource = '[text](url)'
|
||||
|
||||
const get = inlineOffsetFor(outerSource, [
|
||||
[{ visualStart: 0, visualEnd: 4, sourceStart: 0, sourceEnd: outerSource.length }]
|
||||
])
|
||||
|
||||
expect(get(0, 0)).toBe(0)
|
||||
expect(get(0, 4)).toBe(outerSource.length)
|
||||
})
|
||||
|
||||
test('mixed row: plain text + rendered span + plain text', () => {
|
||||
// Source: "pre **bold** post" (17 bytes)
|
||||
// Rendered: "pre bold post" (13 cells)
|
||||
// Visual: 0-4 "pre " (verbatim, 4 chars), 4-8 "bold" (rendered for **bold**),
|
||||
// 8-13 " post" (verbatim, 5 chars).
|
||||
const outerSource = 'pre **bold** post'
|
||||
|
||||
const spans: InlineSpanTable = [
|
||||
[
|
||||
{ visualStart: 0, visualEnd: 4, sourceStart: 0, sourceEnd: 4 }, // "pre "
|
||||
{ visualStart: 4, visualEnd: 8, sourceStart: 4, sourceEnd: 12 }, // "**bold**"
|
||||
{ visualStart: 8, visualEnd: 13, sourceStart: 12, sourceEnd: 17 } // " post"
|
||||
]
|
||||
]
|
||||
|
||||
const get = inlineOffsetFor(outerSource, spans)
|
||||
expect(get(0, 0)).toBe(0) // start of "pre "
|
||||
expect(get(0, 3)).toBe(3) // last char of "pre"
|
||||
expect(get(0, 4)).toBe(4) // start of bold span source
|
||||
expect(get(0, 8)).toBe(12) // end of bold span source
|
||||
expect(get(0, 13)).toBe(17) // end of post span source
|
||||
})
|
||||
|
||||
test('past last span snaps to its sourceEnd', () => {
|
||||
const get = inlineOffsetFor('hello', [
|
||||
[{ visualStart: 0, visualEnd: 5, sourceStart: 0, sourceEnd: 5 }]
|
||||
])
|
||||
|
||||
expect(get(0, 99)).toBe(5)
|
||||
})
|
||||
|
||||
test('empty row finds the next non-empty row sourceStart', () => {
|
||||
const get = inlineOffsetFor('first\nsecond', [
|
||||
[],
|
||||
[{ visualStart: 0, visualEnd: 6, sourceStart: 6, sourceEnd: 12 }]
|
||||
])
|
||||
|
||||
expect(get(0, 0)).toBe(6)
|
||||
})
|
||||
|
||||
test('empty row at end with no further content returns outerSource.length', () => {
|
||||
const get = inlineOffsetFor('hello', [[], []])
|
||||
expect(get(0, 0)).toBe(5)
|
||||
})
|
||||
})
|
||||
441
ui-tui/src/lib/copySource/__tests__/toCopyText.test.ts
Normal file
441
ui-tui/src/lib/copySource/__tests__/toCopyText.test.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
import { afterEach, describe, expect, test } from 'vitest'
|
||||
|
||||
import { buildLineStartsFromRows, simpleOffsetFor } from '../offsetMaps.js'
|
||||
import { registerRange, resetRegistry } from '../registry.js'
|
||||
import { toCopyText } from '../toCopyText.js'
|
||||
import type { MsgSnapshot, RangeId, SelectionPoint } from '../types.js'
|
||||
|
||||
/**
|
||||
* Helper: register a one-line-per-source-line range (no soft-wrap).
|
||||
* Returns its RangeId.
|
||||
*/
|
||||
function registerSimple(
|
||||
msgId: string,
|
||||
blockIndex: number,
|
||||
outerSource: string,
|
||||
innerSource?: string,
|
||||
innerOffset?: number
|
||||
): RangeId {
|
||||
const lines = outerSource.split('\n')
|
||||
const rowStarts = buildLineStartsFromRows(lines)
|
||||
|
||||
return registerRange({
|
||||
msgId,
|
||||
blockIndex,
|
||||
outerSource,
|
||||
innerSource,
|
||||
innerOffset,
|
||||
visualLineCount: rowStarts.length,
|
||||
getOffset: simpleOffsetFor(outerSource, rowStarts)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: register a range with explicit visual→source mapping (for
|
||||
* soft-wrap or out-of-order rendering tests).
|
||||
*/
|
||||
function registerCustom(
|
||||
msgId: string,
|
||||
blockIndex: number,
|
||||
outerSource: string,
|
||||
rowStartsArr: number[],
|
||||
innerSource?: string,
|
||||
innerOffset?: number
|
||||
): RangeId {
|
||||
const rowStarts = new Uint32Array(rowStartsArr)
|
||||
|
||||
return registerRange({
|
||||
msgId,
|
||||
blockIndex,
|
||||
outerSource,
|
||||
innerSource,
|
||||
innerOffset,
|
||||
visualLineCount: rowStarts.length,
|
||||
getOffset: simpleOffsetFor(outerSource, rowStarts)
|
||||
})
|
||||
}
|
||||
|
||||
function msgs(...ids: string[]): readonly MsgSnapshot[] {
|
||||
return ids.map((id, order) => ({ id, order }))
|
||||
}
|
||||
|
||||
function ptInRange(rangeId: RangeId, visualLine: number, col: number): SelectionPoint {
|
||||
return { kind: 'in-range', rangeId, visualLine, col }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetRegistry()
|
||||
})
|
||||
|
||||
describe('toCopyText — single range', () => {
|
||||
test('empty selection returns empty string', () => {
|
||||
const r = registerSimple('m1', 0, 'hello world')
|
||||
const p = ptInRange(r, 0, 5)
|
||||
expect(toCopyText({ anchor: p, focus: p, transcript: msgs('m1') })).toBe('')
|
||||
})
|
||||
|
||||
test('within one line, returns the exact source slice', () => {
|
||||
const r = registerSimple('m1', 0, 'hello world')
|
||||
expect(
|
||||
toCopyText({
|
||||
anchor: ptInRange(r, 0, 0),
|
||||
focus: ptInRange(r, 0, 5),
|
||||
transcript: msgs('m1')
|
||||
})
|
||||
).toBe('hello')
|
||||
})
|
||||
|
||||
test('across two source lines, includes the newline', () => {
|
||||
const r = registerSimple('m1', 0, 'hello\nworld')
|
||||
expect(
|
||||
toCopyText({
|
||||
anchor: ptInRange(r, 0, 0),
|
||||
focus: ptInRange(r, 1, 5),
|
||||
transcript: msgs('m1')
|
||||
})
|
||||
).toBe('hello\nworld')
|
||||
})
|
||||
|
||||
test('reversed anchor/focus produces same result (auto-order)', () => {
|
||||
const r = registerSimple('m1', 0, 'hello world')
|
||||
const a = ptInRange(r, 0, 0)
|
||||
const b = ptInRange(r, 0, 5)
|
||||
expect(toCopyText({ anchor: b, focus: a, transcript: msgs('m1') })).toBe('hello')
|
||||
expect(toCopyText({ anchor: a, focus: b, transcript: msgs('m1') })).toBe('hello')
|
||||
})
|
||||
|
||||
test('col past end of line clamps to end of that line, not next', () => {
|
||||
const r = registerSimple('m1', 0, 'abc\ndef')
|
||||
expect(
|
||||
toCopyText({
|
||||
anchor: ptInRange(r, 0, 0),
|
||||
focus: ptInRange(r, 0, 99),
|
||||
transcript: msgs('m1')
|
||||
})
|
||||
).toBe('abc')
|
||||
})
|
||||
|
||||
test('select whole single-line range', () => {
|
||||
const r = registerSimple('m1', 0, 'foo bar baz')
|
||||
expect(
|
||||
toCopyText({
|
||||
anchor: ptInRange(r, 0, 0),
|
||||
focus: ptInRange(r, 0, 11),
|
||||
transcript: msgs('m1')
|
||||
})
|
||||
).toBe('foo bar baz')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toCopyText — multiple ranges in one message', () => {
|
||||
test('select across two blocks in one msg includes both source bodies', () => {
|
||||
const r1 = registerSimple('m1', 1, '# heading')
|
||||
const r2 = registerSimple('m1', 2, 'paragraph text')
|
||||
expect(
|
||||
toCopyText({
|
||||
anchor: ptInRange(r1, 0, 0),
|
||||
focus: ptInRange(r2, 0, 14),
|
||||
transcript: msgs('m1')
|
||||
})
|
||||
).toBe('# heading\nparagraph text')
|
||||
})
|
||||
|
||||
test('select partial of first block + all of second', () => {
|
||||
const r1 = registerSimple('m1', 1, '# heading')
|
||||
const r2 = registerSimple('m1', 2, 'para')
|
||||
expect(
|
||||
toCopyText({
|
||||
anchor: ptInRange(r1, 0, 2),
|
||||
focus: ptInRange(r2, 0, 4),
|
||||
transcript: msgs('m1')
|
||||
})
|
||||
).toBe('heading\npara')
|
||||
})
|
||||
|
||||
test('three blocks: middle block included whole', () => {
|
||||
const r1 = registerSimple('m1', 1, 'aaa')
|
||||
const r2 = registerSimple('m1', 2, 'bbb')
|
||||
const r3 = registerSimple('m1', 3, 'ccc')
|
||||
expect(
|
||||
toCopyText({
|
||||
anchor: ptInRange(r1, 0, 1),
|
||||
focus: ptInRange(r3, 0, 2),
|
||||
transcript: msgs('m1')
|
||||
})
|
||||
).toBe('aa\nbbb\ncc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toCopyText — across messages', () => {
|
||||
test('select spans m1 → m2 → m3, middle msgs included whole', () => {
|
||||
const r1 = registerSimple('m1', 0, 'first')
|
||||
const r2 = registerSimple('m2', 0, 'middle')
|
||||
const r3 = registerSimple('m3', 0, 'last')
|
||||
expect(
|
||||
toCopyText({
|
||||
anchor: ptInRange(r1, 0, 2),
|
||||
focus: ptInRange(r3, 0, 3),
|
||||
transcript: msgs('m1', 'm2', 'm3')
|
||||
})
|
||||
).toBe('rst\nmiddle\nlas')
|
||||
})
|
||||
|
||||
test('order independence — selecting bottom-to-top yields same text', () => {
|
||||
const r1 = registerSimple('m1', 0, 'first')
|
||||
const r2 = registerSimple('m2', 0, 'second')
|
||||
|
||||
const forward = toCopyText({
|
||||
anchor: ptInRange(r1, 0, 0),
|
||||
focus: ptInRange(r2, 0, 6),
|
||||
transcript: msgs('m1', 'm2')
|
||||
})
|
||||
|
||||
const reverse = toCopyText({
|
||||
anchor: ptInRange(r2, 0, 6),
|
||||
focus: ptInRange(r1, 0, 0),
|
||||
transcript: msgs('m1', 'm2')
|
||||
})
|
||||
|
||||
expect(forward).toBe('first\nsecond')
|
||||
expect(reverse).toBe(forward)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toCopyText — fence-stripping rule', () => {
|
||||
test('both endpoints inside inner body of same range emits inner', () => {
|
||||
const outer = '```py\ncode\nlines\n```'
|
||||
const inner = 'code\nlines'
|
||||
const innerOffset = outer.indexOf(inner)
|
||||
const r = registerCustom('m1', 1, outer, [0, 6, 11, 17], inner, innerOffset)
|
||||
// Endpoints land on the inner visual rows (visual rows 1 and 2 — the
|
||||
// body lines). Sel from "code" start to "lines" end.
|
||||
expect(
|
||||
toCopyText({
|
||||
anchor: ptInRange(r, 1, 0),
|
||||
focus: ptInRange(r, 2, 5),
|
||||
transcript: msgs('m1')
|
||||
})
|
||||
).toBe('code\nlines')
|
||||
})
|
||||
|
||||
test('selection extending past fence emits outer with fence markers', () => {
|
||||
const outer = '```py\ncode\n```'
|
||||
const inner = 'code'
|
||||
const innerOffset = outer.indexOf(inner)
|
||||
const r = registerCustom('m1', 1, outer, [0, 6, 11], inner, innerOffset)
|
||||
|
||||
// Anchor on fence opener (visualLine 0), focus on inner — fence
|
||||
// markers must survive.
|
||||
const out = toCopyText({
|
||||
anchor: ptInRange(r, 0, 0),
|
||||
focus: ptInRange(r, 1, 4),
|
||||
transcript: msgs('m1')
|
||||
})
|
||||
|
||||
expect(out).toBe('```py\ncode')
|
||||
})
|
||||
|
||||
test('endpoints land exactly on inner boundary still emits inner', () => {
|
||||
const outer = '```\nx\n```'
|
||||
const inner = 'x'
|
||||
const innerOffset = outer.indexOf(inner)
|
||||
const r = registerCustom('m1', 1, outer, [0, 4, 6], inner, innerOffset)
|
||||
expect(
|
||||
toCopyText({
|
||||
anchor: ptInRange(r, 1, 0),
|
||||
focus: ptInRange(r, 1, 1),
|
||||
transcript: msgs('m1')
|
||||
})
|
||||
).toBe('x')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toCopyText — soft-wrap', () => {
|
||||
test('one source line wrapped to two visual rows', () => {
|
||||
// "abcdefghij" wrapped at col 5 → visual rows "abcde" and "fghij"
|
||||
// mapVisualToSource = [0, 5] (both rows point into the same source line)
|
||||
const r = registerCustom('m1', 0, 'abcdefghij', [0, 5])
|
||||
// Select from visual (0,2) to visual (1,3) — should give "cdefgh"
|
||||
expect(
|
||||
toCopyText({
|
||||
anchor: ptInRange(r, 0, 2),
|
||||
focus: ptInRange(r, 1, 3),
|
||||
transcript: msgs('m1')
|
||||
})
|
||||
).toBe('cdefgh')
|
||||
})
|
||||
|
||||
test('soft-wrap does not insert a newline that isn\'t in source', () => {
|
||||
const r = registerCustom('m1', 0, 'abcdefghij', [0, 5])
|
||||
expect(
|
||||
toCopyText({
|
||||
anchor: ptInRange(r, 0, 0),
|
||||
focus: ptInRange(r, 1, 5),
|
||||
transcript: msgs('m1')
|
||||
})
|
||||
).toBe('abcdefghij')
|
||||
})
|
||||
|
||||
test('visualLine past visualLineCount defers to last-row offset (no whole-doc clamp)', () => {
|
||||
// Regression: a paragraph that's a single source line gets
|
||||
// registered with visualLineCount=1, but when rendered the
|
||||
// terminal wraps it to multiple visual rows. A click on a
|
||||
// wrap-continuation row (e.g. row 1) would arrive at toCopyText
|
||||
// with visualLine=1. The OLD pointToOffset clamped to
|
||||
// outerSource.length on this — copying the WHOLE source line
|
||||
// instead of just the prefix the user dragged across. The fix
|
||||
// is to defer to the last tracked row's getOffset(col), which
|
||||
// is bounded by the row's source-end.
|
||||
const source = 'the quick brown fox jumps over'
|
||||
const r = registerSimple('m1', 0, source)
|
||||
|
||||
// Anchor at col 5 on the (sole) tracked row 0, focus on the
|
||||
// hypothetical wrap-continuation row 1 col 0. The old behavior
|
||||
// gave the whole 30-char line; the new behavior gives the row 0
|
||||
// portion up to its source-end (the line's whole content since
|
||||
// there's only one source line — but key thing: it's bounded by
|
||||
// line content not by `outerSource.length`, which matters when
|
||||
// the range has further content past this line).
|
||||
const result = toCopyText({
|
||||
anchor: ptInRange(r, 0, 5),
|
||||
focus: ptInRange(r, 1, 0),
|
||||
transcript: msgs('m1')
|
||||
})
|
||||
|
||||
// For a single-source-line range, the deferred-to-last-row offset
|
||||
// at col=0 gives byte 0 of row 0. The selection slice from byte 5
|
||||
// back to byte 0 is `'the q'` (reversed, but toCopyText orders).
|
||||
expect(result).toBe('the q')
|
||||
})
|
||||
|
||||
test('multi-source-line range: visualLine past count clamps to LAST line end', () => {
|
||||
// Same defensive scenario but the range has multiple source lines.
|
||||
// The wrap-continuation click should NOT include subsequent lines —
|
||||
// it should clamp to the end of the last tracked visual row.
|
||||
const source = 'first line here\nsecond line'
|
||||
// Two source lines. rowStarts = [0, 16] (16 = "first line here\n".length).
|
||||
const r = registerCustom('m1', 0, source, [0, 16])
|
||||
|
||||
// Anchor mid-first-line, focus on a "wrap continuation" row that
|
||||
// doesn't exist (visualLine=5). Should NOT include the second
|
||||
// source line — should clamp to end of last known row.
|
||||
const result = toCopyText({
|
||||
anchor: ptInRange(r, 0, 5),
|
||||
focus: ptInRange(r, 5, 0),
|
||||
transcript: msgs('m1')
|
||||
})
|
||||
|
||||
// visualLine=5 past visualLineCount=2 → defers to getOffset(1, 0)
|
||||
// = start of "second line" (byte 16). Slice [5, 16) = byte index
|
||||
// 5 to 16 of "first line here\n" = " line here\n" (leading space).
|
||||
expect(result).toBe(' line here\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toCopyText — boundary points', () => {
|
||||
test('before-all + after-all selects entire transcript', () => {
|
||||
const r1 = registerSimple('m1', 0, 'one')
|
||||
const r2 = registerSimple('m2', 0, 'two')
|
||||
expect(
|
||||
toCopyText({
|
||||
anchor: { kind: 'before-all' },
|
||||
focus: { kind: 'after-all' },
|
||||
transcript: msgs('m1', 'm2')
|
||||
})
|
||||
).toBe('one\ntwo')
|
||||
})
|
||||
|
||||
test('before-all to mid-msg emits from start', () => {
|
||||
const r1 = registerSimple('m1', 0, 'hello')
|
||||
expect(
|
||||
toCopyText({
|
||||
anchor: { kind: 'before-all' },
|
||||
focus: ptInRange(r1, 0, 3),
|
||||
transcript: msgs('m1')
|
||||
})
|
||||
).toBe('hel')
|
||||
})
|
||||
|
||||
test('mid-msg to after-all emits to end', () => {
|
||||
const r1 = registerSimple('m1', 0, 'hello')
|
||||
expect(
|
||||
toCopyText({
|
||||
anchor: ptInRange(r1, 0, 2),
|
||||
focus: { kind: 'after-all' },
|
||||
transcript: msgs('m1')
|
||||
})
|
||||
).toBe('llo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toCopyText — stale rangeId (evicted)', () => {
|
||||
test('stale anchor + valid focus + no host-side repair → empty', () => {
|
||||
// Contract: when a range is evicted, the host is expected to repair
|
||||
// the selection via the truncate-to-survivor policy BEFORE calling
|
||||
// toCopyText. If it forgets, toCopyText degrades gracefully — both
|
||||
// endpoints fall to the far-end of the document, the resolved window
|
||||
// collapses, and the output is empty rather than wrong.
|
||||
const r2 = registerSimple('m2', 0, 'two')
|
||||
const stale: SelectionPoint = { kind: 'in-range', rangeId: 99999, visualLine: 0, col: 0 }
|
||||
expect(
|
||||
toCopyText({
|
||||
anchor: stale,
|
||||
focus: ptInRange(r2, 0, 3),
|
||||
transcript: msgs('m1', 'm2')
|
||||
})
|
||||
).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toCopyText — idempotence / round-trip', () => {
|
||||
test('select-all of plain transcript equals concatenated source with \\n separator', () => {
|
||||
const sources = ['first message', 'second message', 'third message']
|
||||
const ids = ['m1', 'm2', 'm3']
|
||||
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
registerSimple(ids[i]!, 0, sources[i]!)
|
||||
}
|
||||
|
||||
expect(
|
||||
toCopyText({
|
||||
anchor: { kind: 'before-all' },
|
||||
focus: { kind: 'after-all' },
|
||||
transcript: msgs(...ids)
|
||||
})
|
||||
).toBe('first message\nsecond message\nthird message')
|
||||
})
|
||||
|
||||
test('select-all of markdown msg (multi-block) reproduces full body', () => {
|
||||
// msg "m1" with three blocks emulating:
|
||||
// "# heading"
|
||||
// ""
|
||||
// "paragraph here"
|
||||
registerSimple('m1', 1, '# heading')
|
||||
registerSimple('m1', 2, '')
|
||||
registerSimple('m1', 3, 'paragraph here')
|
||||
expect(
|
||||
toCopyText({
|
||||
anchor: { kind: 'before-all' },
|
||||
focus: { kind: 'after-all' },
|
||||
transcript: msgs('m1')
|
||||
})
|
||||
).toBe('# heading\n\nparagraph here')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toCopyText — gap points', () => {
|
||||
test('gap between two ranges acts like the boundary between them', () => {
|
||||
const r1 = registerSimple('m1', 0, 'first')
|
||||
const r2 = registerSimple('m2', 0, 'second')
|
||||
const gap: SelectionPoint = { kind: 'gap', afterRangeId: r1, beforeRangeId: r2 }
|
||||
// Anchor at start of r1, focus in the gap → emits just first
|
||||
// (gap-after-r1 means we're past r1 but before r2).
|
||||
expect(
|
||||
toCopyText({
|
||||
anchor: ptInRange(r1, 0, 0),
|
||||
focus: gap,
|
||||
transcript: msgs('m1', 'm2')
|
||||
})
|
||||
).toBe('first')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Regression test for ethie's report: double-click "might" in a callout
|
||||
* (`> [!WARNING]\n> things might break if u skip this`) copied "migh" —
|
||||
* one char short. Same on drag-select.
|
||||
*
|
||||
* Root cause: cell-INCLUSIVE selection bounds (anchor/focus point AT
|
||||
* the cell, not past it) crossed with EXCLUSIVE slice semantics in
|
||||
* toCopyText. The hit-test for verbatim fragments returned
|
||||
* `f.start + (localCol - f.colStart)` — the START byte of the clicked
|
||||
* cell — for both endpoints, dropping one char off the right edge of
|
||||
* every selection.
|
||||
*
|
||||
* Fix: `copyPointAt` now takes an `endpoint: 'start' | 'end'` arg.
|
||||
* The buildCopyTextFromDom bridge passes `'end'` for the focus, and the
|
||||
* verbatim cell→byte math bumps by 1 (clamped to fragment end) so the
|
||||
* end-byte points PAST the last selected cell. Slice semantics then
|
||||
* work out exactly.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
|
||||
import { simpleOffsetFor } from '../offsetMaps.js'
|
||||
import { registerRange, resetRegistry } from '../registry.js'
|
||||
import { toCopyText } from '../toCopyText.js'
|
||||
|
||||
describe('word selection endpoint off-by-one (regression)', () => {
|
||||
beforeEach(() => {
|
||||
resetRegistry()
|
||||
})
|
||||
|
||||
it('focus on last cell of "might" with endpoint="end" math yields "might"', () => {
|
||||
// Source layout:
|
||||
// "things might break"
|
||||
// 0 7 13
|
||||
// t h i n g s _ m i g h t _ b r e a k
|
||||
// 0 1 2 3 4 5 6 7 8 9 ...
|
||||
const SOURCE = 'things might break'
|
||||
const MIGHT_START = 7
|
||||
const MIGHT_END = 12
|
||||
|
||||
const rangeId = registerRange({
|
||||
msgId: 'm1',
|
||||
blockIndex: 1,
|
||||
outerSource: SOURCE,
|
||||
visualLineCount: 1,
|
||||
getOffset: simpleOffsetFor(SOURCE, new Uint32Array([0]))
|
||||
})
|
||||
|
||||
// Simulate the post-fix verbatim cell→byte math from
|
||||
// copyPointHitTest.ts. The fragment spans cells [0, SOURCE.length)
|
||||
// and source bytes [0, SOURCE.length).
|
||||
// anchor (endpoint='start'): bump=0 → f.start + cellsIn
|
||||
// focus (endpoint='end'): bump=1 → f.start + cellsIn + 1, clamped
|
||||
const cellToByte = (col: number, endpoint: 'start' | 'end'): number => {
|
||||
const cellsIn = col - 0
|
||||
const bump = endpoint === 'end' ? 1 : 0
|
||||
const len = SOURCE.length
|
||||
|
||||
return 0 + Math.min(cellsIn + bump, len)
|
||||
}
|
||||
|
||||
// anchor: cell 7 ('m'), endpoint='start' → 7
|
||||
const anchorOffset = cellToByte(7, 'start')
|
||||
// focus: cell 11 ('t' — last cell of 'might'), endpoint='end' → 12
|
||||
const focusOffset = cellToByte(11, 'end')
|
||||
|
||||
expect(anchorOffset).toBe(MIGHT_START)
|
||||
expect(focusOffset).toBe(MIGHT_END)
|
||||
|
||||
const copied = toCopyText({
|
||||
anchor: { kind: 'in-range', rangeId, visualLine: 0, col: 7, sourceOffset: anchorOffset },
|
||||
focus: { kind: 'in-range', rangeId, visualLine: 0, col: 11, sourceOffset: focusOffset },
|
||||
transcript: [{ id: 'm1', order: 0 }]
|
||||
})
|
||||
|
||||
expect(copied).toBe('might')
|
||||
})
|
||||
|
||||
it('focus past last cell of fragment clamps to fragment end (no over-read)', () => {
|
||||
// Click on the very last cell with endpoint='end' should land
|
||||
// EXACTLY on fragment end (not over).
|
||||
const SOURCE = 'might'
|
||||
const rangeId = registerRange({
|
||||
msgId: 'm2',
|
||||
blockIndex: 1,
|
||||
outerSource: SOURCE,
|
||||
visualLineCount: 1,
|
||||
getOffset: simpleOffsetFor(SOURCE, new Uint32Array([0]))
|
||||
})
|
||||
|
||||
const cellToByte = (col: number, endpoint: 'start' | 'end'): number => {
|
||||
const cellsIn = col - 0
|
||||
const bump = endpoint === 'end' ? 1 : 0
|
||||
const len = SOURCE.length
|
||||
|
||||
return 0 + Math.min(cellsIn + bump, len)
|
||||
}
|
||||
|
||||
// Even with bump, clamped at fragment end — no over-read.
|
||||
expect(cellToByte(4, 'end')).toBe(5)
|
||||
expect(cellToByte(4, 'start')).toBe(4)
|
||||
|
||||
const copied = toCopyText({
|
||||
anchor: { kind: 'in-range', rangeId, visualLine: 0, col: 0, sourceOffset: 0 },
|
||||
focus: { kind: 'in-range', rangeId, visualLine: 0, col: 4, sourceOffset: 5 },
|
||||
transcript: [{ id: 'm2', order: 0 }]
|
||||
})
|
||||
|
||||
expect(copied).toBe('might')
|
||||
})
|
||||
|
||||
it('anchor unchanged: endpoint="start" still gives cell-start byte', () => {
|
||||
// Sanity: the fix must NOT shift anchor-side semantics.
|
||||
const SOURCE = 'things might break'
|
||||
const cellToByte = (col: number, endpoint: 'start' | 'end'): number => {
|
||||
const cellsIn = col - 0
|
||||
const bump = endpoint === 'end' ? 1 : 0
|
||||
const len = SOURCE.length
|
||||
|
||||
return 0 + Math.min(cellsIn + bump, len)
|
||||
}
|
||||
|
||||
// Anchor on 'm' of "might" → cell 7 → byte 7
|
||||
expect(cellToByte(7, 'start')).toBe(7)
|
||||
// (Same call with endpoint='end' would give 8 — the boundary clarifies
|
||||
// why threading endpoint explicitly matters.)
|
||||
expect(cellToByte(7, 'end')).toBe(8)
|
||||
})
|
||||
})
|
||||
|
||||
62
ui-tui/src/lib/copySource/buildCopyTextFromDom.ts
Normal file
62
ui-tui/src/lib/copySource/buildCopyTextFromDom.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Host-side copy-text builder. Plugged into Ink via `setCopyTextFn`.
|
||||
*
|
||||
* Walks the live DOM at copy time to find every Box tagged with
|
||||
* `style.copyRangeId` that intersects the current selection rect, builds
|
||||
* SelectionPoints for the anchor + focus of the selection, and calls
|
||||
* `toCopyText` against the registry + transcript.
|
||||
*
|
||||
* Drag-scroll fidelity comes for free: rangeIds remain in the registry
|
||||
* after their DOMs unmount, and the anchor SelectionPoint captured at
|
||||
* mouse-down stays valid through scroll because rangeIds are stable.
|
||||
* The "extends past viewport" cases that captureScrolledRows used to
|
||||
* handle are handled by toCopyText seeing the anchor-side range as
|
||||
* fully included (start col 0, span includes the range).
|
||||
*/
|
||||
|
||||
import type { InkInstance } from '@hermes/ink'
|
||||
|
||||
import { copyPointFromColRow } from './hitTestBridge.js'
|
||||
import { toCopyText } from './toCopyText.js'
|
||||
import type { MsgSnapshot, SelectionPoint } from './types.js'
|
||||
|
||||
/**
|
||||
* Build the copy-text builder. Pass the current `transcript` getter so the
|
||||
* builder always sees the latest Msg[] when copy fires (avoids closing
|
||||
* over stale state).
|
||||
*/
|
||||
export function makeCopyTextFn(
|
||||
getTranscript: () => readonly MsgSnapshot[]
|
||||
): (ink: InkInstance) => string {
|
||||
return (ink) => {
|
||||
const bounds = ink.getSelectionBoundsScreen()
|
||||
|
||||
if (!bounds) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const rootDom = ink.getRootDom()
|
||||
const transcript = getTranscript()
|
||||
const anchor = copyPointFromColRow(rootDom, bounds.start.col, bounds.start.row, 'start')
|
||||
const focus = copyPointFromColRow(rootDom, bounds.end.col, bounds.end.row, 'end')
|
||||
|
||||
// Cell-INCLUSIVE selection bounds × byte-EXCLUSIVE slice semantics:
|
||||
// when the focus point fell through to the no-fragment fallback
|
||||
// path (no sourceOffset set — e.g. code fences, plain text blocks
|
||||
// without inline markdown registered as fragments), the resolved
|
||||
// col still points AT the last selected cell. Bump it by +1 so
|
||||
// toCopyText's pointToOffset returns the byte-EXCLUSIVE end. The
|
||||
// bump is clamped by getOffset's per-row source-end cap, so no
|
||||
// over-read across line boundaries.
|
||||
//
|
||||
// For the fragment path, the hit-test already baked this bump in
|
||||
// (see copyPointHitTest endpoint='end' arg) and sourceOffset is
|
||||
// set — we leave that alone.
|
||||
const focusBumped: SelectionPoint =
|
||||
focus.kind === 'in-range' && focus.sourceOffset === undefined
|
||||
? { ...focus, col: focus.col + 1 }
|
||||
: focus
|
||||
|
||||
return toCopyText({ anchor, focus: focusBumped, transcript })
|
||||
}
|
||||
}
|
||||
49
ui-tui/src/lib/copySource/hitTestBridge.ts
Normal file
49
ui-tui/src/lib/copySource/hitTestBridge.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Bridge between hermes-ink's `copyPointAt` (operates on Ink's internal
|
||||
* DOMElement type) and the host's `SelectionPoint` type. The shapes are
|
||||
* structurally identical now that `copyPointAt` returns gap adjacency
|
||||
* and per-fragment `sourceOffset` directly — this bridge is a thin
|
||||
* re-typing layer that keeps the dependency direction clean (host
|
||||
* depends on hermes-ink; hermes-ink doesn't import host types).
|
||||
*/
|
||||
|
||||
import { copyPointAt as inkCopyPointAt } from '@hermes/ink'
|
||||
|
||||
import type { SelectionPoint } from './types.js'
|
||||
|
||||
type RawPoint =
|
||||
| {
|
||||
kind: 'in-range'
|
||||
rangeId: number
|
||||
visualLine: number
|
||||
col: number
|
||||
sourceOffset?: number
|
||||
}
|
||||
| { kind: 'gap'; afterRangeId: null | number; beforeRangeId: null | number }
|
||||
|
||||
export function copyPointFromColRow(
|
||||
rootDom: unknown,
|
||||
col: number,
|
||||
row: number,
|
||||
endpoint: 'start' | 'end' = 'start'
|
||||
): SelectionPoint {
|
||||
const raw = (inkCopyPointAt as (root: unknown, col: number, row: number, endpoint?: 'start' | 'end') => RawPoint)(
|
||||
rootDom,
|
||||
col,
|
||||
row,
|
||||
endpoint
|
||||
)
|
||||
|
||||
if (raw.kind === 'in-range') {
|
||||
return {
|
||||
kind: 'in-range',
|
||||
rangeId: raw.rangeId,
|
||||
visualLine: raw.visualLine,
|
||||
col: raw.col,
|
||||
...(raw.sourceOffset !== undefined && { sourceOffset: raw.sourceOffset })
|
||||
}
|
||||
}
|
||||
|
||||
// Gap: copy adjacency through.
|
||||
return { kind: 'gap', afterRangeId: raw.afterRangeId, beforeRangeId: raw.beforeRangeId }
|
||||
}
|
||||
181
ui-tui/src/lib/copySource/offsetMaps.ts
Normal file
181
ui-tui/src/lib/copySource/offsetMaps.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Factory functions for SourceRange.getOffset.
|
||||
*
|
||||
* Two flavors:
|
||||
*
|
||||
* - `simpleOffsetFor`: for ranges where rendered text == source text
|
||||
* character-for-character. Code fences, plain messages, tool output.
|
||||
* Built from per-visual-row source offsets (a Uint32Array).
|
||||
*
|
||||
* - `inlineOffsetFor`: for ranges with inline markdown rendering, where
|
||||
* `**bold**` (6 source chars) renders as `bold` (4 visual cells). Built
|
||||
* from per-visual-row span tables that describe each rendered segment's
|
||||
* visual extent and source extent.
|
||||
*
|
||||
* Both produce a `(visualRow, col) → byteOffset` function with the same
|
||||
* shape. The range stores this function in `range.getOffset` and toCopyText
|
||||
* just calls it.
|
||||
*/
|
||||
|
||||
import type { SourceRange } from './types.js'
|
||||
|
||||
/**
|
||||
* For each visual row, the byte offset into outerSource where that row's
|
||||
* content begins. The end of row v is `rowStarts[v+1]` (after subtracting
|
||||
* 1 if the boundary is a hard newline) or `outerSource.length` for the
|
||||
* last row.
|
||||
*
|
||||
* Multiple consecutive entries pointing into the same source line
|
||||
* represent soft-wrap of one source line over multiple visual rows.
|
||||
*/
|
||||
export type SimpleOffsetMap = Uint32Array
|
||||
|
||||
export function simpleOffsetFor(
|
||||
outerSource: string,
|
||||
rowStarts: SimpleOffsetMap
|
||||
): (visualRow: number, col: number) => number {
|
||||
return (visualRow, col) => {
|
||||
if (visualRow < 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (visualRow >= rowStarts.length) {
|
||||
return outerSource.length
|
||||
}
|
||||
|
||||
const rowStart = rowStarts[visualRow]!
|
||||
let rowEnd: number
|
||||
|
||||
if (visualRow + 1 < rowStarts.length) {
|
||||
const next = rowStarts[visualRow + 1]!
|
||||
// If the byte before `next` is a newline, that newline is the row
|
||||
// separator and NOT part of either row's content. Step back to
|
||||
// exclude it. For soft-wrap continuations (no intervening \n in
|
||||
// source), next IS the end of the row.
|
||||
rowEnd = next > rowStart && outerSource.charCodeAt(next - 1) === 10 ? next - 1 : next
|
||||
} else {
|
||||
rowEnd = outerSource.length
|
||||
}
|
||||
|
||||
return Math.min(rowStart + Math.max(0, col), rowEnd)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One rendered segment on a visual row. `visualStart`/`visualEnd` are
|
||||
* 0-indexed columns within the row (visualEnd exclusive). `sourceStart`/
|
||||
* `sourceEnd` are byte offsets into outerSource (sourceEnd exclusive).
|
||||
*
|
||||
* For verbatim text (no formatting), visualEnd - visualStart ==
|
||||
* sourceEnd - sourceStart. For rendered markdown like `**bold**`, the
|
||||
* visual span is 4 cells and the source span is 8 bytes.
|
||||
*
|
||||
* Spans within a row must be contiguous and non-overlapping in visual
|
||||
* coordinates. Source coordinates need not be contiguous (a `[link](url)`
|
||||
* has rendered text "link" but the URL bytes are skipped in source span
|
||||
* order). Spans are ordered by visualStart.
|
||||
*/
|
||||
export type InlineSpan = {
|
||||
visualStart: number
|
||||
visualEnd: number
|
||||
sourceStart: number
|
||||
sourceEnd: number
|
||||
}
|
||||
|
||||
/**
|
||||
* For each visual row, the ordered list of spans on that row. Empty
|
||||
* array allowed (row with no content; e.g. blank inline section). Length
|
||||
* is the row count.
|
||||
*/
|
||||
export type InlineSpanTable = readonly (readonly InlineSpan[])[]
|
||||
|
||||
export function inlineOffsetFor(
|
||||
outerSource: string,
|
||||
spansPerRow: InlineSpanTable
|
||||
): (visualRow: number, col: number) => number {
|
||||
return (visualRow, col) => {
|
||||
if (visualRow < 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (visualRow >= spansPerRow.length) {
|
||||
return outerSource.length
|
||||
}
|
||||
|
||||
const spans = spansPerRow[visualRow]!
|
||||
|
||||
if (spans.length === 0) {
|
||||
// Row had no source-mapped content. Best we can do is "first byte
|
||||
// of the next row's content, or end of source."
|
||||
for (let r = visualRow + 1; r < spansPerRow.length; r++) {
|
||||
const nextSpans = spansPerRow[r]!
|
||||
|
||||
if (nextSpans.length > 0) {
|
||||
return nextSpans[0]!.sourceStart
|
||||
}
|
||||
}
|
||||
|
||||
return outerSource.length
|
||||
}
|
||||
|
||||
const c = Math.max(0, col)
|
||||
|
||||
// Before the first span on this row → snap to its source start.
|
||||
if (c < spans[0]!.visualStart) {
|
||||
return spans[0]!.sourceStart
|
||||
}
|
||||
|
||||
// Find the span containing col.
|
||||
for (let i = 0; i < spans.length; i++) {
|
||||
const s = spans[i]!
|
||||
|
||||
if (c >= s.visualStart && c < s.visualEnd) {
|
||||
// Within this span. The col offset within the span maps linearly
|
||||
// into the source span ONLY when source-len == visual-len (verbatim).
|
||||
// For rendered spans where they differ, we proportionally map:
|
||||
// col == visualStart → sourceStart, col == visualEnd → sourceEnd.
|
||||
const visualLen = s.visualEnd - s.visualStart
|
||||
const sourceLen = s.sourceEnd - s.sourceStart
|
||||
|
||||
if (visualLen === sourceLen) {
|
||||
return s.sourceStart + (c - s.visualStart)
|
||||
}
|
||||
|
||||
// Proportional: round so that "all visual cells of the span have
|
||||
// a source position" (no orphan cell falling between two source
|
||||
// chars). For c == visualStart the formula gives sourceStart;
|
||||
// for c == visualEnd - 1 the formula gives ~sourceEnd - 1.
|
||||
const t = visualLen > 0 ? (c - s.visualStart) / visualLen : 0
|
||||
|
||||
return s.sourceStart + Math.round(t * sourceLen)
|
||||
}
|
||||
}
|
||||
|
||||
// Past the last span on this row → snap to its source end.
|
||||
return spans[spans.length - 1]!.sourceEnd
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder helper for the common case where you have an array of strings
|
||||
* (visual rows) and want a SimpleOffsetMap assuming each row corresponds
|
||||
* to one source line and rows are joined by '\n' in source.
|
||||
*
|
||||
* Returns a fresh Uint32Array. NOT for soft-wrap — callers that know
|
||||
* about wrap should build the array themselves with duplicate row-starts
|
||||
* for wrapped continuations.
|
||||
*/
|
||||
export function buildLineStartsFromRows(rows: readonly string[]): SimpleOffsetMap {
|
||||
const out = new Uint32Array(rows.length)
|
||||
let off = 0
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
out[i] = off
|
||||
off += rows[i]!.length + 1
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/** Re-export for SourceRange consumers. */
|
||||
export type GetOffset = SourceRange['getOffset']
|
||||
142
ui-tui/src/lib/copySource/registry.ts
Normal file
142
ui-tui/src/lib/copySource/registry.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Source-range registry.
|
||||
*
|
||||
* Keyed on `${msgId}::${blockIndex}` so that re-mounting a range (e.g. when
|
||||
* a virtual-scrolled message scrolls back into view) re-uses its previous
|
||||
* RangeId. This means selection points anchored to a range survive
|
||||
* unmount/remount cycles correctly.
|
||||
*
|
||||
* The registry outlives the DOM: a range stays registered until its
|
||||
* message is evicted from the transcript history. The host app calls
|
||||
* `evictMessage(msgId)` from the history-cap path.
|
||||
*
|
||||
* The registry is a module-level singleton. This is one piece of "global
|
||||
* state" but it's confined to a single small module with a tiny API; any
|
||||
* test that needs isolation calls `resetRegistry()`.
|
||||
*/
|
||||
|
||||
import type { RangeId, SourceRange } from './types.js'
|
||||
|
||||
const ranges = new Map<RangeId, SourceRange>()
|
||||
const byKey = new Map<string, RangeId>()
|
||||
let nextId = 1
|
||||
|
||||
function rangeKey(msgId: string, blockIndex: number): string {
|
||||
return `${msgId}\x00${blockIndex}`
|
||||
}
|
||||
|
||||
export type RegisterInput = {
|
||||
msgId: string
|
||||
blockIndex: number
|
||||
outerSource: string
|
||||
/** Defaults to `outerSource`. */
|
||||
innerSource?: string
|
||||
/** Defaults to 0. */
|
||||
innerOffset?: number
|
||||
/**
|
||||
* Total visual-row count this range rendered to. For unmounted /
|
||||
* not-yet-measured ranges, pass 1 (placeholder — selection won't be
|
||||
* able to anchor inside it until a real measurement arrives).
|
||||
*/
|
||||
visualLineCount: number
|
||||
/**
|
||||
* (visualRow, col) → byte offset into outerSource.
|
||||
*
|
||||
* Plain-text helper: see `simpleOffsetFor(outerSource, lineStarts)`.
|
||||
* Inline-markdown helper: see `inlineOffsetFor(spansPerRow)`.
|
||||
*
|
||||
* For ranges that lack a measurement yet, pass `() => 0` and re-register
|
||||
* later when measured — toCopyText will snap selections inside the range
|
||||
* to offset 0 in the interim (no source leak; the range still emits its
|
||||
* outerSource when fully covered).
|
||||
*/
|
||||
getOffset: (visualRow: number, col: number) => number
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a source range. Returns the (possibly recycled) RangeId.
|
||||
* If a range with the same (msgId, blockIndex) is already registered,
|
||||
* the SourceRange is updated in place and the same id is returned —
|
||||
* callers don't have to coordinate unmount/remount themselves.
|
||||
*/
|
||||
export function registerRange(input: RegisterInput): RangeId {
|
||||
const key = rangeKey(input.msgId, input.blockIndex)
|
||||
const existing = byKey.get(key)
|
||||
const id = existing ?? nextId++
|
||||
const innerSource = input.innerSource ?? input.outerSource
|
||||
const innerOffset = input.innerOffset ?? 0
|
||||
|
||||
const range: SourceRange = {
|
||||
id,
|
||||
msgId: input.msgId,
|
||||
blockIndex: input.blockIndex,
|
||||
outerSource: input.outerSource,
|
||||
innerSource,
|
||||
innerOffset,
|
||||
visualLineCount: input.visualLineCount,
|
||||
getOffset: input.getOffset,
|
||||
domNode: existing ? (ranges.get(existing)?.domNode ?? null) : null
|
||||
}
|
||||
|
||||
ranges.set(id, range)
|
||||
|
||||
if (!existing) {
|
||||
byKey.set(key, id)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
/** Update only the DOM node pointer (called from anchor.tsx ref). */
|
||||
export function setRangeDom(id: RangeId, domNode: unknown): void {
|
||||
const range = ranges.get(id)
|
||||
|
||||
if (range) {
|
||||
range.domNode = domNode
|
||||
}
|
||||
}
|
||||
|
||||
/** Get a range by id. Returns undefined if it has been evicted. */
|
||||
export function getRange(id: RangeId): SourceRange | undefined {
|
||||
return ranges.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict all ranges belonging to a message. Called from the history-cap
|
||||
* path when a message is dropped from the transcript. The msg's ranges
|
||||
* are gone forever — any selection point still pointing at them is
|
||||
* stale and must be repaired by the caller (truncate-to-survivor policy).
|
||||
*/
|
||||
export function evictMessage(msgId: string): RangeId[] {
|
||||
const evicted: RangeId[] = []
|
||||
|
||||
for (const [key, id] of byKey) {
|
||||
const range = ranges.get(id)
|
||||
|
||||
if (range && range.msgId === msgId) {
|
||||
evicted.push(id)
|
||||
ranges.delete(id)
|
||||
byKey.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
return evicted
|
||||
}
|
||||
|
||||
/**
|
||||
* All currently-registered ranges. Used by toCopyText to assemble copy
|
||||
* text in document order. The host app must provide a message-order
|
||||
* function to break ties between ranges from different messages
|
||||
* (insertion order isn't enough — messages can be popped from the
|
||||
* middle on /undo).
|
||||
*/
|
||||
export function listRanges(): SourceRange[] {
|
||||
return Array.from(ranges.values())
|
||||
}
|
||||
|
||||
/** Test helper: wipe everything. Not used in production. */
|
||||
export function resetRegistry(): void {
|
||||
ranges.clear()
|
||||
byKey.clear()
|
||||
nextId = 1
|
||||
}
|
||||
443
ui-tui/src/lib/copySource/toCopyText.ts
Normal file
443
ui-tui/src/lib/copySource/toCopyText.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* Assemble clipboard text from two SelectionPoints + the transcript.
|
||||
*
|
||||
* This is the entire copy pipeline. Pure function. No screen-buffer access,
|
||||
* no DOM access, no globals — just point arithmetic over registered
|
||||
* SourceRanges.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Order the two endpoints into (lo, hi).
|
||||
* 2. Collect every SourceRange whose msg falls in [lo.msg .. hi.msg] in
|
||||
* document order.
|
||||
* 3. For each range, compute the source-byte slice it contributes:
|
||||
* - middle ranges (not touched by either endpoint) → entire outerSource
|
||||
* - lo's range → from lo's source offset to end
|
||||
* - hi's range → from start to hi's source offset
|
||||
* - same range (lo and hi point into it) → from lo to hi
|
||||
* 4. Apply the fence-stripping rule: if BOTH endpoints land inside the
|
||||
* inner body of the same range, swap outerSource for innerSource and
|
||||
* adjust offsets accordingly.
|
||||
* 5. Join the slices with newlines. The separator between two adjacent
|
||||
* ranges is one newline; nothing else is added (the source already
|
||||
* has its own trailing newlines if appropriate).
|
||||
*/
|
||||
|
||||
import { getRange, listRanges } from './registry.js'
|
||||
import type { MsgSnapshot, SelectionPoint, SourceRange } from './types.js'
|
||||
|
||||
type Point = SelectionPoint
|
||||
|
||||
/**
|
||||
* Compare two ranges in document order using msg order then blockIndex.
|
||||
* Used to sort the ranges between lo and hi.
|
||||
*/
|
||||
function compareRanges(
|
||||
a: SourceRange,
|
||||
b: SourceRange,
|
||||
msgOrder: ReadonlyMap<string, number>
|
||||
): number {
|
||||
const oa = msgOrder.get(a.msgId) ?? Number.POSITIVE_INFINITY
|
||||
const ob = msgOrder.get(b.msgId) ?? Number.POSITIVE_INFINITY
|
||||
|
||||
if (oa !== ob) {
|
||||
return oa - ob
|
||||
}
|
||||
|
||||
return a.blockIndex - b.blockIndex
|
||||
}
|
||||
|
||||
/**
|
||||
* Within a single range, convert (visualLine, col) to a source-byte offset.
|
||||
*
|
||||
* The map gives the source offset where each visual row begins. The column
|
||||
* is added on top: visual columns map 1-to-1 to source bytes WITHIN a
|
||||
* single row (assuming ASCII or single-codepoint chars). For unicode-aware
|
||||
* handling, callers should pre-clamp `col` to the on-screen column index
|
||||
* of the desired character. This function performs no width conversion.
|
||||
*
|
||||
* If `visualLine >= visualLineCount`, defers to `getOffset` with the
|
||||
* last-row index — the offset map's own clamping kicks in there. This
|
||||
* avoids snapping to `outerSource.length` when the block had no
|
||||
* fragment hit and the visual row is just a soft-wrap continuation
|
||||
* past the block's tracked source-line count (a common case: source
|
||||
* line wraps to multiple visual rows, but the block was registered
|
||||
* with `visualLineCount = source-line-count`).
|
||||
*
|
||||
* If `visualLine < 0`, returns 0.
|
||||
*/
|
||||
function pointToOffset(range: SourceRange, visualLine: number, col: number): number {
|
||||
if (visualLine < 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (visualLine >= range.visualLineCount) {
|
||||
// Defer to the last tracked row + the column. The offset map's
|
||||
// per-row clamping will cap at the row's source-end. This is more
|
||||
// useful than `outerSource.length`, which would copy the entire
|
||||
// remaining block on what's likely just a wrap-continuation click.
|
||||
return range.getOffset(Math.max(0, range.visualLineCount - 1), Math.max(0, col))
|
||||
}
|
||||
|
||||
return range.getOffset(visualLine, Math.max(0, col))
|
||||
}
|
||||
|
||||
/**
|
||||
* Order two points so the smaller (earlier in the document) is first.
|
||||
*
|
||||
* Order rules:
|
||||
* - before-all < anything < after-all
|
||||
* - in-range comparison: by (msgOrder, blockIndex, visualLine, col)
|
||||
* - gap with after/before refs falls between the two referenced ranges;
|
||||
* gap.afterRangeId == X and another point on range X → the gap comes
|
||||
* after range X.
|
||||
*/
|
||||
function orderPoints(
|
||||
a: Point,
|
||||
b: Point,
|
||||
msgOrder: ReadonlyMap<string, number>
|
||||
): [Point, Point] {
|
||||
if (compareToA(a, b, msgOrder) <= 0) {
|
||||
return [a, b]
|
||||
}
|
||||
|
||||
return [b, a]
|
||||
}
|
||||
|
||||
/**
|
||||
* Negative when a < b, positive when a > b, 0 when equal in document order.
|
||||
*/
|
||||
function compareToA(a: Point, b: Point, msgOrder: ReadonlyMap<string, number>): number {
|
||||
// Universal endpoints
|
||||
if (a.kind === 'before-all') {
|
||||
return b.kind === 'before-all' ? 0 : -1
|
||||
}
|
||||
|
||||
if (b.kind === 'before-all') {
|
||||
return 1
|
||||
}
|
||||
|
||||
if (a.kind === 'after-all') {
|
||||
return b.kind === 'after-all' ? 0 : 1
|
||||
}
|
||||
|
||||
if (b.kind === 'after-all') {
|
||||
return -1
|
||||
}
|
||||
|
||||
// Reduce gap → range-anchored point for comparison: a gap "after X" is
|
||||
// (X-end + epsilon), "before X" is (X-start - epsilon).
|
||||
const ar = reducePoint(a)
|
||||
const br = reducePoint(b)
|
||||
const ag = msgOrder.get(ar.msgId) ?? Number.POSITIVE_INFINITY
|
||||
const bg = msgOrder.get(br.msgId) ?? Number.POSITIVE_INFINITY
|
||||
|
||||
if (ag !== bg) {
|
||||
return ag - bg
|
||||
}
|
||||
|
||||
if (ar.blockIndex !== br.blockIndex) {
|
||||
return ar.blockIndex - br.blockIndex
|
||||
}
|
||||
|
||||
if (ar.visualLine !== br.visualLine) {
|
||||
return ar.visualLine - br.visualLine
|
||||
}
|
||||
|
||||
return ar.col - br.col
|
||||
}
|
||||
|
||||
type Reduced = {
|
||||
msgId: string
|
||||
blockIndex: number
|
||||
visualLine: number
|
||||
col: number
|
||||
}
|
||||
|
||||
/** Reduce in-range / gap into a Reduced shape for ordering. */
|
||||
function reducePoint(p: Exclude<Point, { kind: 'before-all' | 'after-all' }>): Reduced {
|
||||
if (p.kind === 'in-range') {
|
||||
const r = getRange(p.rangeId)
|
||||
|
||||
if (!r) {
|
||||
return { msgId: '\uFFFF', blockIndex: 0, visualLine: 0, col: 0 }
|
||||
}
|
||||
|
||||
return { msgId: r.msgId, blockIndex: r.blockIndex, visualLine: p.visualLine, col: p.col }
|
||||
}
|
||||
|
||||
// gap
|
||||
const afterId = p.afterRangeId
|
||||
const beforeId = p.beforeRangeId
|
||||
|
||||
if (afterId != null) {
|
||||
const r = getRange(afterId)
|
||||
|
||||
if (r) {
|
||||
return {
|
||||
msgId: r.msgId,
|
||||
blockIndex: r.blockIndex,
|
||||
// After the last visual row → position AFTER it.
|
||||
visualLine: r.visualLineCount,
|
||||
col: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (beforeId != null) {
|
||||
const r = getRange(beforeId)
|
||||
|
||||
if (r) {
|
||||
return { msgId: r.msgId, blockIndex: r.blockIndex, visualLine: -1, col: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// Truly empty gap (no neighbors known): treat as far-end so two empty
|
||||
// gaps compare equal.
|
||||
return { msgId: '\uFFFF', blockIndex: 0, visualLine: 0, col: 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a point to either:
|
||||
* - { rangeId, offset } when it falls inside a range (in-range) OR
|
||||
* when it's a gap whose adjacency uniquely places it at a known
|
||||
* range's start/end (gap-after-X → end of X, gap-before-X → start
|
||||
* of X)
|
||||
* - null when it's before/after/in-an-empty-gap (the point contributes
|
||||
* nothing to the output; the ranges between the two points are what
|
||||
* matters)
|
||||
*
|
||||
* The gap resolution is what lets selections that anchor on the blank
|
||||
* line between two messages emit clean output. Without it, gap endpoints
|
||||
* 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)
|
||||
|
||||
if (!r) {
|
||||
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) }
|
||||
}
|
||||
|
||||
if (p.kind === 'gap') {
|
||||
// gap-after-X: the gap is past the end of range X → resolve to
|
||||
// (X, X.outerSource.length). When X is the lo endpoint, this means
|
||||
// X contributes nothing (from == to == end). When X is the hi
|
||||
// endpoint, this means X contributes its entire source (from 0 to
|
||||
// end).
|
||||
if (p.afterRangeId != null) {
|
||||
const r = getRange(p.afterRangeId)
|
||||
|
||||
if (r) {
|
||||
return { rangeId: p.afterRangeId, offset: r.outerSource.length }
|
||||
}
|
||||
}
|
||||
|
||||
// gap-before-Y: the gap is just before the start of range Y →
|
||||
// resolve to (Y, 0). When Y is the hi endpoint, Y contributes
|
||||
// nothing (from == to == 0). When Y is the lo endpoint, Y
|
||||
// contributes its entire source.
|
||||
if (p.beforeRangeId != null) {
|
||||
const r = getRange(p.beforeRangeId)
|
||||
|
||||
if (r) {
|
||||
return { rangeId: p.beforeRangeId, offset: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export type ToCopyTextInput = {
|
||||
anchor: Point
|
||||
focus: Point
|
||||
transcript: readonly MsgSnapshot[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry. Returns the clipboard text for a selection.
|
||||
*
|
||||
* Empty when the selection is empty (anchor == focus AND both point at
|
||||
* nothing meaningful), or when the transcript is empty.
|
||||
*/
|
||||
export function toCopyText(input: ToCopyTextInput): string {
|
||||
const { anchor, focus, transcript } = input
|
||||
|
||||
if (transcript.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Build msg-id → order map once for ordering.
|
||||
const msgOrder = new Map<string, number>()
|
||||
|
||||
for (const m of transcript) {
|
||||
msgOrder.set(m.id, m.order)
|
||||
}
|
||||
|
||||
const [lo, hi] = orderPoints(anchor, focus, msgOrder)
|
||||
|
||||
// Filter to ranges that lie in [lo .. hi] inclusive.
|
||||
const all = listRanges().sort((a, b) => compareRanges(a, b, msgOrder))
|
||||
const loResolved = resolvePoint(lo)
|
||||
const hiResolved = resolvePoint(hi)
|
||||
|
||||
// Find the range index window.
|
||||
// Stale rangeIds (the range was evicted between selection time and now)
|
||||
// order to the far-end of the document via reducePoint's '\uFFFF' msgId
|
||||
// fallback. This makes findFirstAtOrAfter / findLastAtOrBefore yield -1
|
||||
// for them, which short-circuits below into an empty result. The
|
||||
// expected lifecycle is that the host repairs the selection via the
|
||||
// truncate-to-survivor policy when a msg is evicted; toCopyText's
|
||||
// behavior here is the graceful-degradation backstop.
|
||||
const startIdx = lo.kind === 'before-all' ? 0 : findFirstAtOrAfter(all, lo, msgOrder)
|
||||
const endIdx = hi.kind === 'after-all' ? all.length - 1 : findLastAtOrBefore(all, hi, msgOrder)
|
||||
|
||||
if (startIdx > endIdx || startIdx === -1 || endIdx === -1) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Fence-stripping rule: if BOTH points land inside the inner body of
|
||||
// the SAME range, emit innerSource sliced by inner-relative offsets.
|
||||
if (
|
||||
loResolved &&
|
||||
hiResolved &&
|
||||
loResolved.rangeId === hiResolved.rangeId &&
|
||||
startIdx === endIdx
|
||||
) {
|
||||
const r = all[startIdx]!
|
||||
const a = loResolved.offset
|
||||
const b = hiResolved.offset
|
||||
const innerStart = r.innerOffset
|
||||
const innerEnd = r.innerOffset + r.innerSource.length
|
||||
|
||||
if (a >= innerStart && a <= innerEnd && b >= innerStart && b <= innerEnd) {
|
||||
const lo2 = Math.min(a, b) - innerStart
|
||||
const hi2 = Math.max(a, b) - innerStart
|
||||
|
||||
return r.innerSource.slice(lo2, hi2)
|
||||
}
|
||||
}
|
||||
|
||||
// General path: walk ranges, slice each.
|
||||
const parts: string[] = []
|
||||
|
||||
for (let i = startIdx; i <= endIdx; i++) {
|
||||
const r = all[i]!
|
||||
let from = 0
|
||||
let to = r.outerSource.length
|
||||
|
||||
if (i === startIdx && loResolved && loResolved.rangeId === r.id) {
|
||||
from = loResolved.offset
|
||||
}
|
||||
|
||||
if (i === endIdx && hiResolved && hiResolved.rangeId === r.id) {
|
||||
to = hiResolved.offset
|
||||
}
|
||||
|
||||
if (from < to) {
|
||||
parts.push(r.outerSource.slice(from, to))
|
||||
} else if (from === to && i !== startIdx && i !== endIdx) {
|
||||
// Empty middle range — still include as a separator-only entry
|
||||
// so blank blocks (rare) survive the round-trip.
|
||||
parts.push('')
|
||||
}
|
||||
}
|
||||
|
||||
// Join with single newline. Trailing newlines in sources already exist
|
||||
// where appropriate; we don't add extra.
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
/** Find first range that is >= point in document order. */
|
||||
function findFirstAtOrAfter(
|
||||
ranges: readonly SourceRange[],
|
||||
point: Point,
|
||||
msgOrder: ReadonlyMap<string, number>
|
||||
): number {
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
const r = ranges[i]!
|
||||
|
||||
// Compare: is this range's END >= point.start?
|
||||
const rEnd: Reduced = {
|
||||
msgId: r.msgId,
|
||||
blockIndex: r.blockIndex,
|
||||
visualLine: r.visualLineCount,
|
||||
col: 0
|
||||
}
|
||||
|
||||
if (compareReducedToPoint(rEnd, point, msgOrder) >= 0) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
/** Find last range that is <= point in document order. */
|
||||
function findLastAtOrBefore(
|
||||
ranges: readonly SourceRange[],
|
||||
point: Point,
|
||||
msgOrder: ReadonlyMap<string, number>
|
||||
): number {
|
||||
for (let i = ranges.length - 1; i >= 0; i--) {
|
||||
const r = ranges[i]!
|
||||
const rStart: Reduced = { msgId: r.msgId, blockIndex: r.blockIndex, visualLine: 0, col: 0 }
|
||||
|
||||
if (compareReducedToPoint(rStart, point, msgOrder) <= 0) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
function compareReducedToPoint(
|
||||
a: Reduced,
|
||||
p: Point,
|
||||
msgOrder: ReadonlyMap<string, number>
|
||||
): number {
|
||||
if (p.kind === 'before-all') {return 1}
|
||||
|
||||
if (p.kind === 'after-all') {return -1}
|
||||
const b = reducePoint(p)
|
||||
const ag = msgOrder.get(a.msgId) ?? Number.POSITIVE_INFINITY
|
||||
const bg = msgOrder.get(b.msgId) ?? Number.POSITIVE_INFINITY
|
||||
|
||||
if (ag !== bg) {return ag - bg}
|
||||
|
||||
if (a.blockIndex !== b.blockIndex) {return a.blockIndex - b.blockIndex}
|
||||
|
||||
if (a.visualLine !== b.visualLine) {return a.visualLine - b.visualLine}
|
||||
|
||||
return a.col - b.col
|
||||
}
|
||||
124
ui-tui/src/lib/copySource/types.ts
Normal file
124
ui-tui/src/lib/copySource/types.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Transcript-virtual selection coordinates.
|
||||
*
|
||||
* The TUI's source-of-truth is the `Msg[]` array of conversation messages.
|
||||
* Selection endpoints are anchored to source ranges within those messages,
|
||||
* NOT to screen cells. This decouples copy/paste fidelity from rendering
|
||||
* concerns like soft-wrap, viewport culling, and drag-scroll.
|
||||
*
|
||||
* A SourceRange represents one contiguous span of original source text that
|
||||
* was rendered to a contiguous block of visual rows. Markdown messages emit
|
||||
* one range per block (paragraph, heading, fence, list, etc.). Plain
|
||||
* messages and tool output emit one range covering the whole message.
|
||||
*
|
||||
* Each range carries a `mapVisualToSource` table built at render time that
|
||||
* lets hitTest translate (visualRow, col) into a position in the outer
|
||||
* source string. toCopyText slices outer/inner source by these positions.
|
||||
*/
|
||||
|
||||
/** Stable handle for a SourceRange. Allocated by the registry. */
|
||||
export type RangeId = number
|
||||
|
||||
/**
|
||||
* One contiguous block of source text + the visual rendering of that text.
|
||||
*
|
||||
* `outerSource` is the original source string including any wrapper syntax
|
||||
* (fence markers, blockquote markers, etc.). `innerSource` is the body
|
||||
* without the wrapper — equal to `outerSource` when there is no wrapper
|
||||
* (paragraphs, plain text, tool output).
|
||||
*
|
||||
* `mapVisualToSource[v]` = byte offset into `outerSource` where the visual
|
||||
* row `v` begins. The end of row v is `mapVisualToSource[v+1]` or
|
||||
* `outerSource.length` for the last row. This handles soft-wrap correctly:
|
||||
* one source line that wrapped to N visual rows has N entries, each
|
||||
* pointing into the same source line at the right column.
|
||||
*/
|
||||
export type SourceRange = {
|
||||
/** Stable id assigned by the registry. */
|
||||
readonly id: RangeId
|
||||
/** Message this range belongs to. Used for inter-range ordering. */
|
||||
readonly msgId: string
|
||||
/** 0 for whole-msg ranges; ≥1 for per-block ranges within a msg. */
|
||||
readonly blockIndex: number
|
||||
/** Full source including any wrapper (e.g. fence markers). */
|
||||
readonly outerSource: string
|
||||
/** Body without wrapper. Equals outerSource when there is no wrapper. */
|
||||
readonly innerSource: string
|
||||
/** Byte offset in outerSource where innerSource begins. */
|
||||
readonly innerOffset: number
|
||||
/**
|
||||
* Number of visual rows this range rendered to. Used by toCopyText to
|
||||
* compute "did the selection cover this whole range" and to know what
|
||||
* range a `visualLine == visualLineCount` (after-end) point refers to.
|
||||
*/
|
||||
readonly visualLineCount: number
|
||||
/**
|
||||
* (visualRow, col) → byte offset into outerSource.
|
||||
*
|
||||
* For ranges where rendered text == source text (code fences, plain
|
||||
* messages, tool output), this is `rowStart[visualRow] + col`, clamped
|
||||
* to the row's source-byte length.
|
||||
*
|
||||
* For ranges where inline markdown rendering is applied (paragraphs,
|
||||
* 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.
|
||||
*/
|
||||
readonly getOffset: (visualRow: number, col: number) => number
|
||||
/**
|
||||
* The DOM node currently rendering this range. Mutated by anchor.tsx
|
||||
* on mount/unmount. Null when the range is registered but unmounted
|
||||
* (e.g. scrolled out of viewport). Typed as `unknown` here because
|
||||
* the registry is dom-agnostic; hitTest casts as needed.
|
||||
*/
|
||||
domNode: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* A point in transcript-virtual space. Used as the anchor and focus of a
|
||||
* selection. Survives DOM unmount cycles — only depends on the registry,
|
||||
* which outlives any individual render.
|
||||
*/
|
||||
export type SelectionPoint =
|
||||
/**
|
||||
* 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). */
|
||||
| { kind: 'after-all' }
|
||||
/**
|
||||
* In a gap between ranges (blank row, chrome, prompt). The selection
|
||||
* snaps to the appropriate side of the gap based on drag direction;
|
||||
* both adjacents are tracked so extendSelection / toCopyText can pick
|
||||
* the right side. Either side may be null at the document edges.
|
||||
*/
|
||||
| { kind: 'gap'; afterRangeId: RangeId | null; beforeRangeId: RangeId | null }
|
||||
|
||||
/** Snapshot of a transcript message minimally needed by toCopyText. */
|
||||
export type MsgSnapshot = {
|
||||
/** Stable id matching what each SourceRange records. */
|
||||
readonly id: string
|
||||
/**
|
||||
* Insertion-order index. Used to order ranges from different messages
|
||||
* when assembling copy text. The transcript array index serves directly.
|
||||
*/
|
||||
readonly order: number
|
||||
}
|
||||
19
ui-tui/src/types/hermes-ink.d.ts
vendored
19
ui-tui/src/types/hermes-ink.d.ts
vendored
@@ -76,6 +76,20 @@ declare module '@hermes/ink' {
|
||||
readonly cleanup: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Live Ink instance reference passed to copy-text overrides + exposed
|
||||
* by `useInkInstance()`. Surfaces the minimum API needed for
|
||||
* transcript-virtual selection/copy.
|
||||
*/
|
||||
export type InkInstance = {
|
||||
readonly setCopyTextFn: (fn: ((self: InkInstance) => string) | null) => void
|
||||
readonly getRootDom: () => unknown
|
||||
readonly getSelectionBoundsScreen: () =>
|
||||
| { readonly start: { readonly col: number; readonly row: number }; readonly end: { readonly col: number; readonly row: number } }
|
||||
| null
|
||||
readonly hasTextSelection: () => boolean
|
||||
}
|
||||
|
||||
export type ScrollBoxHandle = {
|
||||
readonly scrollTo: (y: number) => void
|
||||
readonly scrollBy: (dy: number) => void
|
||||
@@ -134,6 +148,11 @@ declare module '@hermes/ink' {
|
||||
export function evictInkCaches(level?: EvictLevel): InkCacheSizes
|
||||
|
||||
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; 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
|
||||
|
||||
export function useApp(): { readonly exit: (error?: Error) => void }
|
||||
|
||||
Reference in New Issue
Block a user