Skip to content

Add keyboard copy mode for terminal scrollback#792

Merged
lawrencecchen merged 7 commits intomainfrom
issue-788-terminal-vi-copy-mode
Mar 4, 2026
Merged

Add keyboard copy mode for terminal scrollback#792
lawrencecchen merged 7 commits intomainfrom
issue-788-terminal-vi-copy-mode

Conversation

@lawrencecchen
Copy link
Contributor

@lawrencecchen lawrencecchen commented Mar 3, 2026

Summary

  • add a new customizable shortcut (Cmd+Shift+M by default) to toggle terminal keyboard copy mode
  • implement vi-style keyboard scrollback/copy controls in GhosttyNSView (j/k/h/l, g/G, Ctrl+u/d, v, y, q/Esc)
  • add Ghostty C APIs to start/clear selection and anchor selection to visible viewport when cursor is off-screen
  • add regression tests for copy-mode action mapping and shortcut metadata

Dependency

Verification

  • zig build -Demit-xcframework=true -Doptimize=ReleaseFast -Dversion-string=1.3.0-dev (in ghostty/)
  • xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination 'platform=macOS' build
  • xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug -destination 'platform=macOS' test -only-testing:cmuxTests/TerminalKeyboardCopyModeActionTests -only-testing:cmuxTests/CmuxWebViewKeyEquivalentTests

Closes #788

Summary by CodeRabbit

  • New Features

    • Keyboard copy mode for selecting and copying text via keyboard
    • Default shortcut: Cmd+Shift+M to toggle copy mode
    • VIM-style navigation, selection, searching, and scrolling while in copy mode
    • Visual "VI MODE" badge shown when copy mode is active
  • Tests

    • Added comprehensive test coverage for keyboard copy mode and shortcut behaviors

@vercel
Copy link

vercel bot commented Mar 3, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Mar 4, 2026 2:41am

@coderabbitai
Copy link

coderabbitai bot commented Mar 3, 2026

Warning

Rate limit exceeded

@lawrencecchen has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 0 minutes and 16 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

Run ID: 767b4e1c-4fb7-4f79-82f1-73fe6a0e1ec9

📥 Commits

Reviewing files that changed from the base of the PR and between 861f9d4 and d1186d5.

📒 Files selected for processing (4)
  • Sources/AppDelegate.swift
  • Sources/GhosttyTerminalView.swift
  • cmuxTests/AppDelegateShortcutRoutingTests.swift
  • cmuxTests/CmuxWebViewKeyEquivalentTests.swift
📝 Walkthrough

Walkthrough

Adds a terminal "keyboard copy mode": new shortcut and default binding, TabManager routing, Ghostty surface toggle and C APIs, comprehensive key-to-action mapping and state, UI badge indicator, and extensive tests for mappings and behavior.

Changes

Cohort / File(s) Summary
Copy‑Mode Core Logic
Sources/GhosttyTerminalView.swift
Implements keyboard copy-mode state, enums for moves/actions, input parsing/resolution, key event → action mapping, action dispatch (scroll/select/copy/exit), UI badge indicator, and view-level toggle/propagation.
Shortcut Routing & Dispatch
Sources/AppDelegate.swift, Sources/TabManager.swift
AppDelegate handles the .toggleTerminalCopyMode shortcut and delegates to TabManager.toggleFocusedTerminalCopyMode(), which calls the focused panel's surface to toggle keyboard copy mode.
Keyboard Shortcut Configuration
Sources/KeyboardShortcutSettings.swift
Adds toggleTerminalCopyMode action, label, defaults key, and default binding (Cmd+Shift+M).
Public API Extensions (ghostty)
ghostty.h, ghostty (submodule)
Adds C APIs ghostty_surface_select_cursor_cell() and ghostty_surface_clear_selection() and updates ghostty submodule reference.
Tests
cmuxTests/CmuxWebViewKeyEquivalentTests.swift
Adds tests for shortcut metadata, TerminalKeyboardCopyModeAction behaviors, resolution/count prefixes, viewport row logic, and indicator mounting/unmounting.

Sequence Diagram(s)

