mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
fix(tui): preserve rendered indentation in selections
- trim only empty edge rows instead of full selected text - bound selection paint using unwritten cells so rendered indentation remains copyable
This commit is contained in:
parent
132620ba3d
commit
bba16943f6
2 changed files with 72 additions and 32 deletions
|
|
@ -19,6 +19,8 @@ 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()
|
||||
|
|
@ -40,6 +42,22 @@ describe('selection whitespace handling', () => {
|
|||
expect(getSelectedText(selection, screen)).toBe('hi')
|
||||
})
|
||||
|
||||
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, 2, 0, { char: 'x', hyperlink: undefined, styleId: screen.emptyStyleId, width: CellWidth.Narrow })
|
||||
|
||||
startSelection(selection, 0, 0)
|
||||
updateSelection(selection, 9, 0)
|
||||
|
||||
expect(getSelectedText(selection, screen)).toBe(' x')
|
||||
})
|
||||
|
||||
it('does not paint selection background on leading/trailing empty cells or empty rows', () => {
|
||||
const { screen, styles } = screenWithText()
|
||||
const selection = createSelectionState()
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
|
||||
import { clamp } from './layout/geometry.js'
|
||||
import type { Screen, StylePool } from './screen.js'
|
||||
import { cellAt, cellAtIndex, CellWidth, setCellStyleId } from './screen.js'
|
||||
import { cellAt, cellAtIndex, CellWidth, isEmptyCellAt, setCellStyleId } from './screen.js'
|
||||
|
||||
type Point = { col: number; row: number }
|
||||
|
||||
|
|
@ -842,31 +842,41 @@ export function isCellSelected(s: SelectionState, col: number, row: number): boo
|
|||
return true
|
||||
}
|
||||
|
||||
function rowSelectableContentBounds(screen: Screen, row: number): { first: number; last: number } | null {
|
||||
if (row < 0 || row >= screen.height) {
|
||||
function selectableCell(screen: Screen, row: number, col: number): boolean {
|
||||
const cell = cellAt(screen, col, row)
|
||||
|
||||
return (
|
||||
screen.noSelect[row * screen.width + col] !== 1 &&
|
||||
!isEmptyCellAt(screen, col, row) &&
|
||||
!!cell &&
|
||||
cell.width !== CellWidth.SpacerTail &&
|
||||
cell.width !== CellWidth.SpacerHead
|
||||
)
|
||||
}
|
||||
|
||||
function selectionContentBounds(
|
||||
screen: Screen,
|
||||
row: number,
|
||||
start: number,
|
||||
end: number
|
||||
): { first: number; last: number } | null {
|
||||
let first = start
|
||||
|
||||
while (first <= end && !selectableCell(screen, row, first)) {
|
||||
first++
|
||||
}
|
||||
|
||||
if (first > end) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rowOff = row * screen.width
|
||||
let first = -1
|
||||
let last = -1
|
||||
let last = end
|
||||
|
||||
for (let col = 0; col < screen.width; col++) {
|
||||
if (screen.noSelect[rowOff + col] === 1) {
|
||||
continue
|
||||
while (last >= first && !selectableCell(screen, row, last)) {
|
||||
last--
|
||||
}
|
||||
|
||||
const cell = cellAt(screen, col, row)
|
||||
|
||||
if (!cell || cell.width === CellWidth.SpacerTail || cell.width === CellWidth.SpacerHead || !cell.char.trim()) {
|
||||
continue
|
||||
}
|
||||
|
||||
first = first === -1 ? col : first
|
||||
last = col
|
||||
}
|
||||
|
||||
return first === -1 ? null : { first, last }
|
||||
return { first, last }
|
||||
}
|
||||
|
||||
/** Extract text from one screen row. When the next row is a soft-wrap
|
||||
|
|
@ -917,6 +927,21 @@ function joinRows(lines: string[], text: string, sw: boolean | undefined): void
|
|||
}
|
||||
}
|
||||
|
||||
function trimEmptyEdgeRows(lines: string[]): string[] {
|
||||
let start = 0
|
||||
let end = lines.length
|
||||
|
||||
while (start < end && !lines[start]!.trim()) {
|
||||
start++
|
||||
}
|
||||
|
||||
while (end > start && !lines[end - 1]!.trim()) {
|
||||
end--
|
||||
}
|
||||
|
||||
return lines.slice(start, end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from the screen buffer within the selection range.
|
||||
* Rows are joined with newlines unless the screen's softWrap bitmap
|
||||
|
|
@ -946,14 +971,16 @@ export function getSelectedText(s: SelectionState, screen: Screen): string {
|
|||
for (let row = start.row; row <= end.row; row++) {
|
||||
const rowStart = row === start.row ? start.col : 0
|
||||
const rowEnd = row === end.row ? end.col : screen.width - 1
|
||||
joinRows(lines, extractRowText(screen, row, rowStart, rowEnd), sw[row]! > 0)
|
||||
const bounds = selectionContentBounds(screen, row, rowStart, rowEnd)
|
||||
|
||||
joinRows(lines, bounds ? extractRowText(screen, row, bounds.first, bounds.last) : '', sw[row]! > 0)
|
||||
}
|
||||
|
||||
for (let i = 0; i < s.scrolledOffBelow.length; i++) {
|
||||
joinRows(lines, s.scrolledOffBelow[i]!, s.scrolledOffBelowSW[i])
|
||||
}
|
||||
|
||||
return lines.join('\n').trim()
|
||||
return trimEmptyEdgeRows(lines).join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1076,21 +1103,16 @@ export function applySelectionOverlay(screen: Screen, selection: SelectionState,
|
|||
const noSelect = screen.noSelect
|
||||
|
||||
for (let row = start.row; row <= end.row && row < screen.height; row++) {
|
||||
const bounds = rowSelectableContentBounds(screen, row)
|
||||
const colStart = row === start.row ? start.col : 0
|
||||
const colEnd = row === end.row ? Math.min(end.col, width - 1) : width - 1
|
||||
const bounds = selectionContentBounds(screen, row, colStart, colEnd)
|
||||
const rowOff = row * width
|
||||
|
||||
if (!bounds) {
|
||||
continue
|
||||
}
|
||||
|
||||
const colStart = Math.max(row === start.row ? start.col : 0, bounds.first)
|
||||
const colEnd = Math.min(row === end.row ? end.col : width - 1, bounds.last)
|
||||
const rowOff = row * width
|
||||
|
||||
if (colStart > colEnd) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (let col = colStart; col <= colEnd; col++) {
|
||||
for (let col = bounds.first; col <= bounds.last; col++) {
|
||||
const idx = rowOff + col
|
||||
|
||||
// Skip noSelect cells — gutters stay visually unchanged so it's
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue