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}
)
})}