sequenceDiagram
    participant User as User
    participant AppDelegate
    participant TabManager
    participant TerminalSurface as TerminalSurface/GhosttyNSView
    participant KeyboardHandler as Key Event Handler
    participant Clipboard

    User->>AppDelegate: Trigger Cmd+Shift+M
    AppDelegate->>TabManager: toggleFocusedTerminalCopyMode()
    TabManager->>TerminalSurface: toggleKeyboardCopyMode()
    TerminalSurface->>TerminalSurface: set keyboardCopyModeActive / init state
    TerminalSurface-->>TabManager: return handled (true)
    TabManager-->>AppDelegate: return true

    Note over User,KeyboardHandler: In copy mode, keyboard drives navigation & selection
    User->>KeyboardHandler: Press navigation/key (e.g., j,k,G,y,Esc)
    KeyboardHandler->>TerminalSurface: terminalKeyboardCopyModeAction(...) -> action
    TerminalSurface->>TerminalSurface: apply action (scroll/select/copy/exit)
    TerminalSurface->>Clipboard: copy selected text (on copy)
    TerminalSurface->>TerminalSurface: clear selection & deactivate copy mode (on exit)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 I tapped my paws on keys at night,

Cmd+Shift+M — the copy-mode light.
I hopped through lines and yankéd with glee,
Clipboard cradled scrollback for me.
A tiny badge: "VI MODE" — hop, free!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.85% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Title clearly summarizes the main change—introducing keyboard copy mode for terminal scrollback navigation, which aligns with the primary objective of adding vi-style terminal copy mode.
Linked Issues check ✅ Passed The pull request implements all coding objectives from #788: keyboard copy mode entry via shortcut, vi-style navigation/selection, copy-to-clipboard, proper exit behavior, and test coverage for the feature.
Out of Scope Changes check ✅ Passed All changes are directly in scope: copy mode implementation, shortcut integration, UI badge display, test coverage, and necessary Ghostty submodule updates. No extraneous modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch issue-788-terminal-vi-copy-mode

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 3, 2026

Greptile Summary

This PR implements a keyboard-driven copy mode for terminal scrollback with vi-style navigation. Users can toggle copy mode with Cmd+Shift+M, navigate using j/k/h/l and arrow keys, start selection with 'v', and copy with 'y'.

Key Changes:

  • Implemented terminalKeyboardCopyModeAction function that maps vi keys (j/k/h/l, g/G, Ctrl+u/d) to scrolling and selection actions
  • Added bypass logic allowing Command shortcuts to work normally while in copy mode
  • Integrated two new Ghostty C APIs (ghostty_surface_select_cursor_cell, ghostty_surface_clear_selection) for selection management
  • Key events are consumed while in copy mode to prevent terminal input, exiting with 'q', 'Esc', or 'y' after copying
  • Comprehensive test coverage for key mappings, modifier handling, and shortcut registration

Confidence Score: 5/5

  • Safe to merge with no issues found
  • Clean implementation with comprehensive test coverage, proper integration across multiple files, and well-structured vi-style keyboard handling logic
  • No files require special attention

Important Files Changed

Filename Overview
Sources/GhosttyTerminalView.swift Adds keyboard copy mode with vi-style navigation (j/k/h/l, g/G, Ctrl+u/d, v/y/q) and proper modifier handling
Sources/KeyboardShortcutSettings.swift Registers new toggleTerminalCopyMode action with Cmd+Shift+M default shortcut
cmuxTests/CmuxWebViewKeyEquivalentTests.swift Comprehensive tests for copy mode key mappings, bypass logic, and shortcut metadata
ghostty.h Declares new C APIs for selection management: select_cursor_cell and clear_selection

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[User presses Cmd+Shift+M] --> B[AppDelegate.keyDown]
    B --> C[TabManager.toggleFocusedTerminalCopyMode]
    C --> D[TerminalSurface.toggleKeyboardCopyMode]
    D --> E[GhosttyNSView.toggleKeyboardCopyMode]
    E --> F{Toggle keyboardCopyModeActive}
    F -->|Mode ON| G[Copy mode activated]
    F -->|Mode OFF| H[ghostty_surface_clear_selection]
    
    G --> I[User presses key in copy mode]
    I --> J[GhosttyNSView.keyDown]
    J --> K{handleKeyboardCopyModeIfNeeded}
    K -->|Command modifier| L[Bypass copy mode]
    L --> M[Normal shortcut handling]
    K -->|Other key| N[terminalKeyboardCopyModeAction]
    
    N --> O{Determine action}
    O -->|j/k/arrows| P[Scroll lines or adjust selection]
    O -->|v| Q[Start/clear selection]
    O -->|y| R[Copy and exit]
    O -->|q/Esc| S[Exit copy mode]
    O -->|g/G| T[Scroll to top/bottom]
    O -->|Ctrl+u/d| U[Page up/down]
    
    P --> V[performBindingAction]
    Q --> W[ghostty_surface_select_cursor_cell]
    R --> X[performBindingAction copy_to_clipboard]
    X --> Y[Exit copy mode]
