mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
fix(tui): trim whitespace-only selection chrome
- clamp selection highlight to real row content so blank drag margins do not render or copy - keep successful copy actions quiet while preserving usage and failure feedback
This commit is contained in:
parent
a68793b6c4
commit
876bb60044
4 changed files with 98 additions and 9 deletions
57
ui-tui/packages/hermes-ink/src/ink/selection.test.ts
Normal file
57
ui-tui/packages/hermes-ink/src/ink/selection.test.ts
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -842,6 +842,33 @@ export function isCellSelected(s: SelectionState, col: number, row: number): boo
|
||||||
return true
|
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
|
/** 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
|
* continuation (screen.softWrap[row+1]>0), clamp to that content-end
|
||||||
* column and skip the trailing trim so the word-separator space survives
|
* 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])
|
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
|
const noSelect = screen.noSelect
|
||||||
|
|
||||||
for (let row = start.row; row <= end.row && row < screen.height; row++) {
|
for (let row = start.row; row <= end.row && row < screen.height; row++) {
|
||||||
const colStart = row === start.row ? start.col : 0
|
const bounds = rowSelectableContentBounds(screen, row)
|
||||||
const colEnd = row === end.row ? Math.min(end.col, width - 1) : width - 1
|
|
||||||
|
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
|
const rowOff = row * width
|
||||||
|
|
||||||
|
if (colStart > colEnd) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
for (let col = colStart; col <= colEnd; col++) {
|
for (let col = colStart; col <= colEnd; col++) {
|
||||||
const idx = rowOff + col
|
const idx = rowOff + col
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -269,7 +269,6 @@ export const coreCommands: SlashCommand[] = [
|
||||||
}
|
}
|
||||||
|
|
||||||
writeOsc52Clipboard(target.text)
|
writeOsc52Clipboard(target.text)
|
||||||
sys(`copied ${target.text.length} chars`)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||||
const copySelection = () => {
|
const copySelection = () => {
|
||||||
// ink's copySelection() already calls setClipboard() which handles
|
// ink's copySelection() already calls setClipboard() which handles
|
||||||
// pbcopy (macOS), wl-copy/xclip (Linux), tmux, and OSC 52 fallback.
|
// pbcopy (macOS), wl-copy/xclip (Linux), tmux, and OSC 52 fallback.
|
||||||
const text = terminal.selection.copySelection()
|
terminal.selection.copySelection()
|
||||||
|
|
||||||
if (text) {
|
|
||||||
actions.sys(`copied ${text.length} chars`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearSelection = () => {
|
const clearSelection = () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue