diff --git a/ui-tui/packages/hermes-ink/src/ink/screen.ts b/ui-tui/packages/hermes-ink/src/ink/screen.ts index 9dea201329..32c7e7d7e6 100644 --- a/ui-tui/packages/hermes-ink/src/ink/screen.ts +++ b/ui-tui/packages/hermes-ink/src/ink/screen.ts @@ -1,6 +1,6 @@ -import { type AnsiCode, ansiCodesToString, diffAnsiCodes } from '@alcalzone/ansi-tokenize' +import { ansiCodesToString, diffAnsiCodes, type AnsiCode } from '@alcalzone/ansi-tokenize' -import { type Point, type Rectangle, type Size, unionRect } from './layout/geometry.js' +import { unionRect, type Point, type Rectangle, type Size } from './layout/geometry.js' import { BEL, ESC, SEP } from './termio/ansi.js' import * as warn from './warn.js' @@ -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 new file mode 100644 index 0000000000..83d77bf35d --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/selection.test.ts @@ -0,0 +1,82 @@ +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('preserves selected indentation when spaces are rendered content', () => { + const styles = new StylePool() + const screen = createScreen(10, 1, styles, new CharPool(), new HyperlinkPool()) + const selection = createSelectionState() + + 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) + updateSelection(selection, 9, 0) + + expect(getSelectedText(selection, screen)).toBe(' x') + }) + + it('clamps copied selection bounds to screen width', () => { + const { screen } = screenWithText() + const selection = createSelectionState() + + startSelection(selection, 0, 1) + updateSelection(selection, 99, 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..76e776c22e 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, setCellStyleId } from './screen.js' +import { cellAt, cellAtIndex, CellWidth, isWrittenCellAt, setCellStyleId } from './screen.js' type Point = { col: number; row: number } @@ -842,6 +842,43 @@ export function isCellSelected(s: SelectionState, col: number, row: number): boo return true } +function selectableCell(screen: Screen, row: number, col: number): boolean { + const cell = cellAt(screen, col, row) + + return ( + screen.noSelect[row * screen.width + col] !== 1 && + isWrittenCellAt(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 + } + + let last = end + + while (last >= first && !selectableCell(screen, row, last)) { + last-- + } + + return { 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 @@ -890,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 @@ -917,16 +969,18 @@ 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 rowStart = Math.max(0, row === start.row ? start.col : 0) + const rowEnd = Math.min(row === end.row ? end.col : screen.width - 1, screen.width - 1) + 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') + return trimEmptyEdgeRows(lines).join('\n') } /** @@ -1051,9 +1105,14 @@ export function applySelectionOverlay(screen: Screen, selection: SelectionState, 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 = selectionContentBounds(screen, row, colStart, colEnd) const rowOff = row * width - for (let col = colStart; col <= colEnd; col++) { + if (!bounds) { + continue + } + + for (let col = bounds.first; col <= bounds.last; col++) { const idx = rowOff + col // Skip noSelect cells — gutters stay visually unchanged so it's diff --git a/ui-tui/src/__tests__/platform.test.ts b/ui-tui/src/__tests__/platform.test.ts index 8995b9c6fc..e3035a79b2 100644 --- a/ui-tui/src/__tests__/platform.test.ts +++ b/ui-tui/src/__tests__/platform.test.ts @@ -31,6 +31,28 @@ describe('platform action modifier', () => { }) }) +describe('isCopyShortcut', () => { + it('keeps Ctrl+C as the local non-macOS copy chord', async () => { + const { isCopyShortcut } = await importPlatform('linux') + + expect(isCopyShortcut({ ctrl: true, meta: false, super: false }, 'c', {})).toBe(true) + }) + + it('accepts client Cmd+C over SSH even when running on Linux', async () => { + const { isCopyShortcut } = await importPlatform('linux') + const env = { SSH_CONNECTION: '1 2 3 4' } as NodeJS.ProcessEnv + + expect(isCopyShortcut({ ctrl: false, meta: false, super: true }, 'c', env)).toBe(true) + expect(isCopyShortcut({ ctrl: false, meta: true, super: false }, 'c', env)).toBe(true) + }) + + it('does not treat local Linux Alt+C as copy', async () => { + const { isCopyShortcut } = await importPlatform('linux') + + expect(isCopyShortcut({ ctrl: false, meta: true, super: false }, 'c', {})).toBe(false) + }) +}) + describe('isVoiceToggleKey', () => { it('matches raw Ctrl+B on macOS (doc-default across platforms)', async () => { const { isVoiceToggleKey } = await importPlatform('darwin') 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 294a44ca6f..d7ac30d932 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -8,7 +8,7 @@ import type { SudoRespondResponse, VoiceRecordResponse } from '../gatewayTypes.js' -import { isAction, isMac, isVoiceToggleKey } from '../lib/platform.js' +import { isAction, isCopyShortcut, isMac, isVoiceToggleKey } from '../lib/platform.js' import { getInputSelection } from './inputSelectionStore.js' import type { InputHandlerContext, InputHandlerResult } from './interfaces.js' @@ -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 = () => { @@ -315,7 +311,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { } } - if (isAction(key, ch, 'c')) { + if (isCopyShortcut(key, ch)) { if (terminal.hasSelection) { return copySelection() } diff --git a/ui-tui/src/content/hotkeys.ts b/ui-tui/src/content/hotkeys.ts index 0a58e305b8..4944a19a6a 100644 --- a/ui-tui/src/content/hotkeys.ts +++ b/ui-tui/src/content/hotkeys.ts @@ -1,15 +1,22 @@ -import { isMac } from '../lib/platform.js' +import { isMac, isRemoteShell } from '../lib/platform.js' const action = isMac ? 'Cmd' : 'Ctrl' const paste = isMac ? 'Cmd' : 'Alt' +const copyHotkeys: [string, string][] = isMac + ? [ + ['Cmd+C', 'copy selection'], + ['Ctrl+C', 'interrupt / clear draft / exit'] + ] + : isRemoteShell() + ? [ + ['Cmd+C', 'copy selection when forwarded by the terminal'], + ['Ctrl+C', 'copy selection / interrupt / clear draft / exit'] + ] + : [['Ctrl+C', 'copy selection / interrupt / clear draft / exit']] + export const HOTKEYS: [string, string][] = [ - ...(isMac - ? ([ - ['Cmd+C', 'copy selection'], - ['Ctrl+C', 'interrupt / clear draft / exit'] - ] as [string, string][]) - : ([['Ctrl+C', 'copy selection / interrupt / clear draft / exit']] as [string, string][])), + ...copyHotkeys, [action + '+D', 'exit'], [action + '+G', 'open $EDITOR for prompt'], [action + '+L', 'new session (clear)'], diff --git a/ui-tui/src/lib/platform.ts b/ui-tui/src/lib/platform.ts index 6913df4bc8..c8b38b0d5f 100644 --- a/ui-tui/src/lib/platform.ts +++ b/ui-tui/src/lib/platform.ts @@ -5,8 +5,8 @@ * as `key.meta`. Some macOS terminals also translate Cmd+Left/Right/Backspace * into readline-style Ctrl+A/Ctrl+E/Ctrl+U before the app sees them. * On other platforms the action modifier is Ctrl. - * Ctrl+C is ALWAYS the interrupt key regardless of platform — it must never be - * remapped to copy. + * Ctrl+C stays the interrupt key on macOS. On non-mac terminals it can also + * copy an active TUI selection, matching common terminal selection behavior. */ export const isMac = process.platform === 'darwin' @@ -34,6 +34,16 @@ export const isMacActionFallback = ( export const isAction = (key: { ctrl: boolean; meta: boolean; super?: boolean }, ch: string, target: string): boolean => isActionMod(key) && ch.toLowerCase() === target +export const isRemoteShell = (env: NodeJS.ProcessEnv = process.env): boolean => + Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) + +export const isCopyShortcut = ( + key: { ctrl: boolean; meta: boolean; super?: boolean }, + ch: string, + env: NodeJS.ProcessEnv = process.env +): boolean => + isAction(key, ch, 'c') || (isRemoteShell(env) && (key.meta || key.super === true) && ch.toLowerCase() === 'c') + /** * Voice recording toggle key (Ctrl+B). *