Skip to content

Vi mode: half-page scroll, visible cursor, gg fix#851

Merged
lawrencecchen merged 2 commits intomainfrom
issue-846-vi-mode-p0
Mar 4, 2026
Merged

Vi mode: half-page scroll, visible cursor, gg fix#851
lawrencecchen merged 2 commits intomainfrom
issue-846-vi-mode-p0

Conversation

@lawrencecchen
Copy link
Copy Markdown
Contributor

@lawrencecchen lawrencecchen commented Mar 4, 2026

Summary

  • Ctrl+U/D half-page scroll: Was full-page (same as Ctrl+B/F). Now correctly scrolls half a page using Ghostty's scroll_page_fractional binding. Ctrl+B/F remain full-page.
  • Visible cursor on copy mode entry: select_cursor_cell creates a 1-cell selection as a cursor indicator when entering copy mode. Visual mode (v) tracked separately so the cursor doesn't make every motion extend a selection.
  • gg two-key sequence: Single g now waits for next key. gg scrolls to top. Same pendingG state pattern as pendingYankLine for yy. (Supersedes Fix vi mode: require gg to scroll to top #843)

Testing

  • 9 new unit tests, all passing (662 total passed, 5 pre-existing failures unrelated)
  • Tests cover: gg/counted gg/g cancellation/G immediate, Ctrl+U half-page, Ctrl+D half-page, Ctrl+B full-page, Ctrl+F full-page

Related


Summary by cubic

Improved vi copy mode with half-page scrolling, a visible cursor indicator, correct gg behavior, and reliable viewport row refresh in non-visual mode.

  • New Features

    • Ctrl+U/D scroll half-page via Ghostty’s scroll_page_fractional; Ctrl+B/F stay full-page.
    • Visible cursor on entry: create a 1-cell selection; track visual mode (v) separately so motions don’t auto-extend; exiting visual restores the cursor cell.
    • 'g' is now a prefix; 'gg' jumps to top (with selection: adjust to home). Shift+G still jumps to bottom. Counts supported; non-gg cancels.
  • Bug Fixes

    • Viewport row now refreshes after scrolling when not in visual mode; guard on visual-mode flag and re-create the 1-cell cursor selection to keep it visible.

Written for commit 2518d7c. Summary will update on new commits.

Summary by CodeRabbit

  • New Features
    • Half-page scrolling in copy-mode for Ctrl‑U/Ctrl‑D and similar commands for smoother navigation
    • Improved "gg" handling: bare "g" acts as a prefix and double "g" jumps to the top reliably
    • Visual-selection improvements so cursor remains visible and scrolling won't unintentionally extend selection

Three changes to keyboard copy mode (vi mode):

1. Ctrl+U/D now scroll half-page (was full-page). Ctrl+B/F remain
   full-page. Uses Ghostty's scroll_page_fractional binding.

2. Entering copy mode now creates a 1-cell selection at the terminal
   cursor via select_cursor_cell, giving the user a visible cursor
   indicator. Visual mode (v) is tracked separately from Ghostty's
   has_selection so the cursor selection doesn't make every motion
   behave as visual. Exiting visual mode (v again) collapses back
   to the cursor cell.

3. Single 'g' is now a prefix key requiring 'gg' to scroll to top,
   matching standard vim behavior. Uses the same pendingG state
   machine pattern as pendingYankLine for 'yy'.

Part of #846
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 4, 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 7:44am

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 4, 2026

📝 Walkthrough

Walkthrough

Adds vi-like copy-mode enhancements: a new fractional-scroll action (scrollHalfPage), pending-key handling for g/gg, a visual-selection flag to distinguish a 1-cell visible cursor from logical selection, and movement/scroll behavior updates to respect visual-selection state.

Changes

Cohort / File(s) Summary
Copy-Mode Action & State Enhancements
Sources/GhosttyTerminalView.swift
Added case scrollHalfPage(Int) to TerminalKeyboardCopyModeAction, introduced pendingG: Bool in TerminalKeyboardCopyModeInputState, added keyboardCopyModeVisualActive handling, made bare g a prefix (deferred via pendingG), and wired half-page scrolling and visual-selection semantics through action resolution and view handlers.
Copy-Mode Tests
cmuxTests/CmuxWebViewKeyEquivalentTests.swift
Updated tests to expect Ctrl+U/Ctrl+D mapping to half-page (scrollHalfPage) behavior, treat bare g as a nil prefix action (gg resolved later), and added/adjusted tests covering gg resolution, pending-g cancellation, counted gg, shift-G behavior, and control-based paging variations.

Sequence Diagram

sequenceDiagram
    participant User as User (Keyboard)
    participant Resolver as Input Resolver
    participant State as Copy-Mode State
    participant Selection as Selection Manager
    participant View as Viewport

    User->>Resolver: Press 'g'
    Resolver->>State: check pendingG
    alt pendingG == false
        State->>State: set pendingG = true
        State-->>Resolver: no action (prefix)
    else pendingG == true
        Resolver->>State: resolve 'gg'
        State->>Selection: clear/adjust selection or set 1-cell cursor at top
        Selection->>View: scroll to top / update viewport
        View->>View: render
        State->>State: pendingG = false
    end

    Note over Resolver,View: Ctrl+U/Ctrl+D -> Resolver emits scrollHalfPage(delta) -> State/View handle fractional scroll and preserve visual cursor
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 I nibble keys and count my hops,

press "g" then "g" — the viewport stops,
half-pages tumble, cursors glow,
a tiny blink to show and go,
hop on, hop on—copy-mode flows 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 9.09% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the three main changes: half-page scroll behavior, visible cursor implementation, and gg prefix key fix.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch issue-846-vi-mode-p0

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

Copy link
Copy Markdown

@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: 66b1a4aac2

ℹ️ 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 +3265 to +3267
// Create a 1-cell selection at the terminal cursor to serve as a
// visible cursor indicator in copy mode.
_ = ghostty_surface_select_cursor_cell(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.

P1 Badge Keep viewport row tracking independent of cursor indicator

Creating a persistent 1-cell selection on copy-mode entry makes ghostty_surface_has_selection(surface) true even in non-visual mode, so refreshKeyboardCopyModeViewportRowFromVisibleAnchor now bails out on every scroll and stops updating keyboardCopyModeViewportRow. In that state, actions like j/k, Ctrl+U/D, and prompt jumps move the viewport, but Y/yy still copies from the stale row captured when copy mode started, which produces incorrect copied lines after navigation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

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

1 issue found across 2 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/GhosttyTerminalView.swift">

<violation number="1" location="Sources/GhosttyTerminalView.swift:3267">
P2: Keeping a permanent 1-cell cursor selection prevents viewport-row refresh logic from running, which can make line-copy operations use stale row positions after scrolling.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 4, 2026

Greptile Summary

Implemented three P0 vi mode improvements:

  • Half-page scroll (Ctrl+U/D): Changed from full-page to half-page using scroll_page_fractional:±0.5, while Ctrl+B/F remain full-page
  • Visible cursor: Creates 1-cell selection on copy mode entry as visual cursor indicator, with keyboardCopyModeVisualActive flag to distinguish user visual mode from cursor selection
  • gg two-key sequence: Implemented state machine for gg (scroll to top) using pendingG flag, matching the pattern used for yy

Critical Issue Found:
refreshKeyboardCopyModeViewportRowFromVisibleAnchor() at line 3318 checks !ghostty_surface_has_selection(surface), which always fails now because the 1-cell cursor selection is always present. This breaks viewport row tracking after all scroll operations (Ctrl+U/D/B/F, gg, G, etc.).

Fix needed:

  • Change guard to check !keyboardCopyModeVisualActive instead
  • Clear old selection before calling keyboardCopyModeSelectionAnchor
  • Keep new 1-cell cursor selection (remove clear at line 3321)

Confidence Score: 2/5

  • Critical bug in viewport tracking will break scroll operations in vi mode
  • The logic bug in refreshKeyboardCopyModeViewportRowFromVisibleAnchor prevents viewport row updates after scrolling, causing cursor positioning issues. While the implementation approach for vi mode features is sound and tests pass, the bug affects core functionality.
  • Pay close attention to Sources/GhosttyTerminalView.swift line 3318 and the viewport tracking logic

Important Files Changed

Filename Overview
Sources/GhosttyTerminalView.swift Vi mode improvements with critical bug in viewport tracking after scrolling
cmuxTests/CmuxWebViewKeyEquivalentTests.swift Comprehensive test coverage for new vi mode features (9 new tests)

Last reviewed commit: 66b1a4a

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 4, 2026

Additional Comments (1)

Sources/GhosttyTerminalView.swift, line 3318
guard will always fail now that 1-cell cursor selection is always present in copy mode

After this PR, ghostty_surface_has_selection(surface) always returns true in copy mode (because of the 1-cell cursor). This means refreshKeyboardCopyModeViewportRowFromVisibleAnchor will always early-return and never update the viewport row after scroll operations.

    guard !keyboardCopyModeVisualActive else { return }

Also, the function should clear the old selection before calling keyboardCopyModeSelectionAnchor, and not clear the new selection at the end (line 3321) to keep the 1-cell cursor visible.

Copy link
Copy Markdown

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/GhosttyTerminalView.swift (1)

3317-3322: ⚠️ Potential issue | 🟠 Major

Viewport row refresh is blocked in all copy-mode states due to persistent 1-cell cursor selection

After copy mode entry creates a persistent 1-cell cursor selection (Line 3267), refreshKeyboardCopyModeViewportRowFromVisibleAnchor exits immediately on the guard at Line 3318 because ghostty_surface_has_selection(surface) is always true. This prevents viewport row updates after scroll, search, or jump actions, causing subsequent line-yank operations to use the initial (stale) row rather than the current cursor position.

The fix should use keyboardCopyModeVisualActive to guard only actual user selections:

🔧 Proposed fix
 private func refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: ghostty_surface_t) {
-    guard !ghostty_surface_has_selection(surface) else { return }
+    // Visual mode owns the user selection range. Outside visual mode we still keep
+    // a 1-cell cursor selection for visibility and should continue refreshing row state.
+    guard !keyboardCopyModeVisualActive else { return }
     guard let anchor = keyboardCopyModeSelectionAnchor(surface: surface) else { return }
     keyboardCopyModeViewportRow = anchor.row
-    _ = ghostty_surface_clear_selection(surface)
+    // Preserve the visible cursor indicator.
+    _ = ghostty_surface_select_cursor_cell(surface)
 }

Regression test: Enter copy mode, move viewport (j, Ctrl+D, search), then yy should copy from updated row.

Also applies to: 3376-3378 (correct use of keyboardCopyModeVisualActive), 3265-3267 (source of persistent selection)

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

In `@Sources/GhosttyTerminalView.swift` around lines 3317 - 3322, The refresh
function refreshKeyboardCopyModeViewportRowFromVisibleAnchor currently bails
whenever ghostty_surface_has_selection(surface) is true, but the copy-mode
creates a persistent 1-cell selection; change the guard to only return when
there is an actual user visual selection by combining the checks (i.e. return
only when ghostty_surface_has_selection(surface) is true AND
keyboardCopyModeVisualActive is true). Update the same pattern at the analogous
spots referenced (around the code that sets the persistent 1-cell selection and
the block at lines ~3376-3378) so viewport updates happen when only the
persistent cursor selection exists.
🧹 Nitpick comments (2)
Sources/GhosttyTerminalView.swift (1)

3420-3424: Use the existing repeat helper for fractional page scroll

This path duplicates repeat logic already centralized in performBindingAction(_:repeatCount:).

♻️ Suggested cleanup
         case let .scrollHalfPage(delta):
             let fraction = delta > 0 ? 0.5 : -0.5
-            for _ in 0 ..< count {
-                _ = performBindingAction("scroll_page_fractional:\(fraction)")
-            }
+            performBindingAction("scroll_page_fractional:\(fraction)", repeatCount: count)
             refreshKeyboardCopyModeViewportRowFromVisibleAnchor(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 3420 - 3424, The
.scrollHalfPage(delta) arm duplicates looping logic; replace the manual for-loop
with the existing helper by calling performBindingAction(_:repeatCount:) using
the same action string ("scroll_page_fractional:\(fraction)") and the provided
count so the repeat logic is centralized in performBindingAction(_:repeatCount:)
while preserving the fraction computation from the case.
cmuxTests/CmuxWebViewKeyEquivalentTests.swift (1)

1661-1665: Consider asserting input-state reset after gg with selection.

Other gg tests assert that TerminalKeyboardCopyModeInputState returns to default; adding the same check here improves regression protection for pending-prefix leakage.

Suggested test hardening
     func testGGWithSelectionAdjustsToHome() {
         var state = TerminalKeyboardCopyModeInputState()
         XCTAssertEqual(resolve(5, chars: "g", hasSelection: true, state: &state), .consume)
         XCTAssertEqual(resolve(5, chars: "g", hasSelection: true, state: &state), .perform(.adjustSelection(.home), count: 1))
+        XCTAssertEqual(state, TerminalKeyboardCopyModeInputState())
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/CmuxWebViewKeyEquivalentTests.swift` around lines 1661 - 1665, Add
an assertion in testGGWithSelectionAdjustsToHome to verify that the
TerminalKeyboardCopyModeInputState is reset to its default after the two-key
"gg" sequence when hasSelection is true; specifically, after calling resolve
twice (function resolve in the test) and asserting the
.perform(.adjustSelection(.home)), also assert that the mutable state variable
state equals a fresh TerminalKeyboardCopyModeInputState() to catch any leftover
pending-prefix leakage.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 3317-3322: The refresh function
refreshKeyboardCopyModeViewportRowFromVisibleAnchor currently bails whenever
ghostty_surface_has_selection(surface) is true, but the copy-mode creates a
persistent 1-cell selection; change the guard to only return when there is an
actual user visual selection by combining the checks (i.e. return only when
ghostty_surface_has_selection(surface) is true AND keyboardCopyModeVisualActive
is true). Update the same pattern at the analogous spots referenced (around the
code that sets the persistent 1-cell selection and the block at lines
~3376-3378) so viewport updates happen when only the persistent cursor selection
exists.

---

Nitpick comments:
In `@cmuxTests/CmuxWebViewKeyEquivalentTests.swift`:
- Around line 1661-1665: Add an assertion in testGGWithSelectionAdjustsToHome to
verify that the TerminalKeyboardCopyModeInputState is reset to its default after
the two-key "gg" sequence when hasSelection is true; specifically, after calling
resolve twice (function resolve in the test) and asserting the
.perform(.adjustSelection(.home)), also assert that the mutable state variable
state equals a fresh TerminalKeyboardCopyModeInputState() to catch any leftover
pending-prefix leakage.

In `@Sources/GhosttyTerminalView.swift`:
- Around line 3420-3424: The .scrollHalfPage(delta) arm duplicates looping
logic; replace the manual for-loop with the existing helper by calling
performBindingAction(_:repeatCount:) using the same action string
("scroll_page_fractional:\(fraction)") and the provided count so the repeat
logic is centralized in performBindingAction(_:repeatCount:) while preserving
the fraction computation from the case.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a13cee8f-51a5-48fc-833b-4d1ecffb7d5b

📥 Commits

Reviewing files that changed from the base of the PR and between c7bdd92 and 66b1a4a.

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

The 1-cell cursor selection made refreshKeyboardCopyModeViewportRowFromVisibleAnchor
always bail (has_selection was always true). Guard on keyboardCopyModeVisualActive
instead so viewport row updates work after scrolling in non-visual mode. Also
re-creates the cursor cell after refresh to preserve visibility.

Additionally: use performBindingAction(_:repeatCount:) for scrollHalfPage,
add state-reset assertion to testGGWithSelectionAdjustsToHome.

Addresses review feedback from CodeRabbit, Cubic, Codex, and Greptile.
Copy link
Copy Markdown

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

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

1668-1673: Add state-reset assertion in counted gg test for parity.

testCountedGG validates action/count, but not that input state is fully cleared afterward (unlike adjacent gg tests). Adding that check tightens regression coverage.

Suggested test tightening
     func testCountedGG() {
         var state = TerminalKeyboardCopyModeInputState()
         XCTAssertEqual(resolve(22, chars: "5", hasSelection: false, state: &state), .consume)
         XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .consume)
         XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .perform(.scrollToTop, count: 5))
+        XCTAssertEqual(state, TerminalKeyboardCopyModeInputState())
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/CmuxWebViewKeyEquivalentTests.swift` around lines 1668 - 1673, The
testCountedGG currently asserts the returned actions/counts but not that the
TerminalKeyboardCopyModeInputState is reset afterward; update the testCountedGG
to check that the input state (the variable state of type
TerminalKeyboardCopyModeInputState) is cleared after the last resolve call by
adding an assertion (e.g., XCTAssertEqual or XCTAssertTrue) that verifies state
equals a fresh TerminalKeyboardCopyModeInputState() or that its internal fields
are in their initial values; locate the testCountedGG function and add the
state-reset assertion after the final resolve(...) assertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@cmuxTests/CmuxWebViewKeyEquivalentTests.swift`:
- Around line 1668-1673: The testCountedGG currently asserts the returned
actions/counts but not that the TerminalKeyboardCopyModeInputState is reset
afterward; update the testCountedGG to check that the input state (the variable
state of type TerminalKeyboardCopyModeInputState) is cleared after the last
resolve call by adding an assertion (e.g., XCTAssertEqual or XCTAssertTrue) that
verifies state equals a fresh TerminalKeyboardCopyModeInputState() or that its
internal fields are in their initial values; locate the testCountedGG function
and add the state-reset assertion after the final resolve(...) assertion.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5b1e39b0-c72f-4989-9964-65956eddfb7d

📥 Commits

Reviewing files that changed from the base of the PR and between 66b1a4a and 2518d7c.

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

Copy link
Copy Markdown

@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: 2518d7c483

ℹ️ 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".

setKeyboardCopyModeActive(false)
case .startSelection:
_ = ghostty_surface_select_cursor_cell(surface)
keyboardCopyModeVisualActive = true
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 Badge Re-anchor cursor cell when starting visual mode

In handleKeyboardCopyModeIfNeeded, the .startSelection branch now only sets keyboardCopyModeVisualActive = true, so it assumes the existing 1-cell cursor selection is already at the current copy-mode cursor. However, jump actions like gg/G (.scrollToTop/.scrollToBottom) do not call refreshKeyboardCopyModeViewportRowFromVisibleAnchor, so the stored cursor-cell indicator can be stale; starting visual mode after one of those jumps can extend/copy from the wrong location instead of the newly jumped row.

Useful? React with 👍 / 👎.

@lawrencecchen lawrencecchen merged commit 044a3db into main Mar 4, 2026
12 checks passed
@lawrencecchen lawrencecchen deleted the issue-846-vi-mode-p0 branch March 4, 2026 07:56
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