Loading

Last reviewed commit: e14914f

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
cmuxTests/CmuxWebViewKeyEquivalentTests.swift (1)

1337-1337: Rename testVGYMapping for precision.

Line 1337 validates v/y; g is covered in a separate test. Renaming improves scanability when failures are triaged.

Proposed rename
-    func testVGYMapping() {
+    func testVYMapping() {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/CmuxWebViewKeyEquivalentTests.swift` at line 1337, Rename the test
function testVGYMapping to reflect that it only validates 'v' and 'y' (for
example testVYMapping or testVAndYMapping) so it accurately describes its
coverage; update the function name in the declaration and any references to
testVGYMapping (e.g., in test registration or call sites) to the new name to
keep tests discoverable and failures easier to triage.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cmuxTests/CmuxWebViewKeyEquivalentTests.swift`:
- Around line 1388-1399: Add a regression test that verifies pressing the "q"
key maps to .exit in copy mode: create a new test (e.g., testQAlwaysExits)
alongside testEscapeAlwaysExits that calls terminalKeyboardCopyModeAction with
charactersIgnoringModifiers: "q" (or "Q" if uppercase handling required),
keyCode appropriate for "q" (or use character-based assertion), empty
modifierFlags, and hasSelection: false, and assert the result equals .exit to
cover the q-to-exit mapping in terminalKeyboardCopyModeAction.

In `@Sources/AppDelegate.swift`:
- Around line 5974-5980: The new copy-mode keyboard branch doesn't emit the
unified debug trace; add a debug log call using the project's dlog("...")
wrapper when the shortcut is matched — e.g., inside the if that calls
matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for:
.toggleTerminalCopyMode)) add a dlog message noting the shortcut hit and whether
toggleFocusedTerminalCopyMode() handled it; ensure the dlog call is wrapped in
`#if` DEBUG / `#endif` as required by the Swift debug logging convention and place
it near the call to tabManager?.toggleFocusedTerminalCopyMode() so key-event
observability is consistent.

In `@Sources/GhosttyTerminalView.swift`:
- Around line 3306-3308: The keyUp handler must not forward release events for
keys already consumed by copy mode; update the keyUp path (the method that
currently forwards release events) to consult handleKeyboardCopyModeIfNeeded or
track consumed keys and suppress forwarding when copy mode consumed the
key—reference the existing handleKeyboardCopyModeIfNeeded(surface:) call used in
keyDown and either call it from keyUp before forwarding or maintain a per-key
consumed set updated in keyDown and cleared in keyUp to ensure press/release
symmetry and prevent release-only events from reaching the terminal.

---

Nitpick comments:
In `@cmuxTests/CmuxWebViewKeyEquivalentTests.swift`:
- Line 1337: Rename the test function testVGYMapping to reflect that it only
validates 'v' and 'y' (for example testVYMapping or testVAndYMapping) so it
accurately describes its coverage; update the function name in the declaration
and any references to testVGYMapping (e.g., in test registration or call sites)
to the new name to keep tests discoverable and failures easier to triage.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a086ebc and e14914f.

📒 Files selected for processing (7)
  • Sources/AppDelegate.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/KeyboardShortcutSettings.swift
  • Sources/TabManager.swift
  • cmuxTests/CmuxWebViewKeyEquivalentTests.swift
  • ghostty
  • ghostty.h

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 7 files

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
cmuxTests/CmuxWebViewKeyEquivalentTests.swift (1)

1388-1399: ⚠️ Potential issue | 🟡 Minor

Add explicit regression coverage for q exit mapping.

Line 1388 currently validates Esc only; q is also an exit path and should be locked by test.

Proposed test addition
     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
