Skip to content

cmux-next: keyboard copy mode (Toggle Copy Mode, ⇧⌘M) - #16450

Merged
teamleaderleo merged 4 commits into
feat-cmux-nextfrom
feat-cmux-next-copy-mode
Oct 1, 2026
Merged

teamleaderleo merged 4 commits into
feat-cmux-nextfrom
feat-cmux-next-copy-mode

Conversation

@teamleaderleo

@teamleaderleo teamleaderleo commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

Part of the cmux-next parity work listed in #16174 (comment): #16174 (comment)

Problem

Toggle Copy Mode (⇧⌘M) was registered as unavailable in cmux-next ("needs a keyboard copy mode in the cmux-next terminal"). The old app has a vim-style copy mode over the scrollback.

Behavior

Same keys as the old app:

  • h/j/k/l and the arrows move the cursor; 0 or ^ and $ go to the start and end of the line; gg and G go to the top and bottom.
  • Ctrl-U/D move a half page, Ctrl-B/F a page, Ctrl-Y/E a line. Page Up/Down, Home and End also work.
  • v starts or clears a character selection and V a line selection. y copies the selection and exits; yy and Y copy lines. Digits are a count.
  • { and } jump between prompts. / opens the find prompt (the same one ⌘F opens), and n/N step through matches.
  • Esc or q exits. Command chords pass through, so ⌘C and ⇧⌘M still work. Every other key is swallowed and never reaches the shell.

While copy mode is on, a box marks the cursor cell and a "vim" pill sits in the terminal's top-right corner, as in the old app.

How

  • CmuxNextCopyMode (new, pure, tested): the key table and count/gg/yy state, ported from the old CmuxTerminalCore/CopyMode resolver. It also has the cursor box geometry.
  • TerminalSurfaceView+CopyMode: drives Ghostty's keyboard-copy API from the cmux fork (ghostty_surface_keyboard_copy_*, keyboard_selection_move, copy_selection_to_clipboard_bounded; the pinned ghostty already exports them). The old app used the same calls. Ghostty owns the cursor, selection and viewport, so the old Swift cursor and visual-line models are not ported.
  • keyDown gives copy mode the key first, and keyUp swallows the matching release. The cursor box resyncs on every key, scrollbar update and resize.
  • TerminalHostAction.find routes to the registry's find action, so / uses the same path as ⌘F.
  • toggleTerminalCopyMode is bound in TerminalHandlers. The copyModeUnported refusal and its strings are removed.
  • inventory.md: the CmuxTerminalCore row said copy mode would be deleted in favor of "cmux-tui copy mode", but cmux-tui has none. The row now points at the new code.

