diff --git a/ui-tui/packages/hermes-ink/src/ink/selection.test.ts b/ui-tui/packages/hermes-ink/src/ink/selection.test.ts new file mode 100644 index 0000000000..97f7815db2 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/selection.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' + +import { cellAt, CellWidth, CharPool, createScreen, HyperlinkPool, setCellAt, StylePool } from './screen.js' +import { + applySelectionOverlay, + createSelectionState, + getSelectedText, + startSelection, + updateSelection +} from './selection.js' + +const screenWithText = () => { + const styles = new StylePool() + const screen = createScreen(10, 3, styles, new CharPool(), new HyperlinkPool()) + + setCellAt(screen, 2, 1, { char: 'h', hyperlink: undefined, styleId: screen.emptyStyleId, width: CellWidth.Narrow }) + setCellAt(screen, 3, 1, { char: 'i', hyperlink: undefined, styleId: screen.emptyStyleId, width: CellWidth.Narrow }) + + return { screen, styles } +} + +describe('selection whitespace handling', () => { + it('does not copy whitespace-only selections', () => { + const { screen } = screenWithText() + const selection = createSelectionState() + + startSelection(selection, 0, 0) + updateSelection(selection, 9, 0) + + expect(getSelectedText(selection, screen)).toBe('') + }) + + it('trims outer drag padding while preserving selected content', () => { + const { screen } = screenWithText() + const selection = createSelectionState() + + startSelection(selection, 0, 1) + updateSelection(selection, 9, 1) + + expect(getSelectedText(selection, screen)).toBe('hi') + }) + + it('does not paint selection background on leading/trailing empty cells or empty rows', () => { + const { screen, styles } = screenWithText() + const selection = createSelectionState() + + startSelection(selection, 0, 0) + updateSelection(selection, 9, 2) + applySelectionOverlay(screen, selection, styles) + + expect(cellAt(screen, 0, 0)?.styleId).toBe(screen.emptyStyleId) + expect(cellAt(screen, 0, 1)?.styleId).toBe(screen.emptyStyleId) + expect(cellAt(screen, 2, 1)?.styleId).not.toBe(screen.emptyStyleId) + expect(cellAt(screen, 4, 1)?.styleId).toBe(screen.emptyStyleId) + expect(cellAt(screen, 0, 2)?.styleId).toBe(screen.emptyStyleId) + }) +}) diff --git a/ui-tui/packages/hermes-ink/src/ink/selection.ts b/ui-tui/packages/hermes-ink/src/ink/selection.ts index 9ee71564e6..3834a97315 100644 --- a/ui-tui/packages/hermes-ink/src/ink/selection.ts +++ b/ui-tui/packages/hermes-ink/src/ink/selection.ts @@ -842,6 +842,33 @@ 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) { + return null + } + + const rowOff = row * screen.width + let first = -1 + let last = -1 + + for (let col = 0; col < screen.width; col++) { + if (screen.noSelect[rowOff + col] === 1) { + continue + } + + 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 } +} + /** Extract text from one screen row. When the next row is a soft-wrap * continuation (screen.softWrap[row+1]>0), clamp to that content-end * column and skip the trailing trim so the word-separator space survives @@ -926,7 +953,7 @@ export function getSelectedText(s: SelectionState, screen: Screen): string { joinRows(lines, s.scrolledOffBelow[i]!, s.scrolledOffBelowSW[i]) } - return lines.join('\n') + return lines.join('\n').trim() } /** @@ -1049,10 +1076,20 @@ export function applySelectionOverlay(screen: Screen, selection: SelectionState, const noSelect = screen.noSelect for (let row = start.row; row <= end.row && row < screen.height; row++) { - const colStart = row === start.row ? start.col : 0 - const colEnd = row === end.row ? Math.min(end.col, width - 1) : width - 1 + const bounds = rowSelectableContentBounds(screen, row) + + 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++) { const idx = rowOff + col diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 518eb668a5..7ab64be99e 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -269,7 +269,6 @@ export const coreCommands: SlashCommand[] = [ } writeOsc52Clipboard(target.text) - sys(`copied ${target.text.length} chars`) } }, diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 6125d929fb..d7ac30d932 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -30,11 +30,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { const copySelection = () => { // ink's copySelection() already calls setClipboard() which handles // pbcopy (macOS), wl-copy/xclip (Linux), tmux, and OSC 52 fallback. - const text = terminal.selection.copySelection() - - if (text) { - actions.sys(`copied ${text.length} chars`) - } + terminal.selection.copySelection() } const clearSelection = () => {