+        )
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/CmuxWebViewKeyEquivalentTests.swift` around lines 1388 - 1399, Add
an explicit regression test that asserts the `terminalKeyboardCopyModeAction`
maps the 'q' key to `.exit` (in addition to the existing Esc test). Create a new
test (e.g., `testQAlwaysExits` or extend `testEscapeAlwaysExits`) that calls
`terminalKeyboardCopyModeAction` with the appropriate parameters for the 'q' key
(charactersIgnoringModifiers set to "q", empty modifierFlags, hasSelection
false) and XCTAssertEqual the result to `.exit`, referencing the
`terminalKeyboardCopyModeAction` function and the current
`testEscapeAlwaysExits` test as the model.
Sources/GhosttyTerminalView.swift (1)

3332-3334: ⚠️ Potential issue | 🟠 Major

Suppress release events for keys consumed by copy mode.

Line 3332 can consume keyDown, but keyUp still forwards a release unconditionally (Lines 3538-3552), causing press/release asymmetry and release-only leakage into terminal input.

🔧 Proposed fix
 override func keyUp(with event: NSEvent) {
     guard let surface = ensureSurfaceReadyForInput() else {
         super.keyUp(with: event)
         return
     }
+    if keyboardCopyModeActive,
+       !terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: event.modifierFlags) {
+        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.
     var keyEvent = ghosttyKeyEvent(for: event, surface: surface)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` around lines 3332 - 3334,
handleKeyboardCopyModeIfNeeded(event, surface:) can consume keyDown events but
keyUp currently always forwards releases, causing release-only leakage; fix by
tracking which keys copy-mode consumed and suppress their keyUp forwarding: add
a scoped Set (e.g., consumedKeysByCopyMode) keyed by the event identity you use
(keyCode, keyIdentifier, or charactersIgnoringModifiers) and when
handleKeyboardCopyModeIfNeeded(...) returns true in the keyDown path add that
identity to the set and return; in the keyUp handler check the same identity
against consumedKeysByCopyMode, and if present remove it and do not forward the
release to the terminal. Ensure the lookup uses the same identity logic in both
keyDown and keyUp and clean up entries to avoid leaks.
🧹 Nitpick comments (1)
Sources/GhosttyTerminalView.swift (1)

2372-2376: Avoid double copy-mode state propagation during toggle.

GhosttyNSView.toggleKeyboardCopyMode() already calls terminalSurface?.setKeyboardCopyModeActive(active), and TerminalSurface.toggleKeyboardCopyMode() calls setKeyboardCopyModeActive(...) again. Consider a single owner for this update path to avoid redundant UI/state churn.

Also applies to: 2912-2925

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` around lines 2372 - 2376, The
toggleKeyboardCopyMode path is causing duplicate state propagation:
GhosttyNSView.toggleKeyboardCopyMode() calls
surfaceView.toggleKeyboardCopyMode() and then calls
setKeyboardCopyModeActive(...) again, but surfaceView/TerminalSurface already
update the UI via terminalSurface?.setKeyboardCopyModeActive(active); remove the
redundant call in GhosttyTerminalView.toggleKeyboardCopyMode so only
surfaceView.toggleKeyboardCopyMode() drives the change (and mirror the same fix
for the similar block around lines 2912-2925). Ensure you keep the return value
(handled) and rely on
TerminalSurface.toggleKeyboardCopyMode()/terminalSurface?.setKeyboardCopyModeActive
to perform the UI update.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@cmuxTests/CmuxWebViewKeyEquivalentTests.swift`:
- Around line 1388-1399: Add an explicit regression test that asserts the
`terminalKeyboardCopyModeAction` maps the 'q' key to `.exit` (in addition to the
existing Esc test). Create a new test (e.g., `testQAlwaysExits` or extend
`testEscapeAlwaysExits`) that calls `terminalKeyboardCopyModeAction` with the
appropriate parameters for the 'q' key (charactersIgnoringModifiers set to "q",
empty modifierFlags, hasSelection false) and XCTAssertEqual the result to
`.exit`, referencing the `terminalKeyboardCopyModeAction` function and the
current `testEscapeAlwaysExits` test as the model.

