Add keyboard copy mode for terminal scrollback (#792)

* Add keyboard copy mode for terminal scrollback

* Show vim copy mode indicator in terminal

* Fix vi copy-mode symbol keys and pending yank handling

* Refine copy-mode badge wording and font

* Rename keyboard copy-mode badge to VI MODE

* Address PR feedback for copy-mode routing and keyup handling

* Refresh copy-mode viewport row after scrolling
This commit is contained in:
Lawrence Chen 2026-03-03 19:01:21 -08:00 committed by GitHub
parent bfe843f0bd
commit 2f6cb6ff38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1092 additions and 1 deletions

View file

@ -6030,6 +6030,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return true
}
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleTerminalCopyMode)) {
let handled = tabManager?.toggleFocusedTerminalCopyMode() ?? false
#if DEBUG
dlog(
"shortcut.action name=toggleTerminalCopyMode handled=\(handled ? 1 : 0) " +
"\(debugShortcutRouteSnapshot(event: event))"
)
#endif
// Only consume when a focused terminal actually handled the toggle.
// Otherwise allow the event to continue through the responder chain.
return handled
}
// Workspace navigation: Cmd+Ctrl+] / Cmd+Ctrl+[
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .nextSidebarTab)) {
#if DEBUG

View file

@ -271,6 +271,259 @@ func resolveTerminalOpenURLTarget(_ rawValue: String) -> TerminalOpenURLTarget?
return .external(fallback)
}
enum TerminalKeyboardCopyModeSelectionMove: String, Equatable {
case left
case right
case up
case down
case pageUp = "page_up"
case pageDown = "page_down"
case home
case end
case beginningOfLine = "beginning_of_line"
case endOfLine = "end_of_line"
}
enum TerminalKeyboardCopyModeAction: Equatable {
case exit
case startSelection
case clearSelection
case copyAndExit
case copyLineAndExit
case scrollLines(Int)
case scrollPage(Int)
case scrollToTop
case scrollToBottom
case jumpToPrompt(Int)
case startSearch
case searchNext
case searchPrevious
case adjustSelection(TerminalKeyboardCopyModeSelectionMove)
}
struct TerminalKeyboardCopyModeInputState: Equatable {
var countPrefix: Int?
var pendingYankLine = false
mutating func reset() {
countPrefix = nil
pendingYankLine = false
}
}
enum TerminalKeyboardCopyModeResolution: Equatable {
case perform(TerminalKeyboardCopyModeAction, count: Int)
case consume
}
private let terminalKeyboardCopyModeMaxCount = 9_999
private func terminalKeyboardCopyModeClampCount(_ value: Int) -> Int {
min(max(value, 1), terminalKeyboardCopyModeMaxCount)
}
func terminalKeyboardCopyModeInitialViewportRow(
rows: Int,
imePointY: Double,
imeCellHeight: Double,
topPadding: Double = 0
) -> Int {
let clampedRows = max(rows, 1)
guard imeCellHeight > 0 else { return clampedRows - 1 }
// `ghostty_surface_ime_point` returns a top-origin Y coordinate at the
// cursor baseline plus one cell-height. Convert that to a zero-based row.
let estimatedRow = Int(floor(((imePointY - topPadding) / imeCellHeight) - 1))
return max(0, min(clampedRows - 1, estimatedRow))
}
private func terminalKeyboardCopyModeNormalizedModifiers(
_ modifierFlags: NSEvent.ModifierFlags
) -> NSEvent.ModifierFlags {
modifierFlags
.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function, .capsLock])
}
private func terminalKeyboardCopyModeChars(
_ charactersIgnoringModifiers: String?
) -> String {
guard let scalar = charactersIgnoringModifiers?.unicodeScalars.first else {
return ""
}
return String(scalar).lowercased()
}
func terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: NSEvent.ModifierFlags) -> Bool {
let normalized = terminalKeyboardCopyModeNormalizedModifiers(modifierFlags)
return normalized.contains(.command)
}
func terminalKeyboardCopyModeAction(
keyCode: UInt16,
charactersIgnoringModifiers: String?,
modifierFlags: NSEvent.ModifierFlags,
hasSelection: Bool
) -> TerminalKeyboardCopyModeAction? {
let normalized = terminalKeyboardCopyModeNormalizedModifiers(modifierFlags)
let chars = terminalKeyboardCopyModeChars(charactersIgnoringModifiers)
if keyCode == 53 { // Escape
return .exit
}
switch keyCode {
case 126: // Up
return hasSelection ? .adjustSelection(.up) : .scrollLines(-1)
case 125: // Down
return hasSelection ? .adjustSelection(.down) : .scrollLines(1)
case 123: // Left
return hasSelection ? .adjustSelection(.left) : nil
case 124: // Right
return hasSelection ? .adjustSelection(.right) : nil
case 116: // Page Up
return hasSelection ? .adjustSelection(.pageUp) : .scrollPage(-1)
case 121: // Page Down
return hasSelection ? .adjustSelection(.pageDown) : .scrollPage(1)
case 115: // Home
return hasSelection ? .adjustSelection(.home) : .scrollToTop
case 119: // End
return hasSelection ? .adjustSelection(.end) : .scrollToBottom
default:
break
}
if normalized == [.control] {
if chars == "u" || chars == "\u{15}" {
return hasSelection ? .adjustSelection(.pageUp) : .scrollPage(-1)
}
if chars == "d" || chars == "\u{04}" {
return hasSelection ? .adjustSelection(.pageDown) : .scrollPage(1)
}
if chars == "b" || chars == "\u{02}" {
return hasSelection ? .adjustSelection(.pageUp) : .scrollPage(-1)
}
if chars == "f" || chars == "\u{06}" {
return hasSelection ? .adjustSelection(.pageDown) : .scrollPage(1)
}
if chars == "y" || chars == "\u{19}" {
return hasSelection ? .adjustSelection(.up) : .scrollLines(-1)
}
if chars == "e" || chars == "\u{05}" {
return hasSelection ? .adjustSelection(.down) : .scrollLines(1)
}
return nil
}
guard normalized.isEmpty || normalized == [.shift] else { return nil }
switch chars {
case "q":
return .exit
case "v":
return hasSelection ? .clearSelection : .startSelection
case "y":
if normalized == [.shift], !hasSelection {
return .copyLineAndExit
}
return hasSelection ? .copyAndExit : nil
case "j":
return hasSelection ? .adjustSelection(.down) : .scrollLines(1)
case "k":
return hasSelection ? .adjustSelection(.up) : .scrollLines(-1)
case "h":
return hasSelection ? .adjustSelection(.left) : nil
case "l":
return hasSelection ? .adjustSelection(.right) : nil
case "g":
if normalized == [.shift] {
return hasSelection ? .adjustSelection(.end) : .scrollToBottom
}
return hasSelection ? .adjustSelection(.home) : .scrollToTop
case "0", "^":
return hasSelection ? .adjustSelection(.beginningOfLine) : nil
case "$", "4":
guard chars == "$" || normalized == [.shift] else { return nil }
return hasSelection ? .adjustSelection(.endOfLine) : nil
case "{", "[":
guard chars == "{" || normalized == [.shift] else { return nil }
return .jumpToPrompt(-1)
case "}", "]":
guard chars == "}" || normalized == [.shift] else { return nil }
return .jumpToPrompt(1)
case "/":
return .startSearch
case "n":
return normalized == [.shift] ? .searchPrevious : .searchNext
default:
return nil
}
}
func terminalKeyboardCopyModeResolve(
keyCode: UInt16,
charactersIgnoringModifiers: String?,
modifierFlags: NSEvent.ModifierFlags,
hasSelection: Bool,
state: inout TerminalKeyboardCopyModeInputState
) -> TerminalKeyboardCopyModeResolution {
let normalized = terminalKeyboardCopyModeNormalizedModifiers(modifierFlags)
let chars = terminalKeyboardCopyModeChars(charactersIgnoringModifiers)
if keyCode == 53 { // Escape
state.reset()
return .perform(.exit, count: 1)
}
if state.pendingYankLine {
if chars == "y", normalized.isEmpty || normalized == [.shift] {
let count = terminalKeyboardCopyModeClampCount(state.countPrefix ?? 1)
state.reset()
return .perform(.copyLineAndExit, count: count)
}
// Only `yy`/`Y` are supported as line-yank operators, so cancel the
// pending yank and treat this key as a fresh command.
state.pendingYankLine = false
}
if normalized.isEmpty,
let scalar = chars.unicodeScalars.first,
scalar.isASCII,
scalar.value >= 48,
scalar.value <= 57 {
let digit = Int(scalar.value - 48)
if digit == 0 {
if let currentCount = state.countPrefix {
state.countPrefix = terminalKeyboardCopyModeClampCount(currentCount * 10)
return .consume
}
} else {
let currentCount = state.countPrefix ?? 0
state.countPrefix = terminalKeyboardCopyModeClampCount((currentCount * 10) + digit)
return .consume
}
}
if !hasSelection, chars == "y", normalized.isEmpty {
state.pendingYankLine = true
return .consume
}
guard let action = terminalKeyboardCopyModeAction(
keyCode: keyCode,
charactersIgnoringModifiers: charactersIgnoringModifiers,
modifierFlags: modifierFlags,
hasSelection: hasSelection
) else {
state.reset()
return .consume
}
let count = terminalKeyboardCopyModeClampCount(state.countPrefix ?? 1)
state.reset()
return .perform(action, count: count)
}
private final class GhosttySurfaceCallbackContext {
weak var surfaceView: GhosttyNSView?
weak var terminalSurface: TerminalSurface?
@ -1657,6 +1910,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
}
}
}
@Published private(set) var keyboardCopyModeActive: Bool = false
private var searchNeedleCancellable: AnyCancellable?
init(
@ -2326,6 +2580,29 @@ final class TerminalSurface: Identifiable, ObservableObject {
}
}
@discardableResult
func toggleKeyboardCopyMode() -> Bool {
let handled = surfaceView.toggleKeyboardCopyMode()
if handled {
setKeyboardCopyModeActive(surfaceView.isKeyboardCopyModeActive)
}
return handled
}
func setKeyboardCopyModeActive(_ active: Bool) {
if !Thread.isMainThread {
DispatchQueue.main.async { [weak self] in
self?.setKeyboardCopyModeActive(active)
}
return
}
if keyboardCopyModeActive != active {
keyboardCopyModeActive = active
}
hostedView.setKeyboardCopyModeIndicator(visible: active)
}
func hasSelection() -> Bool {
guard let surface = surface else { return false }
return ghostty_surface_has_selection(surface)
@ -2435,6 +2712,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
private var lastLoggedWindowBackgroundSignature: String?
private var keySequence: [ghostty_input_trigger_s] = []
private var keyTables: [String] = []
fileprivate private(set) var keyboardCopyModeActive = false
private var keyboardCopyModeConsumedKeyUps: Set<UInt16> = []
private var keyboardCopyModeInputState = TerminalKeyboardCopyModeInputState()
private var keyboardCopyModeViewportRow: Int?
fileprivate var isKeyboardCopyModeActive: Bool { keyboardCopyModeActive }
#if DEBUG
private static let keyLatencyProbeEnabled: Bool = {
if ProcessInfo.processInfo.environment["CMUX_KEY_LATENCY_PROBE"] == "1" {
@ -2597,6 +2879,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
terminalSurface = surface
tabId = surface.tabId
surface.attachToView(self)
surface.setKeyboardCopyModeActive(keyboardCopyModeActive)
updateSurfaceSize()
applySurfaceBackground()
applySurfaceColorScheme(force: true)
@ -2865,6 +3148,195 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
}
}
@discardableResult
func toggleKeyboardCopyMode() -> Bool {
guard surface != nil else { return false }
setKeyboardCopyModeActive(!keyboardCopyModeActive)
if !keyboardCopyModeActive, let surface {
_ = ghostty_surface_clear_selection(surface)
}
return true
}
private func setKeyboardCopyModeActive(_ active: Bool) {
keyboardCopyModeInputState.reset()
keyboardCopyModeActive = active
if active, let surface {
keyboardCopyModeViewportRow = keyboardCopyModeSelectionAnchor(surface: surface)?.row
_ = ghostty_surface_clear_selection(surface)
if keyboardCopyModeViewportRow == nil {
keyboardCopyModeViewportRow = keyboardCopyModeImeViewportRow(surface: surface)
}
} else {
keyboardCopyModeViewportRow = nil
}
terminalSurface?.setKeyboardCopyModeActive(active)
}
private func performBindingAction(_ action: String, repeatCount: Int) {
let count = terminalKeyboardCopyModeClampCount(repeatCount)
for _ in 0 ..< count {
_ = performBindingAction(action)
}
}
private func currentKeyboardCopyModeViewportRow(surface: ghostty_surface_t) -> Int {
let rows = max(Int(ghostty_surface_size(surface).rows), 1)
let fallback = rows - 1
return max(0, min(rows - 1, keyboardCopyModeViewportRow ?? fallback))
}
private func keyboardCopyModeImeViewportRow(surface: ghostty_surface_t) -> Int {
let rows = max(Int(ghostty_surface_size(surface).rows), 1)
var x: Double = 0
var y: Double = 0
var width: Double = 0
var height: Double = 0
ghostty_surface_ime_point(surface, &x, &y, &width, &height)
return terminalKeyboardCopyModeInitialViewportRow(
rows: rows,
imePointY: y,
imeCellHeight: height
)
}
private func keyboardCopyModeSelectionAnchor(surface: ghostty_surface_t) -> (row: Int, y: Double)? {
let size = ghostty_surface_size(surface)
guard size.rows > 0, size.columns > 0 else { return nil }
guard ghostty_surface_select_cursor_cell(surface) else { return nil }
var text = ghostty_text_s()
guard ghostty_surface_read_selection(surface, &text) else { return nil }
defer { ghostty_surface_free_text(surface, &text) }
let rows = max(Int(size.rows), 1)
let cols = max(Int(size.columns), 1)
let rawRow = Int(text.offset_start) / cols
let clampedRow = max(0, min(rows - 1, rawRow))
return (row: clampedRow, y: text.tl_px_y)
}
private func refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: ghostty_surface_t) {
guard !ghostty_surface_has_selection(surface) else { return }
guard let anchor = keyboardCopyModeSelectionAnchor(surface: surface) else { return }
keyboardCopyModeViewportRow = anchor.row
_ = ghostty_surface_clear_selection(surface)
}
private func copyCurrentViewportLinesToClipboard(
surface: ghostty_surface_t,
startRow: Int,
lineCount: Int
) -> Bool {
let clampedCount = terminalKeyboardCopyModeClampCount(lineCount)
let rows = max(Int(ghostty_surface_size(surface).rows), 1)
let targetRow = max(0, min(rows - 1, startRow))
let endRow = min(rows - 1, targetRow + clampedCount - 1)
guard let anchor = keyboardCopyModeSelectionAnchor(surface: surface) else {
return false
}
_ = ghostty_surface_clear_selection(surface)
var imeX: Double = 0
var imeY: Double = 0
var imeWidth: Double = 0
var imeHeight: Double = 0
ghostty_surface_ime_point(surface, &imeX, &imeY, &imeWidth, &imeHeight)
let cellHeight = imeHeight > 0 ? imeHeight : max(bounds.height / Double(rows), 1)
let yMax = max(bounds.height - 1, 0)
let startRawY = anchor.y + (Double(targetRow - anchor.row) * cellHeight)
let endRawY = anchor.y + (Double(endRow - anchor.row) * cellHeight)
let startY = max(0, min(startRawY, yMax))
let endY = max(0, min(endRawY, yMax))
let xMax = max(bounds.width - 1, 0)
let startX = min(1, xMax)
let endX = xMax
let mods = ghostty_input_mods_e(rawValue: GHOSTTY_MODS_NONE.rawValue) ?? GHOSTTY_MODS_NONE
ghostty_surface_mouse_pos(surface, startX, startY, mods)
guard ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods) else {
return false
}
defer {
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods)
}
ghostty_surface_mouse_pos(surface, endX, endY, mods)
guard ghostty_surface_has_selection(surface) else { return false }
return performBindingAction("copy_to_clipboard")
}
private func handleKeyboardCopyModeIfNeeded(_ event: NSEvent, surface: ghostty_surface_t) -> Bool {
guard keyboardCopyModeActive else { return false }
if terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: event.modifierFlags) {
keyboardCopyModeInputState.reset()
return false
}
let hasSelection = ghostty_surface_has_selection(surface)
let resolution = terminalKeyboardCopyModeResolve(
keyCode: event.keyCode,
charactersIgnoringModifiers: event.charactersIgnoringModifiers,
modifierFlags: event.modifierFlags,
hasSelection: hasSelection,
state: &keyboardCopyModeInputState
)
guard case let .perform(action, count) = resolution else {
return true
}
switch action {
case .exit:
_ = ghostty_surface_clear_selection(surface)
setKeyboardCopyModeActive(false)
case .startSelection:
_ = ghostty_surface_select_cursor_cell(surface)
case .clearSelection:
_ = ghostty_surface_clear_selection(surface)
case .copyAndExit:
_ = performBindingAction("copy_to_clipboard")
_ = ghostty_surface_clear_selection(surface)
setKeyboardCopyModeActive(false)
case .copyLineAndExit:
let startRow = currentKeyboardCopyModeViewportRow(surface: surface)
_ = copyCurrentViewportLinesToClipboard(
surface: surface,
startRow: startRow,
lineCount: count
)
_ = ghostty_surface_clear_selection(surface)
setKeyboardCopyModeActive(false)
case let .scrollLines(delta):
_ = performBindingAction("scroll_page_lines:\(delta * count)")
refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface)
case let .scrollPage(delta):
performBindingAction(delta > 0 ? "scroll_page_down" : "scroll_page_up", repeatCount: count)
refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface)
case .scrollToTop:
keyboardCopyModeViewportRow = 0
_ = performBindingAction("scroll_to_top")
case .scrollToBottom:
keyboardCopyModeViewportRow = max(Int(ghostty_surface_size(surface).rows) - 1, 0)
_ = performBindingAction("scroll_to_bottom")
case let .jumpToPrompt(delta):
_ = performBindingAction("jump_to_prompt:\(delta * count)")
refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface)
case .startSearch:
_ = performBindingAction("start_search")
case .searchNext:
performBindingAction("navigate_search:next", repeatCount: count)
refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface)
case .searchPrevious:
performBindingAction("navigate_search:previous", repeatCount: count)
refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface)
case let .adjustSelection(direction):
performBindingAction("adjust_selection:\(direction.rawValue)", repeatCount: count)
}
return true
}
// MARK: - Input Handling
@IBAction func copy(_ sender: Any?) {
@ -3227,6 +3699,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
super.keyDown(with: event)
return
}
if handleKeyboardCopyModeIfNeeded(event, surface: surface) {
keyboardCopyModeConsumedKeyUps.insert(event.keyCode)
return
}
#if DEBUG
recordKeyLatency(path: "keyDown", event: event)
#endif
@ -3436,6 +3912,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
return
}
if keyboardCopyModeConsumedKeyUps.remove(event.keyCode) != nil {
return
}
// Build release events from the same translation path as keyDown so
// consumers that depend on precise key identity (for example Space
// hold/release flows) receive consistent metadata.
@ -4088,6 +4568,14 @@ private final class GhosttyFlashOverlayView: NSView {
}
}
private final class GhosttyPassthroughVisualEffectView: NSVisualEffectView {
override var acceptsFirstResponder: Bool { false }
override func hitTest(_ point: NSPoint) -> NSView? {
nil
}
}
final class GhosttySurfaceScrollView: NSView {
private let backgroundView: NSView
private let scrollView: GhosttyScrollView
@ -4099,6 +4587,8 @@ final class GhosttySurfaceScrollView: NSView {
private let notificationRingLayer: CAShapeLayer
private let flashOverlayView: GhosttyFlashOverlayView
private let flashLayer: CAShapeLayer
private let keyboardCopyModeBadgeView: GhosttyPassthroughVisualEffectView
private let keyboardCopyModeBadgeLabel: NSTextField
private var searchOverlayHostingView: NSHostingView<SurfaceSearchOverlay>?
private var observers: [NSObjectProtocol] = []
private var windowObservers: [NSObjectProtocol] = []
@ -4253,6 +4743,8 @@ final class GhosttySurfaceScrollView: NSView {
notificationRingLayer = CAShapeLayer()
flashOverlayView = GhosttyFlashOverlayView(frame: .zero)
flashLayer = CAShapeLayer()
keyboardCopyModeBadgeView = GhosttyPassthroughVisualEffectView(frame: .zero)
keyboardCopyModeBadgeLabel = NSTextField(labelWithString: "VI MODE")
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = false
@ -4325,6 +4817,32 @@ final class GhosttySurfaceScrollView: NSView {
flashLayer.opacity = 0
flashOverlayView.layer?.addSublayer(flashLayer)
addSubview(flashOverlayView)
keyboardCopyModeBadgeView.translatesAutoresizingMaskIntoConstraints = false
keyboardCopyModeBadgeView.wantsLayer = true
keyboardCopyModeBadgeView.material = .hudWindow
keyboardCopyModeBadgeView.blendingMode = .withinWindow
keyboardCopyModeBadgeView.state = .active
keyboardCopyModeBadgeView.layer?.cornerRadius = 7
keyboardCopyModeBadgeView.layer?.masksToBounds = true
keyboardCopyModeBadgeView.layer?.borderWidth = 1
keyboardCopyModeBadgeView.layer?.borderColor = cmuxAccentNSColor().withAlphaComponent(0.45).cgColor
keyboardCopyModeBadgeView.alphaValue = 0.97
keyboardCopyModeBadgeLabel.translatesAutoresizingMaskIntoConstraints = false
keyboardCopyModeBadgeLabel.textColor = NSColor.labelColor
keyboardCopyModeBadgeLabel.lineBreakMode = .byClipping
keyboardCopyModeBadgeView.addSubview(keyboardCopyModeBadgeLabel)
NSLayoutConstraint.activate([
keyboardCopyModeBadgeLabel.leadingAnchor.constraint(equalTo: keyboardCopyModeBadgeView.leadingAnchor, constant: 8),
keyboardCopyModeBadgeLabel.trailingAnchor.constraint(equalTo: keyboardCopyModeBadgeView.trailingAnchor, constant: -8),
keyboardCopyModeBadgeLabel.topAnchor.constraint(equalTo: keyboardCopyModeBadgeView.topAnchor, constant: 4),
keyboardCopyModeBadgeLabel.bottomAnchor.constraint(equalTo: keyboardCopyModeBadgeView.bottomAnchor, constant: -4),
])
keyboardCopyModeBadgeView.isHidden = true
addSubview(keyboardCopyModeBadgeView)
NSLayoutConstraint.activate([
keyboardCopyModeBadgeView.topAnchor.constraint(equalTo: topAnchor, constant: 8),
keyboardCopyModeBadgeView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
])
scrollView.contentView.postsBoundsChangedNotifications = true
observers.append(NotificationCenter.default.addObserver(
@ -4579,6 +5097,9 @@ final class GhosttySurfaceScrollView: NSView {
overlay.trailingAnchor.constraint(equalTo: trailingAnchor),
])
}
if !keyboardCopyModeBadgeView.isHidden {
addSubview(keyboardCopyModeBadgeView, positioned: .above, relativeTo: overlay)
}
return
}
@ -4591,9 +5112,30 @@ final class GhosttySurfaceScrollView: NSView {
overlay.leadingAnchor.constraint(equalTo: leadingAnchor),
overlay.trailingAnchor.constraint(equalTo: trailingAnchor),
])
if !keyboardCopyModeBadgeView.isHidden {
addSubview(keyboardCopyModeBadgeView, positioned: .above, relativeTo: overlay)
}
searchOverlayHostingView = overlay
}
func setKeyboardCopyModeIndicator(visible: Bool) {
if !Thread.isMainThread {
DispatchQueue.main.async { [weak self] in
self?.setKeyboardCopyModeIndicator(visible: visible)
}
return
}
keyboardCopyModeBadgeView.isHidden = !visible
if visible {
if let overlay = searchOverlayHostingView {
addSubview(keyboardCopyModeBadgeView, positioned: .above, relativeTo: overlay)
} else {
addSubview(keyboardCopyModeBadgeView, positioned: .above, relativeTo: nil)
}
}
}
private func dropZoneOverlayFrame(for zone: DropZone, in size: CGSize) -> CGRect {
let padding: CGFloat = 4
switch zone {
@ -4916,6 +5458,10 @@ final class GhosttySurfaceScrollView: NSView {
return overlay.superview === self && !overlay.isHidden
}
func debugHasKeyboardCopyModeIndicator() -> Bool {
keyboardCopyModeBadgeView.superview === self && !keyboardCopyModeBadgeView.isHidden
}
#endif
/// Handle file/URL drops, forwarding to the terminal as shell-escaped paths.
@ -5797,6 +6343,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
)
hostedView.setNotificationRing(visible: showsUnreadNotificationRing)
hostedView.setSearchOverlay(searchState: searchState)
hostedView.setKeyboardCopyModeIndicator(visible: terminalSurface.keyboardCopyModeActive)
hostedView.setFocusHandler { onFocus?(terminalSurface.id) }
hostedView.setTriggerFlashHandler(onTriggerFlash)
let portalExpectedSurfaceId = terminalSurface.id

View file

@ -23,6 +23,7 @@ enum KeyboardShortcutSettings {
case renameWorkspace
case closeWorkspace
case newSurface
case toggleTerminalCopyMode
// Panes / splits
case focusLeft
@ -60,6 +61,7 @@ enum KeyboardShortcutSettings {
case .renameWorkspace: return "Rename Workspace"
case .closeWorkspace: return "Close Workspace"
case .newSurface: return "New Surface"
case .toggleTerminalCopyMode: return "Toggle Terminal Copy Mode"
case .focusLeft: return "Focus Pane Left"
case .focusRight: return "Focus Pane Right"
case .focusUp: return "Focus Pane Up"
@ -102,6 +104,7 @@ enum KeyboardShortcutSettings {
case .nextSurface: return "shortcut.nextSurface"
case .prevSurface: return "shortcut.prevSurface"
case .newSurface: return "shortcut.newSurface"
case .toggleTerminalCopyMode: return "shortcut.toggleTerminalCopyMode"
case .openBrowser: return "shortcut.openBrowser"
case .toggleBrowserDeveloperTools: return "shortcut.toggleBrowserDeveloperTools"
case .showBrowserJavaScriptConsole: return "shortcut.showBrowserJavaScriptConsole"
@ -160,6 +163,8 @@ enum KeyboardShortcutSettings {
return StoredShortcut(key: "[", command: true, shift: true, option: false, control: false)
case .newSurface:
return StoredShortcut(key: "t", command: true, shift: false, option: false, control: false)
case .toggleTerminalCopyMode:
return StoredShortcut(key: "m", command: true, shift: true, option: false, control: false)
case .openBrowser:
return StoredShortcut(key: "l", command: true, shift: true, option: false, control: false)
case .toggleBrowserDeveloperTools:

View file

@ -753,6 +753,12 @@ class TabManager: ObservableObject {
_ = selectedTerminalPanel?.performBindingAction("search:previous")
}
@discardableResult
func toggleFocusedTerminalCopyMode() -> Bool {
guard let panel = selectedTerminalPanel else { return false }
return panel.surface.toggleKeyboardCopyMode()
}
func hideFind() {
selectedTerminalPanel?.searchState = nil
}

View file

@ -476,6 +476,35 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
XCTAssertTrue(appDelegate.tabManager === firstManager, "Unresolved event window should not retarget active manager")
}
func testCmdShiftMReturnsFalseWhenNoFocusedTerminalCanHandle() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
// Force unresolved shortcut routing context and no active manager.
appDelegate.tabManager = nil
guard let event = makeKeyDownEvent(
key: "m",
modifiers: [.command, .shift],
keyCode: 46, // kVK_ANSI_M
windowNumber: Int.max
) else {
XCTFail("Failed to construct Cmd+Shift+M event")
return
}
#if DEBUG
XCTAssertFalse(
appDelegate.debugHandleCustomShortcut(event: event),
"Cmd+Shift+M should not be consumed when no terminal can toggle copy mode"
)
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
}
func testPresentPreferencesWindowShowsCustomSettingsWindowAndActivates() {
var showFallbackSettingsWindowCallCount = 0
var activateApplicationCallCount = 0

View file

@ -1217,6 +1217,21 @@ final class WorkspaceRenameShortcutDefaultsTests: XCTestCase {
XCTAssertTrue(prevShortcut.eventModifiers.contains(.control))
}
func testToggleTerminalCopyModeShortcutDefaultsAndMetadata() {
XCTAssertEqual(KeyboardShortcutSettings.Action.toggleTerminalCopyMode.label, "Toggle Terminal Copy Mode")
XCTAssertEqual(
KeyboardShortcutSettings.Action.toggleTerminalCopyMode.defaultsKey,
"shortcut.toggleTerminalCopyMode"
)
let shortcut = KeyboardShortcutSettings.Action.toggleTerminalCopyMode.defaultShortcut
XCTAssertEqual(shortcut.key, "m")
XCTAssertTrue(shortcut.command)
XCTAssertTrue(shortcut.shift)
XCTAssertFalse(shortcut.option)
XCTAssertFalse(shortcut.control)
}
func testMenuItemKeyEquivalentHandlesArrowAndTabKeys() {
XCTAssertNotNil(StoredShortcut(key: "", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent)
XCTAssertNotNil(StoredShortcut(key: "", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent)
@ -1234,6 +1249,463 @@ final class WorkspaceRenameShortcutDefaultsTests: XCTestCase {
}
}
final class TerminalKeyboardCopyModeActionTests: XCTestCase {
func testCopyModeBypassAllowsOnlyCommandShortcuts() {
XCTAssertTrue(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.command]))
XCTAssertTrue(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.command, .shift]))
XCTAssertTrue(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.command, .option]))
XCTAssertFalse(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.option]))
XCTAssertFalse(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.option, .shift]))
XCTAssertFalse(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.control]))
}
func testJKWithoutSelectionScrollByLine() {
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 38,
charactersIgnoringModifiers: "j",
modifierFlags: [],
hasSelection: false
),
.scrollLines(1)
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 40,
charactersIgnoringModifiers: "k",
modifierFlags: [],
hasSelection: false
),
.scrollLines(-1)
)
}
func testCapsLockDoesNotBlockLetterMappings() {
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 38,
charactersIgnoringModifiers: "j",
modifierFlags: [.capsLock],
hasSelection: false
),
.scrollLines(1)
)
}
func testJKWithSelectionAdjustSelection() {
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 38,
charactersIgnoringModifiers: "j",
modifierFlags: [],
hasSelection: true
),
.adjustSelection(.down)
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 40,
charactersIgnoringModifiers: "k",
modifierFlags: [],
hasSelection: true
),
.adjustSelection(.up)
)
}
func testControlPagingSupportsPrintableAndControlCharacters() {
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 0,
charactersIgnoringModifiers: "\u{15}",
modifierFlags: [.control],
hasSelection: false
),
.scrollPage(-1)
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 0,
charactersIgnoringModifiers: "\u{04}",
modifierFlags: [.control],
hasSelection: true
),
.adjustSelection(.pageDown)
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 0,
charactersIgnoringModifiers: "\u{02}",
modifierFlags: [.control],
hasSelection: false
),
.scrollPage(-1)
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 0,
charactersIgnoringModifiers: "\u{06}",
modifierFlags: [.control],
hasSelection: true
),
.adjustSelection(.pageDown)
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 0,
charactersIgnoringModifiers: "\u{19}",
modifierFlags: [.control],
hasSelection: false
),
.scrollLines(-1)
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 0,
charactersIgnoringModifiers: "\u{05}",
modifierFlags: [.control],
hasSelection: true
),
.adjustSelection(.down)
)
}
func testVGYMapping() {
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 9,
charactersIgnoringModifiers: "v",
modifierFlags: [],
hasSelection: false
),
.startSelection
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 9,
charactersIgnoringModifiers: "v",
modifierFlags: [],
hasSelection: true
),
.clearSelection
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 16,
charactersIgnoringModifiers: "y",
modifierFlags: [],
hasSelection: true
),
.copyAndExit
)
}
func testGAndShiftGMapping() {
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 5,
charactersIgnoringModifiers: "g",
modifierFlags: [],
hasSelection: false
),
.scrollToTop
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 5,
charactersIgnoringModifiers: "g",
modifierFlags: [.shift],
hasSelection: false
),
.scrollToBottom
)
}
func testLineBoundaryPromptAndSearchMappings() {
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 29,
charactersIgnoringModifiers: "0",
modifierFlags: [],
hasSelection: true
),
.adjustSelection(.beginningOfLine)
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 20,
charactersIgnoringModifiers: "^",
modifierFlags: [.shift],
hasSelection: true
),
.adjustSelection(.beginningOfLine)
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 21,
charactersIgnoringModifiers: "4",
modifierFlags: [.shift],
hasSelection: true
),
.adjustSelection(.endOfLine)
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 33,
charactersIgnoringModifiers: "[",
modifierFlags: [.shift],
hasSelection: false
),
.jumpToPrompt(-1)
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 30,
charactersIgnoringModifiers: "]",
modifierFlags: [.shift],
hasSelection: false
),
.jumpToPrompt(1)
)
XCTAssertNil(
terminalKeyboardCopyModeAction(
keyCode: 21,
charactersIgnoringModifiers: "4",
modifierFlags: [],
hasSelection: true
)
)
XCTAssertNil(
terminalKeyboardCopyModeAction(
keyCode: 33,
charactersIgnoringModifiers: "[",
modifierFlags: [],
hasSelection: false
)
)
XCTAssertNil(
terminalKeyboardCopyModeAction(
keyCode: 30,
charactersIgnoringModifiers: "]",
modifierFlags: [],
hasSelection: false
)
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 44,
charactersIgnoringModifiers: "/",
modifierFlags: [],
hasSelection: false
),
.startSearch
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 45,
charactersIgnoringModifiers: "n",
modifierFlags: [],
hasSelection: false
),
.searchNext
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 45,
charactersIgnoringModifiers: "n",
modifierFlags: [.shift],
hasSelection: false
),
.searchPrevious
)
}
func testShiftVMatchesVisualToggleBehavior() {
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 9,
charactersIgnoringModifiers: "v",
modifierFlags: [.shift],
hasSelection: false
),
.startSelection
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 9,
charactersIgnoringModifiers: "v",
modifierFlags: [.shift],
hasSelection: true
),
.clearSelection
)
}
func testEscapeAlwaysExits() {
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 53,
charactersIgnoringModifiers: "",
modifierFlags: [],
hasSelection: false
),
.exit
)
}
func testQAlwaysExits() {
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 12, // kVK_ANSI_Q
charactersIgnoringModifiers: "q",
modifierFlags: [],
hasSelection: false
),
.exit
)
}
}
final class TerminalKeyboardCopyModeResolveTests: XCTestCase {
private func resolve(
_ keyCode: UInt16,
chars: String,
modifiers: NSEvent.ModifierFlags = [],
hasSelection: Bool,
state: inout TerminalKeyboardCopyModeInputState
) -> TerminalKeyboardCopyModeResolution {
terminalKeyboardCopyModeResolve(
keyCode: keyCode,
charactersIgnoringModifiers: chars,
modifierFlags: modifiers,
hasSelection: hasSelection,
state: &state
)
}
func testCountPrefixAppliesToMotion() {
var state = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(resolve(20, chars: "3", hasSelection: false, state: &state), .consume)
XCTAssertEqual(resolve(38, chars: "j", hasSelection: false, state: &state), .perform(.scrollLines(1), count: 3))
XCTAssertEqual(state, TerminalKeyboardCopyModeInputState())
}
func testZeroAppendsCountOrActsAsMotion() {
var state = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(resolve(19, chars: "2", hasSelection: false, state: &state), .consume)
XCTAssertEqual(resolve(29, chars: "0", hasSelection: false, state: &state), .consume)
XCTAssertEqual(resolve(40, chars: "k", hasSelection: false, state: &state), .perform(.scrollLines(-1), count: 20))
var selectionState = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(
resolve(29, chars: "0", hasSelection: true, state: &selectionState),
.perform(.adjustSelection(.beginningOfLine), count: 1)
)
}
func testYankLineOperatorSupportsYYAndYWithCounts() {
var yyState = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &yyState), .consume)
XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &yyState), .perform(.copyLineAndExit, count: 1))
var countedState = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(resolve(21, chars: "4", hasSelection: false, state: &countedState), .consume)
XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &countedState), .consume)
XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &countedState), .perform(.copyLineAndExit, count: 4))
var shiftYState = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(resolve(20, chars: "3", hasSelection: false, state: &shiftYState), .consume)
XCTAssertEqual(
resolve(16, chars: "y", modifiers: [.shift], hasSelection: false, state: &shiftYState),
.perform(.copyLineAndExit, count: 3)
)
}
func testPendingYankLineDoesNotSwallowNextCommand() {
var state = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &state), .consume)
XCTAssertEqual(resolve(38, chars: "j", hasSelection: false, state: &state), .perform(.scrollLines(1), count: 1))
XCTAssertEqual(state, TerminalKeyboardCopyModeInputState())
}
func testSearchAndPromptMotionsUseCounts() {
var promptState = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(resolve(20, chars: "3", hasSelection: false, state: &promptState), .consume)
XCTAssertEqual(
resolve(30, chars: "]", modifiers: [.shift], hasSelection: false, state: &promptState),
.perform(.jumpToPrompt(1), count: 3)
)
var searchState = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(resolve(18, chars: "2", hasSelection: false, state: &searchState), .consume)
XCTAssertEqual(resolve(45, chars: "n", hasSelection: false, state: &searchState), .perform(.searchNext, count: 2))
}
func testInvalidKeyClearsPendingState() {
var state = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(resolve(18, chars: "2", hasSelection: false, state: &state), .consume)
XCTAssertEqual(resolve(7, chars: "x", hasSelection: false, state: &state), .consume)
XCTAssertEqual(state, TerminalKeyboardCopyModeInputState())
}
}
final class TerminalKeyboardCopyModeViewportRowTests: XCTestCase {
func testInitialViewportRowUsesImePointBaseline() {
XCTAssertEqual(
terminalKeyboardCopyModeInitialViewportRow(
rows: 24,
imePointY: 24,
imeCellHeight: 24
),
0
)
XCTAssertEqual(
terminalKeyboardCopyModeInitialViewportRow(
rows: 24,
imePointY: 240,
imeCellHeight: 24
),
9
)
XCTAssertEqual(
terminalKeyboardCopyModeInitialViewportRow(
rows: 24,
imePointY: 48,
imeCellHeight: 24,
topPadding: 24
),
0
)
}
func testInitialViewportRowClampsBoundsAndFallsBackWhenHeightMissing() {
XCTAssertEqual(
terminalKeyboardCopyModeInitialViewportRow(
rows: 24,
imePointY: 0,
imeCellHeight: 24
),
0
)
XCTAssertEqual(
terminalKeyboardCopyModeInitialViewportRow(
rows: 24,
imePointY: 9999,
imeCellHeight: 24
),
23
)
XCTAssertEqual(
terminalKeyboardCopyModeInitialViewportRow(
rows: 24,
imePointY: 123,
imeCellHeight: 0
),
23
)
}
}
@MainActor
final class BrowserDeveloperToolsConfigurationTests: XCTestCase {
func testBrowserPanelEnablesInspectableWebViewAndDeveloperExtras() {
@ -7432,6 +7904,23 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
XCTAssertFalse(hostedView.debugHasSearchOverlay())
}
func testKeyboardCopyModeIndicatorMountsAndUnmounts() {
let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
let hostedView = surface.hostedView
XCTAssertFalse(hostedView.debugHasKeyboardCopyModeIndicator())
hostedView.setKeyboardCopyModeIndicator(visible: true)
XCTAssertTrue(hostedView.debugHasKeyboardCopyModeIndicator())
hostedView.setKeyboardCopyModeIndicator(visible: false)
XCTAssertFalse(hostedView.debugHasKeyboardCopyModeIndicator())
}
func testForceRefreshNoopsAfterSurfaceReleaseDuringGeometryReconcile() throws {
#if DEBUG
let window = NSWindow(

@ -1 +1 @@
Subproject commit 80d3fa07ff8ae86fe6089083371f71ac7634648f
Subproject commit 7dd589824d4c9bda8265355718800cccaf7189a0

View file

@ -1108,6 +1108,8 @@ void ghostty_surface_complete_clipboard_request(ghostty_surface_t,
void*,
bool);
bool ghostty_surface_has_selection(ghostty_surface_t);
bool ghostty_surface_select_cursor_cell(ghostty_surface_t);
bool ghostty_surface_clear_selection(ghostty_surface_t);
bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*);
bool ghostty_surface_read_text(ghostty_surface_t,
ghostty_selection_s,