Not ported: the old soft-wrap re-join after copy (Ghostty's clipboard formatter unwraps soft wraps itself) and the old rendered-frame demand that kept the cursor box in sync on every frame. Here it resyncs on keys, scrollbar updates and resizes.

Tests

  • CmuxNextCopyModeTests: the old resolver suite's cases (non-ASCII layouts, Caps Lock, line bounds, pending g/y, V/Y variants), plus counts, clamping, scroll keys, prompts, search, exit, the Command bypass and the cursor box geometry.
  • TerminalCopyModeTests (app): the action is bound and toggles copy mode on its terminal. Plain keys stay in copy mode, q and Esc exit, Esc's key-up is swallowed, and Command chords pass through and reset the count. / routes to find.

Localization

One new string, terminal.copyMode.indicator ("vim", the old app's indicator text, in every locale the terminal catalog has). One string removed, handlers.refusal.copyModeUnported. A failed entry reuses the existing ghosttyRejected refusal.

Evidence

This is a UI change. cmux-next has no CI screenshot path yet (session cc-next-ci-media is building one), so there are no screenshots here yet; they will be added from the CI tour once it exists.

Credit: the original copy mode is by Lawrence Chen (#792), with follow-ups by Austin Wang. Both are staff, so there is no Co-authored-by line.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Adds vim-style keyboard copy mode (⇧⌘M) to the cmux-next terminal, replacing the "unavailable" refusal and restoring parity with the old app's copy mode over the scrollback.

Behavior

  • Same keys as the old app: h/j/k/l and arrows move, 0/^/$ line boundaries, gg/G top/bottom, Ctrl-U/D/B/F/Y/E scroll, v/V/y/yy/Y select and yank, / opens the same find prompt as ⌘F, {/} jump between prompts, Esc or q exits.
  • Command chords still work (⌘C, ⇧⌘M); every other key is swallowed while copy mode is on.
  • A cursor box and a "vim" pill appear while active, as in the old app.

Implementation

  • New pure CmuxNextCopyMode target holds the key table (a struct carrying its layout lookup) and cursor-box geometry, ported from the old resolver, with its own test suite.
  • TerminalSurfaceView+CopyMode drives Ghostty's keyboard-copy API, which owns the cursor, selection, and viewport, so the old Swift cursor/selection models weren't ported.
  • h/j/k/l resolve through the ASCII-capable layout, so they work under Korean or Russian input; entering copy mode discards an unfinished IME composition.
  • / routes through a new find host action, reusing the ⌘F prompt; live-surface tests self-skip where no Ghostty surface can be created, and the binding test acts through a windowed tab.
  • Depends on the pinned cmux Ghostty fork already exporting the keyboard-copy calls.
  • Adds the terminal.copyMode.indicator string; removes the copyModeUnported refusal.

Written for commit f816959. Summary will update on new commits.

Review in cubic

Port the old app's vim-style copy mode to the cmux-next terminal. The key
table moves to a pure CmuxNextCopyMode target with its tests; the terminal
view drives Ghostty's keyboard-copy API (cursor, selection, viewport,
bounded clipboard copy), draws the cursor box and the "vim" badge, and
takes every key except Command chords until Esc, q, or a copy. `/` opens
the same find prompt as ⌘F through a new `find` host action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository: manaflow-ai/cmux/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: caa321cc-ae0a-44e9-8bb0-644ee6e9d19c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Review fixes for the copy mode port:
- Resolve h/j/k/l and the other command keys through the ASCII-capable
  layout, as the old app did, so they work under Korean or Russian input.
- Discard an unfinished IME composition on entering copy mode.
- The action test now targets a tab shown in a window, which is the only
  way the handler resolves a terminal. The live-surface suite reports
  itself skipped where ghostty_surface_new cannot succeed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cursor

cursor Bot commented Oct 1, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

6 issues found and verified against the latest diff

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView.swift">

<violation number="1" location="Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView.swift:197">
P2: The cursor box remains stale when cell metrics change without a frame resize, such as a font-size or backing-scale change during copy mode. Resynchronize it after every successful `updateSurfaceSize()` path, ideally from that method itself.</violation>
</file>

<file name="Packages/macOS/CmuxNext/Sources/CmuxNextCopyMode/CopyModeKeys.swift">

<violation number="1" location="Packages/macOS/CmuxNext/Sources/CmuxNextCopyMode/CopyModeKeys.swift:150">
P2: `resolve` bypasses the modifier guard for uppercase `Y` and `G`, so non-Command chords such as Option+Shift can copy or jump instead of being swallowed. Apply the same empty-or-Shift guard used by `action`.</violation>

<violation number="2" location="Packages/macOS/CmuxNext/Sources/CmuxNextCopyMode/CopyModeKeys.swift:211">
P2: Caps Lock plus Shift is incorrectly classified as uppercase, so Shift+V/Y/G/N can run uppercase commands instead of their lowercase behavior. Check the raw Caps Lock flag before treating Shift as uppercase.</violation>
</file>

<file name="Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/TerminalHandlers.swift">

<violation number="1" location="Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/TerminalHandlers.swift:88">
P3: The refusal message exposes an internal C API name: `ghosttyRejected("keyboard_copy_cursor_set")` renders as "Ghostty rejected keyboard_copy_cursor_set", but that is the function name `ghostty_surface_keyboard_copy_cursor_set`, not a config key or action the user can touch. Every other `ghosttyRejected` call in this file passes a user-facing Ghostty binding action (e.g. `scroll_page_up`). When entering copy mode fails here (unsupported or not-yet-ready surface), the toast gives the user nothing actionable. Use a refusal that says copy mode isn't available on that surface, or names the config that enables it.</violation>
</file>

<file name="Packages/macOS/CmuxNext/Sources/CmuxNextCopyMode/CopyModeAction.swift">

<violation number="1" location="Packages/macOS/CmuxNext/Sources/CmuxNextCopyMode/CopyModeAction.swift:22">
P2: TerminalSurfaceView+CopyMode's `performCopyMode` runs `.scrollToTop`/`.scrollToBottom` through `moveCopyModeCursor(.home/.end)`, i.e. `ghostty_surface_keyboard_selection_move` with `GHOSTTY_KEYBOARD_SELECTION_MOVE_HOME/END` — a cursor move, not a viewport/scrollback scroll. If Ghostty's HOME/END moves are screen-relative (as in Ghostty's own copy mode, where scroll-to-top/bottom are separate scroll actions), `gg`/`G` will only reach the visible screen edges and never the ends of a long scrollback, contradicting this doc. The available scroll kinds are only LINES/PAGES/HALF_PAGES/PROMPTS. Verify the move semantics against ghostty.h; if screen-relative, add a scroll-to-absolute path or document the viewport-relative behavior.</violation>
</file>

<file name="Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView+Keyboard.swift">

<violation number="1" location="Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView+Keyboard.swift:83">
P3: `handleCopyModeKeyUp` swallows a key-up whenever its key-code is in `copyModeConsumedKeyUps`, and that set is only drained by the matching key-up. The pairing breaks when copy mode is toggled off mid-hold: a key-down consumed by copy mode then gets repeat key-downs routed to Ghostty (copy mode is nil, so `handleCopyModeKeyDown` returns false) while its release is still swallowed, so Ghostty receives `GHOSTTY_ACTION_REPEAT` presses with no press and no release and the terminal can emit phantom repeated characters. Likewise, a key-up that is only genuinely missed (e.g., window lost between down and up) leaves the key-code in the set past `exitCopyMode`, so the next normal-mode press of that key sends press to Ghostty but has its release swallowed, leaving a stuck/auto-repeating key until the same key is pressed again. Bound the swallow to keys actually taken in the current session or drain the set when leaving copy mode (keeping only the release of the key that triggered the exit).</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

public override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
updateSurfaceSize()
syncCopyModeCursor()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The cursor box remains stale when cell metrics change without a frame resize, such as a font-size or backing-scale change during copy mode. Resynchronize it after every successful updateSurfaceSize() path, ideally from that method itself.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView.swift, line 197:

<comment>The cursor box remains stale when cell metrics change without a frame resize, such as a font-size or backing-scale change during copy mode. Resynchronize it after every successful `updateSurfaceSize()` path, ideally from that method itself.</comment>

<file context>
@@ -188,6 +194,7 @@ public final class TerminalSurfaceView: NSView {
     public override func setFrameSize(_ newSize: NSSize) {
         super.setFrameSize(newSize)
         updateSurfaceSize()
+        syncCopyModeCursor()
     }
 
</file context>

chars = first
}
lowercased = chars.lowercased()
if modifiers == [.shift] {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Caps Lock plus Shift is incorrectly classified as uppercase, so Shift+V/Y/G/N can run uppercase commands instead of their lowercase behavior. Check the raw Caps Lock flag before treating Shift as uppercase.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextCopyMode/CopyModeKeys.swift, line 211:

<comment>Caps Lock plus Shift is incorrectly classified as uppercase, so Shift+V/Y/G/N can run uppercase commands instead of their lowercase behavior. Check the raw Caps Lock flag before treating Shift as uppercase.</comment>

<file context>
@@ -0,0 +1,222 @@
+                chars = first
+            }
+            lowercased = chars.lowercased()
+            if modifiers == [.shift] {
+                isUppercase = true
+            } else if raw.contains(.capsLock) {
</file context>

Comment on lines +150 to +151
if !hasSelection, lower == "y", key.isUppercase { return perform(.copyLineAndExit) }
if lower == "g", key.isUppercase { return perform(hasSelection ? .adjustSelection(.end) : .scrollToBottom) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: resolve bypasses the modifier guard for uppercase Y and G, so non-Command chords such as Option+Shift can copy or jump instead of being swallowed. Apply the same empty-or-Shift guard used by action.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextCopyMode/CopyModeKeys.swift, line 150:

<comment>`resolve` bypasses the modifier guard for uppercase `Y` and `G`, so non-Command chords such as Option+Shift can copy or jump instead of being swallowed. Apply the same empty-or-Shift guard used by `action`.</comment>

<file context>
@@ -0,0 +1,222 @@
+                return .consume
+            }
+        }
+        if !hasSelection, lower == "y", key.isUppercase { return perform(.copyLineAndExit) }
+        if lower == "g", key.isUppercase { return perform(hasSelection ? .adjustSelection(.end) : .scrollToBottom) }
+        if !hasSelection, lower == "y", mods.isEmpty {
</file context>
Suggested change
if !hasSelection, lower == "y", key.isUppercase { return perform(.copyLineAndExit) }
if lower == "g", key.isUppercase { return perform(hasSelection ? .adjustSelection(.end) : .scrollToBottom) }
if !hasSelection, lower == "y", key.isUppercase, mods.isEmpty || mods == [.shift] { return perform(.copyLineAndExit) }
if lower == "g", key.isUppercase, mods.isEmpty || mods == [.shift] { return perform(hasSelection ? .adjustSelection(.end) : .scrollToBottom) }

case scrollPage(Int)
/// Scrolls the viewport by signed half pages (Ctrl-U, Ctrl-D).
case scrollHalfPage(Int)
/// Moves to the top-left cell of the scrollback (`gg`, Home).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: TerminalSurfaceView+CopyMode's performCopyMode runs .scrollToTop/.scrollToBottom through moveCopyModeCursor(.home/.end), i.e. ghostty_surface_keyboard_selection_move with GHOSTTY_KEYBOARD_SELECTION_MOVE_HOME/END — a cursor move, not a viewport/scrollback scroll. If Ghostty's HOME/END moves are screen-relative (as in Ghostty's own copy mode, where scroll-to-top/bottom are separate scroll actions), gg/G will only reach the visible screen edges and never the ends of a long scrollback, contradicting this doc. The available scroll kinds are only LINES/PAGES/HALF_PAGES/PROMPTS. Verify the move semantics against ghostty.h; if screen-relative, add a scroll-to-absolute path or document the viewport-relative behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextCopyMode/CopyModeAction.swift, line 22:

<comment>TerminalSurfaceView+CopyMode's `performCopyMode` runs `.scrollToTop`/`.scrollToBottom` through `moveCopyModeCursor(.home/.end)`, i.e. `ghostty_surface_keyboard_selection_move` with `GHOSTTY_KEYBOARD_SELECTION_MOVE_HOME/END` — a cursor move, not a viewport/scrollback scroll. If Ghostty's HOME/END moves are screen-relative (as in Ghostty's own copy mode, where scroll-to-top/bottom are separate scroll actions), `gg`/`G` will only reach the visible screen edges and never the ends of a long scrollback, contradicting this doc. The available scroll kinds are only LINES/PAGES/HALF_PAGES/PROMPTS. Verify the move semantics against ghostty.h; if screen-relative, add a scroll-to-absolute path or document the viewport-relative behavior.</comment>

<file context>
@@ -0,0 +1,59 @@
+    case scrollPage(Int)
+    /// Scrolls the viewport by signed half pages (Ctrl-U, Ctrl-D).
+    case scrollHalfPage(Int)
+    /// Moves to the top-left cell of the scrollback (`gg`, Home).
+    case scrollToTop
+    /// Moves to the bottom-right cell (`G`, End).
</file context>

private static func bindCopyMode(_ registry: ActionRegistry, _ ctx: AppActionContext) {
registry.bind("toggleTerminalCopyMode", invoke: { invocation in
guard let entry = ctx.terminal(invocation) else { return }
if !entry.session.surfaceView.toggleCopyMode() { ctx.refuse(RefusalStrings.ghosttyRejected("keyboard_copy_cursor_set")) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The refusal message exposes an internal C API name: ghosttyRejected("keyboard_copy_cursor_set") renders as "Ghostty rejected keyboard_copy_cursor_set", but that is the function name ghostty_surface_keyboard_copy_cursor_set, not a config key or action the user can touch. Every other ghosttyRejected call in this file passes a user-facing Ghostty binding action (e.g. scroll_page_up). When entering copy mode fails here (unsupported or not-yet-ready surface), the toast gives the user nothing actionable. Use a refusal that says copy mode isn't available on that surface, or names the config that enables it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/TerminalHandlers.swift, line 88:

<comment>The refusal message exposes an internal C API name: `ghosttyRejected("keyboard_copy_cursor_set")` renders as "Ghostty rejected keyboard_copy_cursor_set", but that is the function name `ghostty_surface_keyboard_copy_cursor_set`, not a config key or action the user can touch. Every other `ghosttyRejected` call in this file passes a user-facing Ghostty binding action (e.g. `scroll_page_up`). When entering copy mode fails here (unsupported or not-yet-ready surface), the toast gives the user nothing actionable. Use a refusal that says copy mode isn't available on that surface, or names the config that enables it.</comment>

<file context>
@@ -79,8 +80,16 @@ enum TerminalHandlers {
+    private static func bindCopyMode(_ registry: ActionRegistry, _ ctx: AppActionContext) {
+        registry.bind("toggleTerminalCopyMode", invoke: { invocation in
+            guard let entry = ctx.terminal(invocation) else { return }
+            if !entry.session.surfaceView.toggleCopyMode() { ctx.refuse(RefusalStrings.ghosttyRejected("keyboard_copy_cursor_set")) }
+        })
+    }
</file context>

}

public override func keyUp(with event: NSEvent) {
if handleCopyModeKeyUp(event) { return }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: handleCopyModeKeyUp swallows a key-up whenever its key-code is in copyModeConsumedKeyUps, and that set is only drained by the matching key-up. The pairing breaks when copy mode is toggled off mid-hold: a key-down consumed by copy mode then gets repeat key-downs routed to Ghostty (copy mode is nil, so handleCopyModeKeyDown returns false) while its release is still swallowed, so Ghostty receives GHOSTTY_ACTION_REPEAT presses with no press and no release and the terminal can emit phantom repeated characters. Likewise, a key-up that is only genuinely missed (e.g., window lost between down and up) leaves the key-code in the set past exitCopyMode, so the next normal-mode press of that key sends press to Ghostty but has its release swallowed, leaving a stuck/auto-repeating key until the same key is pressed again. Bound the swallow to keys actually taken in the current session or drain the set when leaving copy mode (keeping only the release of the key that triggered the exit).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView+Keyboard.swift, line 83:

<comment>`handleCopyModeKeyUp` swallows a key-up whenever its key-code is in `copyModeConsumedKeyUps`, and that set is only drained by the matching key-up. The pairing breaks when copy mode is toggled off mid-hold: a key-down consumed by copy mode then gets repeat key-downs routed to Ghostty (copy mode is nil, so `handleCopyModeKeyDown` returns false) while its release is still swallowed, so Ghostty receives `GHOSTTY_ACTION_REPEAT` presses with no press and no release and the terminal can emit phantom repeated characters. Likewise, a key-up that is only genuinely missed (e.g., window lost between down and up) leaves the key-code in the set past `exitCopyMode`, so the next normal-mode press of that key sends press to Ghostty but has its release swallowed, leaving a stuck/auto-repeating key until the same key is pressed again. Bound the swallow to keys actually taken in the current session or drain the set when leaving copy mode (keeping only the release of the key that triggered the exit).</comment>

<file context>
@@ -77,6 +80,7 @@ extension TerminalSurfaceView {
     }
 
     public override func keyUp(with event: NSEvent) {
+        if handleCopyModeKeyUp(event) { return }
         sendKey(GHOSTTY_ACTION_RELEASE, event: event)
     }
</file context>

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

3 existing issues remain and 4 new issues found across 20 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView+CopyMode.swift">

<violation number="1" location="Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView+CopyMode.swift:130">
P2: Counted `gg`/`G` commands lose their count here: `3gg` and `3G` always perform a single absolute jump. Preserve the count in these actions and implement the corresponding counted jump semantics.</violation>
</file>

<file name="Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalCopyModeBadge.swift">

<violation number="1" location="Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalCopyModeBadge.swift:41">
P3: The badge is exposed as its own accessibility element (`setAccessibilityElement(true)` + `setAccessibilityRole(.staticText)` + label "vim") while its two subviews remain separate accessibility elements: the `NSImageView` and the "vim" `NSTextField`. VoiceOver will announce the badge's "vim" label, the image, and the label's "vim" text again, producing duplicated or garbled output. Mark the subviews as non-elements so the badge reads as a single "vim" static text.</violation>
</file>

<file name="Packages/macOS/CmuxNext/Sources/CmuxNextCopyMode/CopyModeAction.swift">

<violation number="1" location="Packages/macOS/CmuxNext/Sources/CmuxNextCopyMode/CopyModeAction.swift:58">
P3: The `consume` case documentation says it only covers pending-state updates, but `CopyModeKeys.resolve` returns `.consume` for swallowed ordinary keys too (after `state.reset()`). Clarify that `consume` means the key was taken with nothing to execute, including unrecognized keys, so the case isn't mistaken for a promise that pending state changed.</violation>
</file>

<file name="Packages/macOS/CmuxNext/Sources/CmuxNextCopyMode/CopyModeKeys.swift">

<violation number="1" location="Packages/macOS/CmuxNext/Sources/CmuxNextCopyMode/CopyModeKeys.swift:85">
P2: The `^` command does not work on the standard macOS layout. Handle the unshifted `6` representation of Shift+6, just as the adjacent `$` handling handles `4`.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 3 unresolved issues already reported by Cubic.

Re-trigger cubic

case .jumpToPrompt(let delta):
copyModeScroll(GHOSTTY_KEYBOARD_COPY_SCROLL_PROMPTS, delta * count, surface: surface)
case .scrollToTop:
moveCopyModeCursor(.home, count: 1, surface: surface)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Counted gg/G commands lose their count here: 3gg and 3G always perform a single absolute jump. Preserve the count in these actions and implement the corresponding counted jump semantics.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView+CopyMode.swift, line 130:

<comment>Counted `gg`/`G` commands lose their count here: `3gg` and `3G` always perform a single absolute jump. Preserve the count in these actions and implement the corresponding counted jump semantics.</comment>

<file context>
@@ -0,0 +1,236 @@
+        case .jumpToPrompt(let delta):
+            copyModeScroll(GHOSTTY_KEYBOARD_COPY_SCROLL_PROMPTS, delta * count, surface: surface)
+        case .scrollToTop:
+            moveCopyModeCursor(.home, count: 1, surface: surface)
+        case .scrollToBottom:
+            moveCopyModeCursor(.end, count: 1, surface: surface)
</file context>

case "g":
guard key.isUppercase else { return nil }
return hasSelection ? .adjustSelection(.end) : .scrollToBottom
case "0", "^":

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The ^ command does not work on the standard macOS layout. Handle the unshifted 6 representation of Shift+6, just as the adjacent $ handling handles 4.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextCopyMode/CopyModeKeys.swift, line 85:

<comment>The `^` command does not work on the standard macOS layout. Handle the unshifted `6` representation of Shift+6, just as the adjacent `$` handling handles `4`.</comment>

<file context>
@@ -0,0 +1,222 @@
+        case "g":
+            guard key.isUppercase else { return nil }
+            return hasSelection ? .adjustSelection(.end) : .scrollToBottom
+        case "0", "^":
+            return .adjustSelection(.beginningOfLine)
+        case "$", "4":
</file context>

Comment on lines +41 to +43
setAccessibilityElement(true)
setAccessibilityRole(.staticText)
setAccessibilityLabel(Self.text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The badge is exposed as its own accessibility element (setAccessibilityElement(true) + setAccessibilityRole(.staticText) + label "vim") while its two subviews remain separate accessibility elements: the NSImageView and the "vim" NSTextField. VoiceOver will announce the badge's "vim" label, the image, and the label's "vim" text again, producing duplicated or garbled output. Mark the subviews as non-elements so the badge reads as a single "vim" static text.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalCopyModeBadge.swift, line 41:

<comment>The badge is exposed as its own accessibility element (`setAccessibilityElement(true)` + `setAccessibilityRole(.staticText)` + label "vim") while its two subviews remain separate accessibility elements: the `NSImageView` and the "vim" `NSTextField`. VoiceOver will announce the badge's "vim" label, the image, and the label's "vim" text again, producing duplicated or garbled output. Mark the subviews as non-elements so the badge reads as a single "vim" static text.</comment>

<file context>
@@ -0,0 +1,52 @@
+            label.topAnchor.constraint(equalTo: topAnchor, constant: 6),
+            label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -6),
+        ])
+        setAccessibilityElement(true)
+        setAccessibilityRole(.staticText)
+        setAccessibilityLabel(Self.text)
</file context>
Suggested change
setAccessibilityElement(true)
setAccessibilityRole(.staticText)
setAccessibilityLabel(Self.text)
icon.setAccessibilityElement(false)
label.setAccessibilityElement(false)
setAccessibilityElement(true)
setAccessibilityRole(.staticText)
setAccessibilityLabel(Self.text)

/// update pending state (a count prefix, the first `g` or `y`).
public enum CopyModeResolution: Equatable, Sendable {
case perform(CopyModeAction, count: Int)
case consume

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The consume case documentation says it only covers pending-state updates, but CopyModeKeys.resolve returns .consume for swallowed ordinary keys too (after state.reset()). Clarify that consume means the key was taken with nothing to execute, including unrecognized keys, so the case isn't mistaken for a promise that pending state changed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextCopyMode/CopyModeAction.swift, line 58:

<comment>The `consume` case documentation says it only covers pending-state updates, but `CopyModeKeys.resolve` returns `.consume` for swallowed ordinary keys too (after `state.reset()`). Clarify that `consume` means the key was taken with nothing to execute, including unrecognized keys, so the case isn't mistaken for a promise that pending state changed.</comment>

<file context>
@@ -0,0 +1,59 @@
+/// update pending state (a count prefix, the first `g` or `y`).
+public enum CopyModeResolution: Equatable, Sendable {
+    case perform(CopyModeAction, count: Int)
+    case consume
+}
</file context>

The suite's .enabled trait named the suite itself, which the Suite macro
cannot resolve ("circular reference resolving attached macro 'Suite'").
The probe now lives in CopyModeLiveSurface. The action test also requires
the window to show the pane before it acts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Independent subagent review (read-only, no build):

Head 3686ccb: fix first. The key resolver is a faithful port. Findings:

  • The action test could not resolve a terminal, because no window showed the tab.
  • The surface tests need a live Ghostty surface.
  • h/j/k/l and the other command keys regressed under non-Latin layouts, because no ASCII layout provider was passed.
  • An unfinished IME composition stayed on screen.

Fixes in b904219:

  • ASCII-capable layout lookup, ported from the old app's KeyboardLayout.character(forKeyCode:).
  • IME preedit discarded on entering copy mode.
  • The action test targets a tab shown in a window.
  • Live-surface tests are gated behind an .enabled trait with a reason.

Re-review of b904219: ship. The provider matches the old one. It has no cost outside copy mode.

CI then found a compile error the review missed: the suite's trait named the suite itself (circular reference resolving attached macro 'Suite'). c75b700 moves the probe into CopyModeLiveSurface.

Still open:

  • If CI has no Metal device, the live-surface suite reports as skipped. That includes the action test, so check the CI log for executed versus skipped before treating it as coverage.
  • Not ported: soft-wrap re-join on copy, and per-frame cursor box tracking. The box resyncs on keys, scrollbar updates and resizes.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalCopyModeTests.swift">

<violation number="1" location="Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalCopyModeTests.swift:102">
P3: Every failure inside `make()` collapses into `nil`: a `BridgeTreeFixture.tree()` decode error returns nil exactly like a missing tab or an unavailable surface, so `try #require(CopyModeLiveSurface.make())` in `terminal()` only reports "returned nil" instead of the underlying JSON/decoding error that the old `try BridgeTreeFixture.tree()` propagated, and a fixture failure disables the whole suite under the misleading "needs a live Ghostty surface" reason. Make `make()` throw for fixture/service failures and let `available()` use `try? make()` so only surface availability drives the skip and test failures surface the real cause.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

enum CopyModeLiveSurface {
static func make() -> (services: AppServices, tab: TabModel, view: TerminalSurfaceView)? {
let services = ActionBindingCoverageTests.boundServices()
guard let tree = try? BridgeTreeFixture.tree() else { return nil }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: Every failure inside make() collapses into nil: a BridgeTreeFixture.tree() decode error returns nil exactly like a missing tab or an unavailable surface, so try #require(CopyModeLiveSurface.make()) in terminal() only reports "returned nil" instead of the underlying JSON/decoding error that the old try BridgeTreeFixture.tree() propagated, and a fixture failure disables the whole suite under the misleading "needs a live Ghostty surface" reason. Make make() throw for fixture/service failures and let available() use try? make() so only surface availability drives the skip and test failures surface the real cause.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalCopyModeTests.swift, line 102:

<comment>Every failure inside `make()` collapses into `nil`: a `BridgeTreeFixture.tree()` decode error returns nil exactly like a missing tab or an unavailable surface, so `try #require(CopyModeLiveSurface.make())` in `terminal()` only reports "returned nil" instead of the underlying JSON/decoding error that the old `try BridgeTreeFixture.tree()` propagated, and a fixture failure disables the whole suite under the misleading "needs a live Ghostty surface" reason. Make `make()` throw for fixture/service failures and let `available()` use `try? make()` so only surface availability drives the skip and test failures surface the real cause.</comment>

<file context>
@@ -98,3 +92,20 @@ struct TerminalCopyModeTests {
+enum CopyModeLiveSurface {
+    static func make() -> (services: AppServices, tab: TabModel, view: TerminalSurfaceView)? {
+        let services = ActionBindingCoverageTests.boundServices()
+        guard let tree = try? BridgeTreeFixture.tree() else { return nil }
+        services.daemon.store.apply(snapshot: tree)
+        guard let tab = services.daemon.store.workspaces.first?.screens.first?.panes.first?.tabs.first else { return nil }
</file context>

package-conventions-lint rejects an all-static public enum. CopyModeKeys
is now a struct built with the ASCII layout lookup it needs, and action and
resolve are instance methods. Count clamping and the shortcut bypass stay
static. No behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Independent subagent review of b904219..f816959 (read-only): ship.

  • c75b700 moves the live-surface probe out of the @Suite trait, which fixes the macro circular reference.
  • f816959 turns CopyModeKeys into a struct that holds its ASCII layout lookup, so package-conventions-lint accepts it. The resolver logic is unchanged apart from the static-to-instance plumbing, the app still passes GhosttyInput.asciiCharacter(forKeyCode:), and test coverage is the same.

CI at f816959 is all green:

  • The copy mode suites ran on a live Ghostty surface and passed, including the window-hosted action test.
  • cmux-next swift test failed once at this head, only on tests outside this PR (ColumnCloseScrollTests, PaneChromeLiveTests, ProxyTests.directDestinationsThatResolveToThisMacAreRefused), and passed on rerun. The same family also failed once on cmux-next: every terminal names itself and its daemon in env #16446 and at c75b700.

Merging per the coordinator: parity items approved; feat-cmux-next is dogfooded as a whole.

@teamleaderleo
teamleaderleo merged commit 8552e45 into feat-cmux-next Oct 1, 2026
45 of 46 checks passed
@teamleaderleo
teamleaderleo deleted the feat-cmux-next-copy-mode branch October 1, 2026 19:33
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.

1 participant