In `@Sources/GhosttyTerminalView.swift`:
- Around line 3332-3334: handleKeyboardCopyModeIfNeeded(event, surface:) can
consume keyDown events but keyUp currently always forwards releases, causing
release-only leakage; fix by tracking which keys copy-mode consumed and suppress
their keyUp forwarding: add a scoped Set (e.g., consumedKeysByCopyMode) keyed by
the event identity you use (keyCode, keyIdentifier, or
charactersIgnoringModifiers) and when handleKeyboardCopyModeIfNeeded(...)
returns true in the keyDown path add that identity to the set and return; in the
keyUp handler check the same identity against consumedKeysByCopyMode, and if
present remove it and do not forward the release to the terminal. Ensure the
lookup uses the same identity logic in both keyDown and keyUp and clean up
entries to avoid leaks.

---

Nitpick comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 2372-2376: The toggleKeyboardCopyMode path is causing duplicate
state propagation: GhosttyNSView.toggleKeyboardCopyMode() calls
surfaceView.toggleKeyboardCopyMode() and then calls
setKeyboardCopyModeActive(...) again, but surfaceView/TerminalSurface already
update the UI via terminalSurface?.setKeyboardCopyModeActive(active); remove the
redundant call in GhosttyTerminalView.toggleKeyboardCopyMode so only
surfaceView.toggleKeyboardCopyMode() drives the change (and mirror the same fix
for the similar block around lines 2912-2925). Ensure you keep the return value
(handled) and rely on
TerminalSurface.toggleKeyboardCopyMode()/terminalSurface?.setKeyboardCopyModeActive
to perform the UI update.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e14914f and 77f4a06.

📒 Files selected for processing (2)
  • Sources/GhosttyTerminalView.swift
  • cmuxTests/CmuxWebViewKeyEquivalentTests.swift

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 77f4a06508

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if !handled {
NSSound.beep()
}
return true

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't consume copy-mode shortcut outside terminal focus

This handler always returns true once the shortcut matches, even when toggleFocusedTerminalCopyMode() fails (for example, when a browser panel or another non-terminal responder is focused), so the local shortcut monitor swallows Cmd+Shift+M and the focused control never receives it. In practice this breaks browser/web-app shortcuts in those contexts and only plays a beep; returning false when no terminal panel is actionable would preserve expected responder behavior.

Useful? React with 👍 / 👎.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
Sources/GhosttyTerminalView.swift (1)

3819-3833: ⚠️ Potential issue | 🟠 Major

Suppress keyUp forwarding for keys consumed by copy mode.

keyDown is consumed in copy mode (Line 3613), but keyUp still always emits release events (Line 3819 onward). That leaks release-only events into terminal handling and breaks press/release symmetry.

