diff --git a/ui-tui/packages/hermes-ink/src/ink/screen.ts b/ui-tui/packages/hermes-ink/src/ink/screen.ts index 9dea201329..6916b8598e 100644 --- a/ui-tui/packages/hermes-ink/src/ink/screen.ts +++ b/ui-tui/packages/hermes-ink/src/ink/screen.ts @@ -436,6 +436,13 @@ export type Screen = Size & { */ noSelect: Uint8Array + /** + * Per-cell written bitmap. A written plain space and never-written padding + * share the same packed cell value, so selection needs this side channel to + * preserve code indentation without selecting blank UI margins. + */ + written: Uint8Array + /** * Per-ROW soft-wrap continuation marker. softWrap[r]=N>0 means row r * is a word-wrap continuation of row r-1 (the `\n` before it was @@ -475,6 +482,14 @@ export function isEmptyCellAt(screen: Screen, x: number, y: number): boolean { return isEmptyCellByIndex(screen, y * screen.width + x) } +export function isWrittenCellAt(screen: Screen, x: number, y: number): boolean { + if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) { + return false + } + + return screen.written[y * screen.width + x] === 1 +} + /** * Check if a Cell (view object) represents an empty cell. */ @@ -533,6 +548,7 @@ export function createScreen( emptyStyleId: styles.none, damage: undefined, noSelect: new Uint8Array(size), + written: new Uint8Array(size), softWrap: new Int32Array(height) } } @@ -566,6 +582,7 @@ export function resetScreen(screen: Screen, width: number, height: number): void screen.cells = new Int32Array(buf) screen.cells64 = new BigInt64Array(buf) screen.noSelect = new Uint8Array(size) + screen.written = new Uint8Array(size) } if (screen.softWrap.length < height) { @@ -575,6 +592,7 @@ export function resetScreen(screen: Screen, width: number, height: number): void // Reset all cells — single fill call, no loop screen.cells64.fill(EMPTY_CELL_VALUE, 0, size) screen.noSelect.fill(0, 0, size) + screen.written.fill(0, 0, size) screen.softWrap.fill(0, 0, height) // Update dimensions @@ -770,6 +788,7 @@ export function setCellAt(screen: Screen, x: number, y: number, cell: Cell): voi if ((cells[spacerCI + 1]! & WIDTH_MASK) === CellWidth.SpacerTail) { cells[spacerCI] = EMPTY_CHAR_INDEX cells[spacerCI + 1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow) + screen.written[y * screen.width + spacerX] = 0 } } } @@ -787,6 +806,7 @@ export function setCellAt(screen: Screen, x: number, y: number, cell: Cell): voi if ((cells[wideCI + 1]! & WIDTH_MASK) === CellWidth.Wide) { cells[wideCI] = EMPTY_CHAR_INDEX cells[wideCI + 1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow) + screen.written[y * screen.width + x - 1] = 0 clearedWideX = x - 1 } } @@ -795,6 +815,7 @@ export function setCellAt(screen: Screen, x: number, y: number, cell: Cell): voi // Pack cell data into cells array cells[ci] = internCharString(screen, cell.char) cells[ci + 1] = packWord1(cell.styleId, internHyperlink(screen, cell.hyperlink), cell.width) + screen.written[y * screen.width + x] = 1 // Track damage - expand bounds in place instead of allocating new objects // Include the main cell position and any cleared orphan cells @@ -841,11 +862,13 @@ export function setCellAt(screen: Screen, x: number, y: number, cell: Cell): voi if (spacerX + 1 < screen.width && (cells[orphanCI + 1]! & WIDTH_MASK) === CellWidth.SpacerTail) { cells[orphanCI] = EMPTY_CHAR_INDEX cells[orphanCI + 1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow) + screen.written[y * screen.width + spacerX + 1] = 0 } } cells[spacerCI] = SPACER_CHAR_INDEX cells[spacerCI + 1] = packWord1(screen.emptyStyleId, 0, CellWidth.SpacerTail) + screen.written[y * screen.width + spacerX] = 1 // Expand damage to include SpacerTail so diff() scans it const d = screen.damage @@ -929,6 +952,8 @@ export function blitRegion( const dstCells = dst.cells const srcNoSel = src.noSelect const dstNoSel = dst.noSelect + const srcWritten = src.written + const dstWritten = dst.written // softWrap is per-row — copy the row range regardless of stride/width. // Partial-width blits still carry the row's wrap provenance since the @@ -947,6 +972,7 @@ export function blitRegion( const nsStart = regionY * src.width const nsLen = (maxY - regionY) * src.width dstNoSel.set(srcNoSel.subarray(nsStart, nsStart + nsLen), nsStart) + dstWritten.set(srcWritten.subarray(nsStart, nsStart + nsLen), nsStart) } else { // Per-row copy for partial-width or mismatched-stride regions let srcRowCI = regionY * srcStride + (regionX << 1) @@ -957,6 +983,7 @@ export function blitRegion( for (let y = regionY; y < maxY; y++) { dstCells.set(srcCells.subarray(srcRowCI, srcRowCI + rowBytes), dstRowCI) dstNoSel.set(srcNoSel.subarray(srcRowNS, srcRowNS + rowLen), dstRowNS) + dstWritten.set(srcWritten.subarray(srcRowNS, srcRowNS + rowLen), dstRowNS) srcRowCI += srcStride dstRowCI += dstStride srcRowNS += src.width @@ -989,6 +1016,7 @@ export function blitRegion( if ((srcCells[srcLastCI + 1]! & WIDTH_MASK) === CellWidth.Wide) { dstCells[dstSpacerCI] = SPACER_CHAR_INDEX dstCells[dstSpacerCI + 1] = packWord1(dst.emptyStyleId, 0, CellWidth.SpacerTail) + dstWritten[y * dst.width + maxX] = 1 wroteSpacerOutsideRegion = true } @@ -1030,6 +1058,7 @@ export function clearRegion( const cells = screen.cells const cells64 = screen.cells64 + const written = screen.written const screenWidth = screen.width const rowBase = startY * screenWidth let damageMinX = startX @@ -1040,6 +1069,7 @@ export function clearRegion( if (startX === 0 && maxX === screenWidth) { // Full-width: single fill, no boundary checks needed cells64.fill(EMPTY_CELL_VALUE, rowBase, rowBase + (maxY - startY) * screenWidth) + written.fill(0, rowBase, rowBase + (maxY - startY) * screenWidth) } else { // Partial-width: single loop handles boundary cleanup and fill per row. const stride = screenWidth << 1 // 2 Int32s per cell @@ -1062,6 +1092,7 @@ export function clearRegion( if ((cells[prevW1]! & WIDTH_MASK) === CellWidth.Wide) { cells[prevW1 - 1] = EMPTY_CHAR_INDEX cells[prevW1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow) + written[y * screenWidth + startX - 1] = 0 damageMinX = startX - 1 } } @@ -1078,12 +1109,14 @@ export function clearRegion( if ((cells[nextW1]! & WIDTH_MASK) === CellWidth.SpacerTail) { cells[nextW1 - 1] = EMPTY_CHAR_INDEX cells[nextW1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow) + written[y * screenWidth + maxX] = 0 damageMaxX = maxX + 1 } } } cells64.fill(EMPTY_CELL_VALUE, fillStart, fillStart + rowLen) + written.fill(0, fillStart, fillStart + rowLen) leftEdge += stride rightEdge += stride fillStart += screenWidth @@ -1120,12 +1153,14 @@ export function shiftRows(screen: Screen, top: number, bottom: number, n: number const w = screen.width const cells64 = screen.cells64 const noSel = screen.noSelect + const written = screen.written const sw = screen.softWrap const absN = Math.abs(n) if (absN > bottom - top) { cells64.fill(EMPTY_CELL_VALUE, top * w, (bottom + 1) * w) noSel.fill(0, top * w, (bottom + 1) * w) + written.fill(0, top * w, (bottom + 1) * w) sw.fill(0, top, bottom + 1) return @@ -1135,17 +1170,21 @@ export function shiftRows(screen: Screen, top: number, bottom: number, n: number // SU: row top+n..bottom → top..bottom-n; clear bottom-n+1..bottom cells64.copyWithin(top * w, (top + n) * w, (bottom + 1) * w) noSel.copyWithin(top * w, (top + n) * w, (bottom + 1) * w) + written.copyWithin(top * w, (top + n) * w, (bottom + 1) * w) sw.copyWithin(top, top + n, bottom + 1) cells64.fill(EMPTY_CELL_VALUE, (bottom - n + 1) * w, (bottom + 1) * w) noSel.fill(0, (bottom - n + 1) * w, (bottom + 1) * w) + written.fill(0, (bottom - n + 1) * w, (bottom + 1) * w) sw.fill(0, bottom - n + 1, bottom + 1) } else { // SD: row top..bottom+n → top-n..bottom; clear top..top-n-1 cells64.copyWithin((top - n) * w, top * w, (bottom + n + 1) * w) noSel.copyWithin((top - n) * w, top * w, (bottom + n + 1) * w) + written.copyWithin((top - n) * w, top * w, (bottom + n + 1) * w) sw.copyWithin(top - n, top, bottom + n + 1) cells64.fill(EMPTY_CELL_VALUE, top * w, (top - n) * w) noSel.fill(0, top * w, (top - n) * w) + written.fill(0, top * w, (top - n) * w) sw.fill(0, top, top - n) } } diff --git a/ui-tui/packages/hermes-ink/src/ink/selection.test.ts b/ui-tui/packages/hermes-ink/src/ink/selection.test.ts index 97676a7313..0e0fba6573 100644 --- a/ui-tui/packages/hermes-ink/src/ink/selection.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/selection.test.ts @@ -19,8 +19,6 @@ const screenWithText = () => { return { screen, styles } } -const styledSpace = (styles: StylePool) => styles.intern([{ code: '\x1b[4m', endCode: '\x1b[24m', type: 'ansi' }]) - describe('selection whitespace handling', () => { it('does not copy whitespace-only selections', () => { const { screen } = screenWithText() @@ -45,11 +43,10 @@ describe('selection whitespace handling', () => { it('preserves selected indentation when spaces are rendered content', () => { const styles = new StylePool() const screen = createScreen(10, 1, styles, new CharPool(), new HyperlinkPool()) - const indentStyle = styledSpace(styles) const selection = createSelectionState() - setCellAt(screen, 0, 0, { char: ' ', hyperlink: undefined, styleId: indentStyle, width: CellWidth.Narrow }) - setCellAt(screen, 1, 0, { char: ' ', hyperlink: undefined, styleId: indentStyle, width: CellWidth.Narrow }) + setCellAt(screen, 0, 0, { char: ' ', hyperlink: undefined, styleId: screen.emptyStyleId, width: CellWidth.Narrow }) + setCellAt(screen, 1, 0, { char: ' ', hyperlink: undefined, styleId: screen.emptyStyleId, width: CellWidth.Narrow }) setCellAt(screen, 2, 0, { char: 'x', hyperlink: undefined, styleId: screen.emptyStyleId, width: CellWidth.Narrow }) startSelection(selection, 0, 0) diff --git a/ui-tui/packages/hermes-ink/src/ink/selection.ts b/ui-tui/packages/hermes-ink/src/ink/selection.ts index ff3260be4b..7c7505540b 100644 --- a/ui-tui/packages/hermes-ink/src/ink/selection.ts +++ b/ui-tui/packages/hermes-ink/src/ink/selection.ts @@ -12,7 +12,7 @@ import { clamp } from './layout/geometry.js' import type { Screen, StylePool } from './screen.js' -import { cellAt, cellAtIndex, CellWidth, isEmptyCellAt, setCellStyleId } from './screen.js' +import { cellAt, cellAtIndex, CellWidth, isWrittenCellAt, setCellStyleId } from './screen.js' type Point = { col: number; row: number } @@ -847,7 +847,7 @@ function selectableCell(screen: Screen, row: number, col: number): boolean { return ( screen.noSelect[row * screen.width + col] !== 1 && - !isEmptyCellAt(screen, col, row) && + isWrittenCellAt(screen, col, row) && !!cell && cell.width !== CellWidth.SpacerTail && cell.width !== CellWidth.SpacerHead diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index e3ef7bb3b7..3fd1b494ac 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -114,25 +114,6 @@ const renderTable = (k: number, rows: string[][], t: Theme) => { ) } -const codeText = (text: string, color: string | undefined, key: number | string, t: Theme) => ( - - {text} - -) - -const plainCodeLine = (text: string, t: Theme) => { - const indent = text.match(/^\s+/)?.[0] ?? '' - - return indent ? ( - <> - {codeText(indent, undefined, 'indent', t)} - {text.slice(indent.length)} - - ) : ( - text - ) -} - function MdInline({ t, text }: { t: Theme; text: string }) { const parts: ReactNode[] = [] @@ -335,7 +316,19 @@ function MdImpl({ compact, t, text }: MdProps) { {block.map((l, j) => { if (highlighted) { - return {highlightLine(l, lang, t).map(([color, text], kk) => codeText(text, color, kk, t))} + return ( + + {highlightLine(l, lang, t).map(([color, text], kk) => + color ? ( + + {text} + + ) : ( + {text} + ) + )} + + ) } const add = isDiff && l.startsWith('+') @@ -349,7 +342,7 @@ function MdImpl({ compact, t, text }: MdProps) { dimColor={isDiff && !add && !del && !hunk && l.startsWith(' ')} key={j} > - {plainCodeLine(l, t)} + {l} ) })}