Merge pull request #15766 from NousResearch/bb/tui-ssh-copy

fix(tui): honor client copy shortcut over ssh
This commit is contained in:
brooklyn!
2026-04-25 15:33:17 -05:00
committed by GitHub
8 changed files with 239 additions and 25 deletions

View File

@@ -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)
}
}

View 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)
})
})

View File

@@ -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

View File

@@ -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')

View File

@@ -269,7 +269,6 @@ export const coreCommands: SlashCommand[] = [
}
writeOsc52Clipboard(target.text)
sys(`copied ${target.text.length} chars`)
}
},

View File

@@ -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()
}

View File

@@ -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)'],

View File

@@ -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).
*