🔧 Proposed fix
 override func keyUp(with event: NSEvent) {
     guard let surface = ensureSurfaceReadyForInput() else {
         super.keyUp(with: event)
         return
     }
+    if keyboardCopyModeActive,
+       !terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: event.modifierFlags) {
+        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.
     var keyEvent = ghosttyKeyEvent(for: event, surface: surface)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` around lines 3819 - 3833, keyUp is
currently always forwarding release events (using ghosttyKeyEvent(...) and
sendGhosttyKey(...)) even when keyDown consumed the event in copy mode;
replicate the same consume-check used in keyDown (or track consumed key
identities on keyDown) and early-return to super.keyUp when the key was
consumed, instead of building and sending a GHOSTTY_ACTION_RELEASE event.
Concretely: either call the same copy-mode check from keyDown inside keyUp
(using ensureSurfaceReadyForInput() and the surface's copy-mode predicate) or
maintain a small Set of consumed key identifiers populated in keyDown and
consulted/cleared in keyUp before calling ghosttyKeyEvent(...) /
sendGhosttyKey(...).
cmuxTests/CmuxWebViewKeyEquivalentTests.swift (1)

1544-1555: ⚠️ Potential issue | 🟡 Minor

Add a regression assertion for q-to-exit mapping.

Line 1544 currently verifies Esc only; copy-mode exit behavior also includes q, so this leaves a coverage gap.

Proposed test addition
     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
+        )
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/CmuxWebViewKeyEquivalentTests.swift` around lines 1544 - 1555, Add
a sibling regression assertion to testEscapeAlwaysExits that verifies the 'q'
key also maps to .exit: call terminalKeyboardCopyModeAction with
charactersIgnoringModifiers "q" (and an appropriate keyCode for 'q', no
modifierFlags, hasSelection: false) and assert the result equals .exit; mirror
the existing test structure so both Esc and 'q' coverage exists.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 3224-3227: The scroll handlers for .scrollLines and .scrollPage
perform binding actions but don't refresh the copy-mode viewport anchor
(keyboardCopyModeViewportRow), causing stale targets for yy/Y; after the
performBindingAction call in the .scrollLines and .scrollPage cases, invoke the
method that recomputes the copy-mode viewport anchor (e.g.,
refreshKeyboardCopyModeViewportRow() or updateKeyboardCopyModeViewportRow()) so
the keyboardCopyModeViewportRow is recalculated immediately after scrolling;
ensure you call this for both branches (the one using the ignored return and the
one using repeatCount: count).

---

Duplicate comments:
In `@cmuxTests/CmuxWebViewKeyEquivalentTests.swift`:
- Around line 1544-1555: Add a sibling regression assertion to
testEscapeAlwaysExits that verifies the 'q' key also maps to .exit: call
terminalKeyboardCopyModeAction with charactersIgnoringModifiers "q" (and an
appropriate keyCode for 'q', no modifierFlags, hasSelection: false) and assert
the result equals .exit; mirror the existing test structure so both Esc and 'q'
coverage exists.

In `@Sources/GhosttyTerminalView.swift`:
- Around line 3819-3833: keyUp is currently always forwarding release events
(using ghosttyKeyEvent(...) and sendGhosttyKey(...)) even when keyDown consumed
the event in copy mode; replicate the same consume-check used in keyDown (or
track consumed key identities on keyDown) and early-return to super.keyUp when
the key was consumed, instead of building and sending a GHOSTTY_ACTION_RELEASE
event. Concretely: either call the same copy-mode check from keyDown inside
keyUp (using ensureSurfaceReadyForInput() and the surface's copy-mode predicate)
or maintain a small Set of consumed key identifiers populated in keyDown and
consulted/cleared in keyUp before calling ghosttyKeyEvent(...) /
sendGhosttyKey(...).

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 91b01e53-52c8-4b23-9552-8456b93d15d3

📥 Commits

Reviewing files that changed from the base of the PR and between 77f4a06 and 861f9d4.

📒 Files selected for processing (2)
  • Sources/GhosttyTerminalView.swift
  • cmuxTests/CmuxWebViewKeyEquivalentTests.swift

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d1186d5cf1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +3076 to +3078
keyboardCopyModeInputState.reset()
keyboardCopyModeActive = active
if active, let surface {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear suppressed key-up cache when copy mode state changes

setKeyboardCopyModeActive(_:) resets the copy-mode input parser but leaves keyboardCopyModeConsumedKeyUps untouched, even though that set is only drained in keyUp(with:). If a copy-mode-consumed key-down is followed by a focus/responder transition (so its key-up is never delivered here), the stale key code survives and the next normal key-up for that code gets dropped, creating a press-without-release sequence in Ghostty. Clearing the suppression set when copy mode is toggled (and on focus transitions) prevents this stuck-key behavior.

Useful? React with 👍 / 👎.

Comment on lines +5982 to +5984
// Only consume when a focused terminal actually handled the toggle.
// Otherwise allow the event to continue through the responder chain.
return handled

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Suppress key-up after consuming Cmd+Shift+M in shortcut monitor

This branch consumes Cmd+Shift+M at the app shortcut layer (return handled), which prevents GhosttyNSView.keyDown from sending a matching press for keycode 46, but GhosttyNSView.keyUp still emits an unconditional GHOSTTY_ACTION_RELEASE unless that key was marked by the copy-mode handler. When a terminal is focused and this toggle shortcut succeeds, terminal apps can receive a release-without-press m event; this shortcut path should also register key-up suppression.

Useful? React with 👍 / 👎.

0xble added a commit to 0xble/cmux that referenced this pull request Mar 7, 2026
@mz026
Copy link

mz026 commented Mar 26, 2026

Hi, thanks for implementing this feature! May I know when I enter the Copy Mode, is there a way to show where the cursor is?

Currently when using V to copy, it selects the text from the top of the screen. Is there a way to show/move the cursor so that we can copy from else where?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add terminal vi/copy mode for keyboard scrollback + copy

2 participants