diff --git a/ui-tui/packages/hermes-ink/src/ink/selection-copy-source.test.ts b/ui-tui/packages/hermes-ink/src/ink/selection-copy-source.test.ts index ebcf8b551d..c16f043bb2 100644 --- a/ui-tui/packages/hermes-ink/src/ink/selection-copy-source.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/selection-copy-source.test.ts @@ -269,4 +269,138 @@ describe('getSelectedText copy-source override', () => { // Region's row 1 is outside selection → partial → fall back to cells. expect(getSelectedText(sel, screen)).toBe('abc') }) + + // ── Nested regions (msg-level + per-block) ── + // The TUI wraps each message in AND each + // markdown block in . Children render + // AFTER parents in render-node-to-output, so child cells overwrite the + // parent's copySource ID — only padding/gaps keep the parent ID. The + // parent's bounding rect (computed from its remaining cells) still + // approximates its visual extent so containment-based shadowing works. + it('emits only the outer source when both outer and inner regions are fully covered', () => { + const styles = new StylePool() + const charPool = new CharPool() + const copySourcePool = new CopySourcePool() + const screen = createScreen(20, 4, styles, charPool, new HyperlinkPool(), copySourcePool) + + for (let i = 0; i < 5; i++) { + setCellAt(screen, 2 + i, 1, { + char: 'hello'[i]!, + hyperlink: undefined, + styleId: screen.emptyStyleId, + width: CellWidth.Narrow + }) + } + + // Outer: msg-level wrapper covers the whole screen rect. + const outerId = copySourcePool.intern('# msg context\n\n**hello**\n\nmore msg') + markCopySourceRegion(screen, 0, 0, 20, 4, outerId) + + // Inner: block-level wrapper for "hello", overwrites outer cells in + // its rect — same order as render-node-to-output (parent first). + const innerId = copySourcePool.intern('**hello**') + markCopySourceRegion(screen, 2, 1, 5, 1, innerId) + + const sel = createSelectionState() + startSelection(sel, 0, 0) + updateSelection(sel, 19, 3) + + // Both fully covered, but outer strictly contains inner → emit outer only. + expect(getSelectedText(sel, screen)).toBe('# msg context\n\n**hello**\n\nmore msg') + }) + + it('emits only the inner source when the selection covers just one block', () => { + const styles = new StylePool() + const charPool = new CharPool() + const copySourcePool = new CopySourcePool() + const screen = createScreen(20, 4, styles, charPool, new HyperlinkPool(), copySourcePool) + + // Two "blocks" rendered as plain text, both within an outer msg region. + for (let i = 0; i < 5; i++) { + setCellAt(screen, i, 0, { + char: 'hello'[i]!, + hyperlink: undefined, + styleId: screen.emptyStyleId, + width: CellWidth.Narrow + }) + } + + for (let i = 0; i < 5; i++) { + setCellAt(screen, i, 2, { + char: 'world'[i]!, + hyperlink: undefined, + styleId: screen.emptyStyleId, + width: CellWidth.Narrow + }) + } + + // Outer covers rows 0..3 (incl. gap rows 1, 3). + const outerId = copySourcePool.intern('**hello**\n\n*world*') + markCopySourceRegion(screen, 0, 0, 20, 4, outerId) + + // Inner blocks + const helloId = copySourcePool.intern('**hello**') + const worldId = copySourcePool.intern('*world*') + markCopySourceRegion(screen, 0, 0, 5, 1, helloId) + markCopySourceRegion(screen, 0, 2, 5, 1, worldId) + + // Selection covers only the "hello" block on row 0. + const sel = createSelectionState() + startSelection(sel, 0, 0) + updateSelection(sel, 4, 0) + + // helloId fully covered (its on-screen cells all in the selection). + // outerId NOT fully covered (rows 1-3 outside selection). + // worldId NOT fully covered (its row outside selection). + expect(getSelectedText(sel, screen)).toBe('**hello**') + }) + + it('emits multiple inner blocks when outer is partially selected but inners are fully covered', () => { + const styles = new StylePool() + const charPool = new CharPool() + const copySourcePool = new CopySourcePool() + const screen = createScreen(20, 5, styles, charPool, new HyperlinkPool(), copySourcePool) + + // Three blocks on rows 0, 2, 4 — outer covers rows 0..4 + for (const row of [0, 2, 4]) { + for (let i = 0; i < 3; i++) { + setCellAt(screen, i, row, { + char: 'abc'[i]!, + hyperlink: undefined, + styleId: screen.emptyStyleId, + width: CellWidth.Narrow + }) + } + } + + const outerId = copySourcePool.intern('OUTER') + markCopySourceRegion(screen, 0, 0, 20, 5, outerId) + + const block1Id = copySourcePool.intern('# h1') + const block2Id = copySourcePool.intern('# h2') + const block3Id = copySourcePool.intern('# h3') + markCopySourceRegion(screen, 0, 0, 3, 1, block1Id) + markCopySourceRegion(screen, 0, 2, 3, 1, block2Id) + markCopySourceRegion(screen, 0, 4, 3, 1, block3Id) + + // Selection covers rows 0..2 → blocks 1 and 2 fully covered; outer NOT + // (row 4 cells outside selection); block3 NOT (row 4 outside). + const sel = createSelectionState() + startSelection(sel, 0, 0) + updateSelection(sel, 19, 2) + + expect(getSelectedText(sel, screen)).toBe('# h1\n# h2') + }) + + it('keeps the single fully-covered region (no shadowing partner exists)', () => { + // Sanity: containment filter must be a no-op when there's only one + // fully-covered region. + const { screen } = screenWithCopySource('hi', '# hi heading') + + const sel = createSelectionState() + startSelection(sel, 0, 0) + updateSelection(sel, 19, 0) + + expect(getSelectedText(sel, screen)).toBe('# hi heading') + }) }) diff --git a/ui-tui/packages/hermes-ink/src/ink/selection.ts b/ui-tui/packages/hermes-ink/src/ink/selection.ts index 66b02653ae..bb8952b368 100644 --- a/ui-tui/packages/hermes-ink/src/ink/selection.ts +++ b/ui-tui/packages/hermes-ink/src/ink/selection.ts @@ -998,7 +998,13 @@ export function getSelectedText(s: SelectionState, screen: Screen): string { // the selection rect — only then is it safe to substitute the source // string (a partial selection has no obvious sub-mapping from rendered // cells back to source characters; v1 punts on that case). - const fullyCovered = computeFullyCoveredCopySources(screen, start, end) + // + // `emit`: regions whose source we'll write out. Excludes inner regions + // shadowed by an outer fully-covered region (parent-wins). + // `coveredAll`: the un-filtered set, including shadowed ones — used + // by the row-segment loop to know "this segment's cells are + // already accounted for by an outer emission, skip". + const { emit, coveredAll } = computeFullyCoveredCopySources(screen, start, end) // Track which copy-source IDs we've already emitted (one source string // covers many rows; we flush it once at the first row of the region in @@ -1034,8 +1040,11 @@ export function getSelectedText(s: SelectionState, screen: Screen): string { segEnd++ } - if (segId !== 0 && fullyCovered.has(segId)) { - if (!emittedIds.has(segId)) { + if (segId !== 0 && coveredAll.has(segId)) { + // This segment's cells are entirely covered by SOME fully-covered + // region. Emit our source IFF we're in the post-shadowing emit + // set; otherwise an outer region's emission already handles us. + if (emit.has(segId) && !emittedIds.has(segId)) { emittedIds.add(segId) const source = screen.copySourcePool.get(segId) @@ -1045,8 +1054,7 @@ export function getSelectedText(s: SelectionState, screen: Screen): string { joinRows(lines, source, false) } } - // else: already emitted — skip this segment, the earlier - // emission's source string already covers all rows of this region. + // else: shadowed inner OR already emitted — skip silently. } else { const bounds = selectionContentBounds(screen, row, segStart, segEnd) const segText = bounds ? extractRowText(screen, row, bounds.first, bounds.last) : '' @@ -1088,7 +1096,7 @@ function computeFullyCoveredCopySources( screen: Screen, start: { col: number; row: number }, end: { col: number; row: number } -): Set { +): { coveredAll: Set; emit: Set } { const cs = screen.copySources const w = screen.width const h = screen.height @@ -1113,7 +1121,7 @@ function computeFullyCoveredCopySources( } if (ids.size === 0) { - return ids + return { coveredAll: ids, emit: ids } } // Second pass: for each candidate ID, scan the WHOLE screen and find @@ -1179,7 +1187,50 @@ function computeFullyCoveredCopySources( fullyCovered.add(id) } - return fullyCovered + // Fourth pass: nested-region shadowing. When a parent + // wraps child regions, BOTH may end up fully covered + // when the selection spans the parent. Without filtering we'd emit + // parent.source AND every child.source — duplicating content. + // + // Drop a region from the EMIT set when ANOTHER fully-covered region's + // bounding rect strictly contains it (parent contains child, parent + // wins — its source already includes the child's text). Both regions + // remain in `coveredAll` so the row-segment loop knows a shadowed + // child's cells are accounted for by the parent (don't fall back to + // rendered cells when scanning across them). + if (fullyCovered.size > 1) { + const emit = new Set(fullyCovered) + + for (const innerId of fullyCovered) { + const inner = idBounds.get(innerId)! + + for (const outerId of fullyCovered) { + if (innerId === outerId) { + continue + } + + const outer = idBounds.get(outerId)! + const containsRow = outer.minRow <= inner.minRow && outer.maxRow >= inner.maxRow + const containsCol = outer.minCol <= inner.minCol && outer.maxCol >= inner.maxCol + + const strictlyLarger = + outer.minRow < inner.minRow || + outer.maxRow > inner.maxRow || + outer.minCol < inner.minCol || + outer.maxCol > inner.maxCol + + if (containsRow && containsCol && strictlyLarger) { + emit.delete(innerId) + + break + } + } + } + + return { coveredAll: fullyCovered, emit } + } + + return { coveredAll: fullyCovered, emit: fullyCovered } } /** diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index c12efb35dc..06d421fc0b 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -382,6 +382,18 @@ function MdImpl({ compact, t, text }: MdProps) { const lines = ensureEmojiPresentation(text).split('\n') const nodes: ReactNode[] = [] + // Parallel array: nodeRanges[k] = { start, end } means nodes[k] (and any + // contiguous run sharing the same range) was emitted by the block + // covering source lines [start..end). Used after the parse to wrap + // each block's rendered nodes in a so partial + // selections of an individual block (one paragraph, one heading, one + // code fence) round-trip the raw markdown for THAT block. + // + // Outer msg-level copySource on covers the whole + // message; the per-block wraps shadow it only when the selection + // covers a single block (see computeFullyCoveredCopySources's + // containment check). + const nodeRanges: Array<{ end: number; start: number } | null> = [] let prevKind: Kind = null let i = 0 @@ -402,8 +414,24 @@ function MdImpl({ compact, t, text }: MdProps) { } while (i < lines.length) { - const line = lines[i]! - const key = nodes.length + // Track this iteration's block range so we can wrap whatever it + // pushes in a covering the raw source lines. + // Selection that fully covers this block's rendered cells will + // copy the original markdown (asterisks, fences, headings, etc.) + // instead of the stripped render. Outer msg-level copySource on + // still wins for whole-message selections via the + // shadowing logic in getSelectedText. + const blockStart = i + const beforeCount = nodes.length + + // Labeled inner block: each branch's `break blockIter` exits to + // the wrap step below instead of skipping it (which a plain + // `continue` on the outer while would do). The branches still + // advance `i` themselves so the loop progresses normally. + + blockIter: { + const line = lines[i]! + const key = nodes.length if (!line.trim()) { if (!compact) { @@ -412,13 +440,13 @@ function MdImpl({ compact, t, text }: MdProps) { i++ - continue + break blockIter } if (AUDIO_DIRECTIVE_RE.test(line)) { i++ - continue + break blockIter } const media = line.match(MEDIA_LINE_RE)?.[1] @@ -438,7 +466,7 @@ function MdImpl({ compact, t, text }: MdProps) { ) i++ - continue + break blockIter } const fence = line.match(FENCE_RE) @@ -467,7 +495,7 @@ function MdImpl({ compact, t, text }: MdProps) { start('paragraph') nodes.push() - continue + break blockIter } start('code') @@ -514,7 +542,7 @@ function MdImpl({ compact, t, text }: MdProps) { ) - continue + break blockIter } const mathOpen = line.match(MATH_BLOCK_OPEN_RE) @@ -542,7 +570,7 @@ function MdImpl({ compact, t, text }: MdProps) { ) i++ - continue + break blockIter } // Multi-line block: scan ahead for a real closer before committing. @@ -563,7 +591,7 @@ function MdImpl({ compact, t, text }: MdProps) { nodes.push() i++ - continue + break blockIter } if (headRest.trim()) { @@ -592,7 +620,7 @@ function MdImpl({ compact, t, text }: MdProps) { ) i = closeIdx + 1 - continue + break blockIter } const heading = line.match(HEADING_RE)?.[2] @@ -606,7 +634,7 @@ function MdImpl({ compact, t, text }: MdProps) { ) i++ - continue + break blockIter } if (i + 1 < lines.length && SETEXT_RE.test(lines[i + 1]!)) { @@ -618,7 +646,7 @@ function MdImpl({ compact, t, text }: MdProps) { ) i += 2 - continue + break blockIter } if (HR_RE.test(line)) { @@ -630,7 +658,7 @@ function MdImpl({ compact, t, text }: MdProps) { ) i++ - continue + break blockIter } const footnote = line.match(FOOTNOTE_RE) @@ -655,7 +683,7 @@ function MdImpl({ compact, t, text }: MdProps) { i++ } - continue + break blockIter } if (i + 1 < lines.length && DEF_RE.test(lines[i + 1]!)) { @@ -683,7 +711,7 @@ function MdImpl({ compact, t, text }: MdProps) { i++ } - continue + break blockIter } const bullet = line.match(BULLET_RE) @@ -704,7 +732,7 @@ function MdImpl({ compact, t, text }: MdProps) { ) i++ - continue + break blockIter } const numbered = line.match(NUMBERED_RE) @@ -721,7 +749,7 @@ function MdImpl({ compact, t, text }: MdProps) { ) i++ - continue + break blockIter } if (QUOTE_RE.test(line)) { @@ -748,7 +776,7 @@ function MdImpl({ compact, t, text }: MdProps) { ) - continue + break blockIter } if (line.includes('|') && i + 1 < lines.length && isTableDivider(lines[i + 1]!)) { @@ -762,13 +790,13 @@ function MdImpl({ compact, t, text }: MdProps) { nodes.push(renderTable(key, rows, t)) - continue + break blockIter } if (/^<\/?details\b/i.test(line)) { i++ - continue + break blockIter } const summary = line.match(/^(.*?)<\/summary>$/i)?.[1] @@ -782,7 +810,7 @@ function MdImpl({ compact, t, text }: MdProps) { ) i++ - continue + break blockIter } if (/^<\/?[^>]+>$/.test(line.trim())) { @@ -794,7 +822,7 @@ function MdImpl({ compact, t, text }: MdProps) { ) i++ - continue + break blockIter } if (line.includes('|') && line.trim().startsWith('|')) { @@ -816,17 +844,68 @@ function MdImpl({ compact, t, text }: MdProps) { nodes.push(renderTable(key, rows, t)) } - continue + break blockIter } start('paragraph') nodes.push() i++ + } + // End blockIter: the body either fell through (final paragraph + // case above advanced i and pushed) or `break blockIter`'d out + // of an explicit branch. Either way `i` is now past this block. + + // Wrap step: every node pushed during this iteration belongs to + // the same block (lines [blockStart, i)). Record the range — the + // post-loop pass will splice each block's nodes into a + // wrapper. + const blockEnd = i + const range = blockEnd > blockStart ? { end: blockEnd, start: blockStart } : null + + for (let k = beforeCount; k < nodes.length; k++) { + nodeRanges[k] = range + } } - cacheSet(bucket, cacheKey, nodes) + // Post-process: group consecutive nodes with the same block range + // and wrap each group in a . + // Nodes with no range (gap pushes) stay flat. + const wrapped: ReactNode[] = [] + let groupStart = 0 - return nodes + while (groupStart < nodes.length) { + const range = nodeRanges[groupStart] ?? null + + if (!range) { + wrapped.push(nodes[groupStart]!) + groupStart++ + + continue + } + + let groupEnd = groupStart + 1 + + while (groupEnd < nodes.length && nodeRanges[groupEnd] === range) { + groupEnd++ + } + + const blockSource = lines.slice(range.start, range.end).join('\n') + const groupNodes = nodes.slice(groupStart, groupEnd) + // Single-node groups skip the extra Box layer when possible — but + // we still need a wrapper to carry copySource. flexDirection= + // 'column' matches the parent Box at line ~860 so layout stays + // identical to the unwrapped version. + wrapped.push( + + {groupNodes} + + ) + groupStart = groupEnd + } + + cacheSet(bucket, cacheKey, wrapped) + + return wrapped }, [compact, t, text]) return {nodes}