mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
Merge pull request #15766 from NousResearch/bb/tui-ssh-copy
fix(tui): honor client copy shortcut over ssh
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
82
ui-tui/packages/hermes-ink/src/ink/selection.test.ts
Normal file
82
ui-tui/packages/hermes-ink/src/ink/selection.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -269,7 +269,6 @@ export const coreCommands: SlashCommand[] = [
|
||||
}
|
||||
|
||||
writeOsc52Clipboard(target.text)
|
||||
sys(`copied ${target.text.length} chars`)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)'],
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user