Skip to content

Niri-style terminal canvas with per-panel tabs and drag-drop#2220

Open
lawrencecchen wants to merge 16 commits intomainfrom
feat-niri-canvas
Open

Niri-style terminal canvas with per-panel tabs and drag-drop#2220
lawrencecchen wants to merge 16 commits intomainfrom
feat-niri-canvas

Conversation

@lawrencecchen
Copy link
Copy Markdown
Contributor

@lawrencecchen lawrencecchen commented Mar 26, 2026

Summary

Niri/PaperWM-style horizontal terminal strip layout prototype (debug only, Cmd+Ctrl+N).

  • Real ghostty terminal surfaces rendered via Metal, composited in a scrollable horizontal strip at 120fps
  • Per-panel tab system with tab bar, Ctrl+1-9 switching, Cmd+T/N/W
  • Cross-panel tab drag-drop with floating drag image and blue insertion indicators
  • Drag between panels to move tabs, drag to gap to create new panel
  • Per-panel resize presets (33%/67%/100%) cycled with Cmd+Ctrl+R
  • Smart scroll: only scrolls when panel is off-screen, auto-scrolls during drag
  • Also includes ghostty submodule sync to latest upstream (1043 commits)

Testing

  • Automated stress test: open canvas, add/switch/close tabs, navigate/resize panels
  • Standalone tab bar drag test verified on cmux-macmini via NSEvent.sendEvent
  • Manual testing of all keyboard shortcuts and drag-drop flows

Related

Summary by CodeRabbit

  • New Features
    • Experimental Terminal Canvas UI in debug builds (multi‑panel canvas, per‑panel tab bars, drag‑and‑drop tab reordering, keyboard shortcuts) plus two demo/test mini‑apps demonstrating strip/tab UIs.
  • Bug Fixes
    • More robust clipboard callback behavior and explicit handling of tab title update actions.
  • Tests
    • New end‑to‑end UI test covering canvas tab drag‑and‑drop.
  • Chores
    • Updated Ghostty subproject and checksum entry; project updated to include the new canvas source.

Summary by cubic

Experimental Niri/PaperWM-style terminal canvas with per-panel tabs, cross-panel drag-drop, edge drop zones for splitting (larger 40% zones on outer edges), continuous drag auto-scroll, and stable drop behavior; rendered at 120 fps. Debug-only: open with Cmd+Ctrl+N; includes scrollable tab bars, terminal-driven titles, Ctrl+Tab cycling, Cmd+D split-right, and fixes for self-drop edge cases.

  • New Features

    • Real ghostty terminals in a horizontal strip with per-panel tab bars; content-sized, scrollable tabs; titles synced from terminal; bar colors follow theme.
    • Cross-panel drag-drop with floating preview and blue indicators; drop zones: left/right edge split inherits source panel width, top/bottom edges split same width, center adds tab; outer edges use larger 40% zones; auto-scroll only when cursor is past all visible panels; continuous tick-driven drag scroll; self-drop guarded.
    • Drag auto-scroll accelerates over 0.4s and scales with edge proximity; drop overlay and target follow during tick-driven scroll; dwell timer resets on leaving the scroll zone.
    • Navigation/management: Cmd+Opt+Arrow or Cmd+Ctrl+H/L to move panels; Ctrl+Tab/Shift cycles tabs; Ctrl+1–8 select, Ctrl+9 jumps to last; Cmd+T adds tab; Cmd+N adds panel; Cmd+D splits right; Cmd+W or Ctrl+D closes; click to focus. Canvas window handles its own shortcuts.
    • Per-panel width presets (33/67/100) with smooth animation; smart scroll keeps the focused panel in view; instant column close absorbs width. UITest covers tab-bar drag reorder; includes small strip/tab-bar demo scripts.
    • Overscroll clamped to the last panel; after drop, scroll position is restored and only adjusted if the focused panel is off-screen; drop target stays visually stable.
  • Dependencies

    • Updated ghostty to latest upstream and synced ghostty.h (IO mode, write callback, renderer/selection symbols); added new GhosttyKit checksum.
    • Adapted to API changes: read_clipboard_cb now returns Bool; handled GHOSTTY_ACTION_SET_TAB_TITLE (canvas manages titles).

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

Merges ghostty-org/ghostty main into manaflow-ai/ghostty fork,
preserving all cmux-specific patches (OSC 99, cursor-click-to-move,
resize fixes, Pure prompt markers, theme picker).
- read_clipboard_cb now returns Bool (was Void)
- Handle new GHOSTTY_ACTION_SET_TAB_TITLE action
- Update ghostty.h to match new upstream header, preserving cmux-specific
  declarations (select_cursor_cell, clear_selection)
Horizontal strip layout with real ghostty terminal surfaces. Each
terminal renders independently via ghostty's Metal renderer. Features:
- Cmd+Opt+Arrow / Cmd+Ctrl+H/L to navigate between panels
- Cmd+Ctrl+R to cycle per-panel resize presets (33%/67%/100%)
- Cmd+W / Ctrl+D to close, Cmd+T to add new terminal
- Smooth 120fps animation via CVDisplayLink
- Smart scroll: only moves when panel is off-screen
- Animated resize with left-edge pinning and visibility clamping

Triggered with Cmd+Ctrl+N in debug builds.
- Per-panel tab system: each panel has its own tab bar with tabs
- Cmd+T adds tab, Cmd+N adds panel, Ctrl+1-9 switches tabs
- Cross-panel tab drag with floating drag image and blue drop indicators
- Drop onto another panel's tab bar moves the tab there
- Drop in gap between panels creates a new panel (inherits source width)
- Escape cancels drag
- Auto-scroll: hovering over a panel during drag scrolls it into view
- Click on terminal/tab focuses that panel and scrolls it into view
- Fixed focus-after-drop: recomputes live index after panel changes
- Fixed hitTest focus stealing: removed side-effect from hitTest,
  focus-on-click handled in NiriCanvasWindow.sendEvent instead
- All operations logged via dlog for debugging
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 26, 2026

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

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Mar 28, 2026 9:35am

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 26, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a debug-only multi-panel "Niri Canvas" UI (tab bars, cross-panel drag/reorder, keyboard controls, window/controller), registers the new source in the Xcode project, adds a debug AppDelegate shortcut to open it, adds UI tests and demo scripts, updates ghostty C API and submodule, and adjusts clipboard/tab-title handling in GhosttyTerminalView.

Changes

Cohort / File(s) Summary
Niri Canvas Core
Sources/NiriCanvasView.swift
New debug-only file introducing NiriTabBarView, NiriCanvasView, NiriCanvasWindow, and NiriCanvasWindowController implementing multi-panel layout, tab rendering, cross-panel drag/drop, keyboard shortcuts, focus routing, panel insertion/removal, and animated updates.
AppDelegate Integration
Sources/AppDelegate.swift
Added debug-only niriCanvasController, openNiriCanvas() and a Cmd+Ctrl+N shortcut; AppDelegate now skips shortcut handling when key window is a NiriCanvasWindow.
Terminal Runtime Handling
Sources/GhosttyTerminalView.swift
Changed clipboard read callback to return false on userdata failure and explicitly return true after scheduling clipboard handling; added handling/ignore for GHOSTTY_ACTION_SET_TAB_TITLE.
Ghostty C API & Submodule
ghostty.h, ghostty (submodule), scripts/ghosttykit-checksums.txt
Bumped ghostty submodule and checksum; header changes include new IO mode and write-callback fields in ghostty_surface_config_s, new enums/types (io_mode, io_write_cb, fullscreen config), renamed some enum values, added GHOSTTY_ACTION_SET_TAB_TITLE and copy action, changed runtime clipboard callback to return bool, and declared ghostty_surface_process_output().
Project Configuration
GhosttyTabs.xcodeproj/project.pbxproj
Registered Sources/NiriCanvasView.swift as a source file in the GhosttyTabs target (PBXFileReference/PBXBuildFile entries).
UI Tests & Demos
cmuxUITests/NiriCanvasUITests.swift, scripts/test-tabbar.swift, scripts/demo-paperwm.swift
Added an end-to-end UI test for tab-bar drag/reorder using raw mouse events and helper utilities; added two AppKit demo/test scripts showcasing a tab bar test and a PaperWM-style animated strip.
Formatting / Minor
Sources/AppDelegate.swift
Removed an extra trailing blank brace line (non-functional).

Sequence Diagram

sequenceDiagram
    participant User
    participant TabBar as NiriTabBarView
    participant Canvas as NiriCanvasView
    participant Window as NiriCanvasWindow
    participant Display as CVDisplayLink

    User->>TabBar: mouseDown & drag
    TabBar->>Canvas: onDragStart(tabIndex, event)
    Canvas->>Canvas: create floating text layer, start drag loop

    loop while dragging
        User->>Canvas: mouse move
        Canvas->>Canvas: compute drop target, auto-scroll
        Canvas->>TabBar: showDropIndicator(at:index?)
        Canvas->>Window: suppress hit-test / route events
        Display->>Canvas: tick -> request visual update
    end

    User->>Canvas: mouseUp
    Canvas->>Canvas: finalize drop (reorder/move/new panel), restore state
    Display->>Canvas: tick -> animate layout updates
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Poem

🐰 I hopped along the tabbed parade,
A floating title, blue trail made,
Drag and drop across the panes I prance,
CVDisplayLink keeps the motion in dance,
Tiny paws cheering the canvas advance!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.55% 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
Title check ✅ Passed The title clearly and specifically describes the main feature being added: a Niri-style terminal canvas with per-panel tabs and drag-drop functionality.
Description check ✅ Passed The PR description comprehensively covers all required template sections: Summary (what changed and why), Testing (automated stress test, standalone tab bar drag test, manual testing), and includes a demo/related context.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-niri-canvas

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 and usage tips.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 26, 2026

Greptile Summary

This PR introduces a Niri/PaperWM-style horizontal terminal canvas prototype (Cmd+Ctrl+N, #if DEBUG only) with per-panel tab bars, cross-panel drag-drop, animated panel close/resize, and a 120fps CVDisplayLink-driven scroll spring. It also syncs 1043 upstream ghostty commits, fixing a read_clipboard_cb return type and handling the new GHOSTTY_ACTION_SET_TAB_TITLE action.

Key changes:

  • Sources/NiriCanvasView.swift — 1069-line new file implementing NiriTabBarView, NiriCanvasView, NiriCanvasWindow, and NiriCanvasWindowController
  • Sources/AppDelegate.swift — Cmd+Ctrl+N shortcut wired to openNiriCanvas(), window-key guard to prevent shortcut conflicts
  • Sources/GhosttyTerminalView.swift — upstream API fixes: read_clipboard_cb now returns bool, GHOSTTY_ACTION_SET_TAB_TITLE handled
  • ghostty.h — upstream header sync (new enums, renamed constants, new surface IO mode fields)
  • cmuxUITests/NiriCanvasUITests.swift — raw CGEvent-based UI test for crash regression

Issues found:

  • NSSetUncaughtExceptionHandler is called inside NiriCanvasWindowController.init(), replacing the Sentry crash handler for the remainder of the app's lifetime — should chain to the previous handler rather than clobber it
  • The debugNoGhostty branch in open() is identical to the normal branch and still creates real TerminalSurface/ghostty instances, orphaning their views and wasting resources
  • CVDisplayLink uses passUnretained(self) — a trailing callback after CVDisplayLinkStop in deinit could dereference freed memory before the [weak v] guard in the async dispatch
  • scrollWheel negates deltas when isDirectionInvertedFromDevice == true, which inverts natural-scrolling direction

Confidence Score: 3/5

Safe to merge as a debug-only prototype, but three correctness bugs in NiriCanvasView.swift should be addressed before the feature is used in regular development sessions.

All new code is guarded by #if DEBUG so there is zero production risk. However, NSSetUncaughtExceptionHandler clobbering Sentry's handler and the debugNoGhostty resource leak are genuine bugs that will affect anyone exercising the canvas during normal development, and the CVDisplayLink unretained-pointer pattern is a latent use-after-free. The scroll direction inversion makes the feature feel broken on machines with natural scrolling. These four issues together warrant addressing before the branch is merged, even as a prototype.

Sources/NiriCanvasView.swift requires the most attention — all four bugs are concentrated there.

Important Files Changed

Filename Overview
Sources/NiriCanvasView.swift New 1069-line file implementing the niri-style canvas. Issues: global NSSetUncaughtExceptionHandler replacing Sentry in init(), debugNoGhostty branch still spawning real ghostty processes, unsafe unretained CVDisplayLink pointer, and inverted natural scroll direction.
Sources/AppDelegate.swift Adds Cmd+Ctrl+N shortcut (DEBUG only) to open NiriCanvasWindowController, skips global shortcut handling when NiriCanvasWindow is key. Changes are minimal and correctly guarded by #if DEBUG.
Sources/GhosttyTerminalView.swift Fixes read_clipboard_cb closure to return bool (required by updated ghostty API), adds GHOSTTY_ACTION_SET_TAB_TITLE case to suppress OSC tab title overrides. Both changes are correct upstream-sync fixes.
ghostty.h Upstream ghostty submodule header sync: adds MSVC ssize_t compat, io_mode surface config fields, fullscreen enum renames (NON_NATIVE → MACOS_NON_NATIVE), RENDERER_HEALTH_OK → RENDERER_HEALTH_HEALTHY, new action tags (SET_TAB_TITLE, COPY_TITLE_TO_CLIPBOARD), and ghostty_surface_process_output API.
cmuxUITests/NiriCanvasUITests.swift UI test for canvas tab bar drag-reorder using raw CGEvent injection. Cannot verify reorder success due to lack of accessibility identifiers on NiriTabBarView, so the test is mostly a crash/regression gate rather than a behavioral assertion.
GhosttyTabs.xcodeproj/project.pbxproj Adds NiriCanvasView.swift to the Xcode project file references and compile sources list. Minimal, mechanical change.
scripts/demo-paperwm.swift Standalone CALayer-based PaperWM strip demo script (not compiled into the main target). Useful prototype reference, no issues.
scripts/test-tabbar.swift Standalone NSEvent.sendEvent-based tab bar drag test script for manual verification. Not compiled into the main target.

Reviews (1): Last reviewed commit: "Add GhosttyKit checksum for ghostty 2c53..." | Re-trigger Greptile

Comment on lines +1030 to +1033
contentRect: NSRect(x: scr.midX - 700, y: scr.midY - 350, width: 1400, height: 700),
styleMask: [.titled, .closable, .resizable],
backing: .buffered, defer: false
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Global uncaught exception handler replaces Sentry

NSSetUncaughtExceptionHandler is a process-wide singleton. Setting it inside NiriCanvasWindowController.init() silently replaces any handler installed by Sentry (or any other crash reporter already wired up in the app), leaving only a bare NSLog behind. This means:

  • After the user first opens the canvas with Cmd+Ctrl+N, the Sentry crash reporter loses its exception hook for the remainder of the app's lifetime.
  • Any ObjC exception thrown after that point (in any part of the app, not just the canvas) will be logged to Console but not sent to Sentry.

Because this is guarded by #if DEBUG, it won't ship to users — but it will silently break crash telemetry in developer debug builds the moment someone tries the canvas, which is exactly when you want Sentry working.

Consider removing this entirely (the stack symbols will still appear in Xcode's crash log), or at least storing the previous handler and chaining to it:

// Store existing handler first, then chain to it
let prev = NSGetUncaughtExceptionHandler()
NSSetUncaughtExceptionHandler { exception in
    NSLog("niri.UNCAUGHT: \(exception.name) reason=\(exception.reason ?? "nil")")
    NSLog("niri.UNCAUGHT.stack: \(exception.callStackSymbols.joined(separator: "\n"))")
    prev?(exception)
}

Comment on lines +1052 to +1064
})
} else {
guard GhosttyApp.shared.app != nil else { return }
canvasView.setSurfaces((0..<terminalCount).map { _ in
TerminalSurface(tabId: UUID(), context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: nil)
})
}
showWindow(nil); window?.makeKeyAndOrderFront(nil)
if !debugNoGhostty {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.canvasView.focusCurrentTerminal()
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 debugNoGhostty branch still creates real terminal surfaces

Both branches of the if debugNoGhostty / else block call the identical TerminalSurface(tabId:context:configTemplate:) constructor. TerminalSurface.init initialises a real ghostty surface — so even in debug-placeholder mode, live terminal processes are spawned (and their hostedView is never added to the view hierarchy, so they are orphaned).

The if debugNoGhostty branch should use lightweight placeholder objects or skip surface creation entirely, otherwise debugNoGhostty only suppresses the view but not the underlying ghostty process.

Comment on lines +936 to +945
return kCVReturnSuccess
}
CVDisplayLinkSetOutputCallback(displayLink, cb, Unmanaged.passUnretained(self).toOpaque())
CVDisplayLinkStart(displayLink)
}

private func tick() {
var anyResizing = false
for i in 0..<panels.count {
let d = panels[i].targetWidth - panels[i].currentWidth
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Unsafe unretained self reference in CVDisplayLink callback

Unmanaged.passUnretained(self).toOpaque() stores a raw pointer to self in the display link callback context. CVDisplayLinkStop is called in deinit, but the CVDisplayLink framework can fire one final callback on a background thread after CVDisplayLinkStop returns (the stop is asynchronous). If that trailing callback fires after self is deallocated, takeUnretainedValue() dereferences freed memory — even though the async dispatch uses [weak v], the takeUnretainedValue() call itself happens before the weak reference check.

Comment on lines +913 to +919
if nf != focusedIndex { focusedIndex = nf; focusCurrentTerminal() }
}

private func nearestLive(forOffset off: CGFloat) -> Int {
var best = 0; var bestD = CGFloat.infinity; var x: CGFloat = 0; var li = 0
for p in panels where !p.closing {
let d = abs(x + pw(for: p) / 2 - off)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Natural scrolling direction is inverted

When "natural scrolling" is enabled, isDirectionInvertedFromDevice is true. The code negates both deltas in that case, which moves the strip in the opposite direction from what the user expects. With natural scrolling on, scrollingDeltaX is already in the "content moves with your fingers" direction — no inversion is needed. Consider flipping the condition:

if !event.isDirectionInvertedFromDevice { dx = -dx; dy = -dy }
let delta = abs(dx) > abs(dy) ? dx : dy
targetOffset += delta * 2.0

Comment on lines +383 to +391
dl.shadowColor = CGColor(gray: 0, alpha: 1)
dl.shadowOpacity = 0.5
dl.shadowRadius = 8
dl.contentsScale = window?.backingScaleFactor ?? 2
dl.zPosition = 1000
dl.anchorPoint = CGPoint(x: 0.5, y: 0.5)
layer?.addSublayer(dl)
dragLayer = dl

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Debug NSLog fires on every mouse movement over tab bar

PanelContainerView.hitTest logs "niri.hitTest -> TAB BAR" every time hitTest is triggered for a tab bar region hit. On macOS, hitTest is called on every mouse-moved event — at 120 Hz with the cursor over a tab bar, this produces ~120 log lines per second to the system log. Suggest wrapping it behind a #if VERBOSE_LOGGING flag or removing it before the PR merges, keeping only the "MISS" log which fires much less frequently.

Comment on lines +831 to +858
panels[pi].closing = true
if let s = panels[pi].activeSurface?.surface { ghostty_surface_request_close(s) }
if liveCount > 0 {
focusedIndex = min(focusedIndex, liveCount - 1)
scrollToReveal()
focusCurrentTerminal()
}
} else {
let idx = panels[pi].activeTab
let surface = panels[pi].tabs[idx]
surface.hostedView.removeFromSuperview()
if let s = surface.surface { ghostty_surface_request_close(s) }
panels[pi].tabs.remove(at: idx)
panels[pi].activeTab = min(idx, panels[pi].tabs.count - 1)
panels[pi].syncTabBar()
if let a = panels[pi].activeSurface {
a.hostedView.removeFromSuperview()
panels[pi].containerView.addSubview(a.hostedView)
}
layoutStrip(); scrollToReveal(); focusCurrentTerminal()
}
}

func addNewTab() {
nlog("addNewTab")
guard GhosttyApp.shared.app != nil else { return }
let live = liveIndices
guard focusedIndex < live.count else { addNewPanel(); return }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 closeActiveTab() panel-close path skips layoutStrip()

When a panel's last tab is closed, closing is set to true but layoutStrip() is never called synchronously. The multi-tab close path explicitly calls layoutStrip(). The tick() display-link callback will eventually call it, but until that tick fires there can be a stale-layout frame where the focused index has changed but the container frames still reflect the old layout.

panels[pi].closing = true
if let s = panels[pi].activeSurface?.surface { ghostty_surface_request_close(s) }
if liveCount > 0 {
    focusedIndex = min(focusedIndex, liveCount - 1)
    scrollToReveal()
    layoutStrip()   // ← add to match multi-tab path
    focusCurrentTerminal()
}

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.

Actionable comments posted: 13

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)

1102-1208: ⚠️ Potential issue | 🟠 Major

Move the runtimeSurface check before returning true to prevent broken clipboard acknowledgements.

The callback returns true at line 1208 before verifying a live surface exists. Since runtimeSurface is only checked inside the dispatched async block (line 1105), the surface can be torn down between the return statement and async execution. If teardown completes (which sets surface = nil before deferred free), the dispatched block exits early without calling ghostty_surface_complete_clipboard_request, leaving Ghostty's callback incomplete. This violates the callback protocol contract.

Capture the live surface before returning true, or fail fast:

Suggested fix
         runtimeConfig.read_clipboard_cb = { userdata, location, state in
-            guard let callbackContext = GhosttyApp.callbackContext(from: userdata) else { return false }
+            guard let callbackContext = GhosttyApp.callbackContext(from: userdata),
+                  let requestSurface = callbackContext.runtimeSurface else { return false }

             DispatchQueue.main.async {
-                guard let requestSurface = callbackContext.runtimeSurface else { return }

                 func completeClipboardRequest(with text: String) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` around lines 1102 - 1208, The callback
currently returns true before ensuring a live runtimeSurface exists, risking
that the async block will early-return and never call
ghostty_surface_complete_clipboard_request; fix by capturing/validating
callbackContext.runtimeSurface synchronously (e.g., bind a local requestSurface
or guard runtimeSurface != nil) before scheduling DispatchQueue.main.async and
only return true when you have a valid captured surface, otherwise return false;
ensure completeClipboardRequest still uses that captured requestSurface and that
callbackContext/runtimeSurface checks inside the async block are updated to use
the captured value where appropriate.
🧹 Nitpick comments (1)
cmuxUITests/NiriCanvasUITests.swift (1)

129-142: Please assert the reorder result.

This currently only proves the window survives the drag. A missed hit target, ignored drag, or no-op reorder still passes, so the test will not catch regressions in the feature it is named after. Expose one stable observable for tab order (for example the same kind of debug/accessibility hook used by BonsplitTabDragUITests) and assert that the order actually changes.

Based on learnings, tests must verify observable runtime behavior through executable paths, not just exercise a path without checking the feature result.

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

In `@cmuxUITests/NiriCanvasUITests.swift` around lines 129 - 142, The test
currently only checks canvasWindow.exists and does not verify tab reorder;
update NiriCanvasUITests to assert the actual tab order by exposing a stable
observable (e.g. add accessibilityIdentifiers or a debug/access API on
NiriTabBarView that returns tab order) similar to the mechanism used in
BonsplitTabDragUITests, perform the drag (Cmd+T then drag) and then query that
observable to assert the order changed (compare before/after order) so the test
fails when the reorder is a no-op or missed hit.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ghostty`:
- Line 1: Update the docs/ghostty-fork.md file to reflect the new submodule sync
to commit 2c530daf (submodule HEAD db6df246d...), replacing the old "as of March
12, 2026" timestamp with the current sync date; add a short "Sync to 2c530daf"
section that lists the primary changes pulled in from that commit, explicitly
document any conflict resolutions applied during the merge (file names and brief
resolution notes), and confirm you followed the fork update checklist present in
the document (e.g., tests run, build verified, and changes reviewed).
- Line 1: The ghostty submodule is in a detached HEAD at commit db6df246... and
references a commit 2c530daf0647... that isn’t present on the
manaflow-ai/ghostty fork and its remote is incorrectly set to manaflow-ai/cmux;
fix this by entering the ghostty submodule, re-pointing its remote to the
correct repo (manaflow-ai/ghostty), fetching remotes, checking out or creating
the intended branch (e.g., main) so HEAD is not detached, ensure the local
commit 2c530daf0647... is present on that branch (apply, cherry-pick, or
recreate the commit if needed) and push the branch to the manaflow-ai/ghostty
fork, then update the parent repo’s submodule pointer to the pushed commit and
commit that change in the parent repository.

In `@scripts/demo-paperwm.swift`:
- Around line 213-226: The z ordering is inverted because zOffset is computed
with a leading negative: change the zOffset calculation in the transform block
(variable zOffset, which currently uses -abs(distFromCenter) * perspectiveZ) so
it does not negate the product; compute zOffset as abs(distFromCenter) *
perspectiveZ (or otherwise remove the unary '-' ) so that given perspectiveZ is
negative (−120) side panels receive a negative zOffset and correctly recede from
the camera.
- Around line 10-12: The file is missing the CoreVideo import required for
CVDisplayLink and related types; add an import CoreVideo statement near the top
alongside existing imports (so the CVDisplayLink, CVDisplayLinkOutputCallback,
CVDisplayLinkStart/Stop and teardown code in the display link setup and teardown
functions will compile).

In `@scripts/test-tabbar.swift`:
- Around line 106-127: Add an NSApplicationDelegate that returns true from
applicationShouldTerminateAfterLastWindowClosed and assign it to the existing
app (let app = NSApplication.shared) so the helper quits when the last window
closes; specifically, create a small AppDelegate class implementing
applicationShouldTerminateAfterLastWindowClosed(_:)->Bool { return true } and
set app.delegate = AppDelegate() before calling app.run(), leaving TestTabBar
and the window setup unchanged.

In `@Sources/AppDelegate.swift`:
- Around line 9091-9095: The Cmd+Ctrl+N handler (the if block that checks
hasCommand && hasControl && !flags.contains(.shift) && (chars.lowercased() ==
"n" || event.keyCode == 45)) runs before the command-palette consumption guard
and can open the demo while the palette is active; either move this
openNiriCanvas() block to after the existing palette-consumption guard or add an
explicit early return when the command palette is visible (i.e., check the same
"palette is visible/consumes commands" condition used elsewhere) so that
openNiriCanvas() is not invoked while the palette has focus.
- Around line 9087-9090: Update the shortcut-handling guard to resolve the
actual window using the event's windowNumber or NSApp.keyWindow when
event.window is nil (instead of only checking event.window). In the method that
contains the current check (the block using "if event.window is NiriCanvasWindow
{ return false }" inside handleCustomShortcut / AppDelegate), obtain a
resolvedWindow from event.window ?? NSApp.window(withWindowNumber:
event.windowNumber) ?? NSApp.keyWindow and use that to test "is
NiriCanvasWindow" so key-equivalent events delivered with event.window == nil
are correctly treated as coming from the canvas.

In `@Sources/GhosttyTerminalView.swift`:
- Around line 2455-2458: The handler is currently swallowing
GHOSTTY_ACTION_SET_TAB_TITLE by returning true; instead, mirror the SET_TITLE
handling: construct and post the same notification used for terminal-driven
title updates (the ghosttyDidSetTitle notification and its payload) but
mark/apply it as the workspace/tab title update so the workspace tab manager
updates the tab label; update the switch case for GHOSTTY_ACTION_SET_TAB_TITLE
to build the same userInfo as SET_TITLE and post the notification rather than
returning true.

In `@Sources/NiriCanvasView.swift`:
- Around line 1005-1012: The window-level shortcut handler is swallowing
unhandled Command combos (the final `if cmd { return }`) which prevents a
focused Ghostty surface from receiving terminal input; update the logic around
the canvas key handling so that after trying canvas.performKeyEquivalent(with:)
you only consume Cmd combos that are truly handled and otherwise forward
unhandled Cmd+Shift (and other unbound Cmd combos) to the canvas/terminal.
Concretely, modify the block containing handleCtrlD(), switchToTabPublic(...),
and the call to canvas.performKeyEquivalent(with:) so that you do not
unconditionally `return` on `if cmd` — instead detect when a real canvas
(GhosttyNSView) is focused and let its performKeyEquivalent take precedence or
explicitly forward unhandled Cmd+Shift combos to the canvas before swallowing
the event.
- Around line 547-560: When removing a tab from a panel (the block manipulating
panels[srcPi].tabs, panels[srcPi].activeTab, panels[srcPi].activeSurface, and
calling panels[srcPi].syncTabBar), preserve the currently displayed surface if
the removed tab is left of the current activeTab: instead of always setting
activeTab = min(...), if srcTab < panels[srcPi].activeTab decrement
panels[srcPi].activeTab by 1; otherwise set it to min(...). Apply the identical
change in the other helper that performs tab removal (the other code path that
also updates panels[...].tabs, activeTab, activeSurface and syncTabBar) so the
activeSurface does not silently jump when a left-side tab is dragged out.
- Around line 503-511: When handling case .inTabBar you must adjust insertIdx
for same-panel rightward moves because removing the source first shifts targets
left; compute an adjusted index (e.g. let adjustedInsert = insertIdx -
(sourceTab < insertIdx ? 1 : 0)), clamp it against panels[sourcePi].tabs.count -
1, and pass adjustedInsert to reorderTab(from:to:inPanel:) instead of the raw
insertIdx; keep the rest of the liveIndices/targetPi/sourcePi logic intact.
- Around line 238-240: The setSurfaces implementation is dropping
TerminalSurface instances by replacing panels without calling their teardown
path; update setSurfaces (and the similar code at the other occurrences) to
iterate the existing panels' TerminalSurface(s) and call teardownSurface() for
each discarded surface before removing container views, then update panels =
surfaces.map { makePanel(with: [$0]) }; ensure you reference the existing panels
array, the makePanel(with:) helper, and the teardownSurface() method so the
portal sealing lifecycle used in GhosttyTerminalView's teardown is executed for
each surface being replaced.
- Line 127: Replace hard-coded UI strings with localized lookups: in
NiriCanvasView where you set let title = tab.title.isEmpty ? "Shell" :
String(tab.title.prefix(20)) and at the other noted locations (the "Terminal
Canvas" uses and other literal strings around lines referenced), change the
literals to use String(localized: "key.name", defaultValue: "Shell") or
appropriate keys (e.g., "terminal.canvas.title") and add corresponding entries
in Resources/Localizable.xcstrings; ensure you use descriptive key names, keep
the same default English text in defaultValue, and update all occurrences
mentioned ("Shell", "Terminal Canvas", and the other two literals) to follow
this pattern so user-facing strings are localized.

---

Outside diff comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 1102-1208: The callback currently returns true before ensuring a
live runtimeSurface exists, risking that the async block will early-return and
never call ghostty_surface_complete_clipboard_request; fix by
capturing/validating callbackContext.runtimeSurface synchronously (e.g., bind a
local requestSurface or guard runtimeSurface != nil) before scheduling
DispatchQueue.main.async and only return true when you have a valid captured
surface, otherwise return false; ensure completeClipboardRequest still uses that
captured requestSurface and that callbackContext/runtimeSurface checks inside
the async block are updated to use the captured value where appropriate.

---

Nitpick comments:
In `@cmuxUITests/NiriCanvasUITests.swift`:
- Around line 129-142: The test currently only checks canvasWindow.exists and
does not verify tab reorder; update NiriCanvasUITests to assert the actual tab
order by exposing a stable observable (e.g. add accessibilityIdentifiers or a
debug/access API on NiriTabBarView that returns tab order) similar to the
mechanism used in BonsplitTabDragUITests, perform the drag (Cmd+T then drag) and
then query that observable to assert the order changed (compare before/after
order) so the test fails when the reorder is a no-op or missed hit.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8995934f-8d6a-4385-9d22-bcae013ac2c3

📥 Commits

Reviewing files that changed from the base of the PR and between bc9e45c and 007569f.

📒 Files selected for processing (9)
  • GhosttyTabs.xcodeproj/project.pbxproj
  • Sources/AppDelegate.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/NiriCanvasView.swift
  • cmuxUITests/NiriCanvasUITests.swift
  • ghostty
  • ghostty.h
  • scripts/demo-paperwm.swift
  • scripts/test-tabbar.swift

@@ -1 +1 @@
Subproject commit bc9be90a21997a4e5f06bf15ae2ec0f937c2dc42
Subproject commit 2c530daf06473067af537db209a0f3c598881281
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check if docs/ghostty-fork.md was updated alongside the submodule

echo "=== Checking if docs/ghostty-fork.md exists and was recently modified ==="
if [ -f docs/ghostty-fork.md ]; then
  echo "File exists. Recent content:"
  echo ""
  head -50 docs/ghostty-fork.md
  echo ""
  echo "=== Checking for mentions of the new commit or recent sync ==="
  rg -i "2c530daf|1043.*commit|sync.*2026-03" docs/ghostty-fork.md || echo "No mention of recent sync found"
else
  echo "✗ docs/ghostty-fork.md not found!"
fi

Repository: manaflow-ai/cmux

Length of output: 2229


🏁 Script executed:

#!/bin/bash

echo "=== Full content of docs/ghostty-fork.md ==="
cat docs/ghostty-fork.md

echo ""
echo "=== Current ghostty submodule commit ==="
cd ghostty && git rev-parse HEAD && cd -

echo ""
echo "=== Checking git log for docs/ghostty-fork.md in recent commits ==="
git log --oneline -n 20 -- docs/ghostty-fork.md

Repository: manaflow-ai/cmux

Length of output: 6089


Update docs/ghostty-fork.md to document the ghostty sync to commit 2c530daf.

The file exists and contains comprehensive fork documentation, but it was not updated in this PR. The file still references "as of March 12, 2026" and lacks any mention of the new commit 2c530daf (submodule HEAD: db6df246d...). Add a section documenting what changed in this sync, which conflict resolutions were applied (if any), and update the timestamp. Per the fork update checklist in the file itself, this documentation must be updated alongside submodule changes.

🧰 Tools
🪛 GitHub Actions: CI

[error] 1-1: Missing pinned GhosttyKit checksum for ghostty 2c530daf06473067af537db209a0f3c598881281 in scripts/ghosttykit-checksums.txt


[error] 1-1: Command failed with exit code 1 (process completed with exit code 1) due to missing pinned GhosttyKit checksum

🪛 GitHub Actions: macOS Compatibility

[error] 1-1: Missing pinned GhosttyKit checksum for ghostty 2c530daf06473067af537db209a0f3c598881281 in /Users/runner/work/cmux/cmux/scripts/ghosttykit-checksums.txt

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

In `@ghostty` at line 1, Update the docs/ghostty-fork.md file to reflect the new
submodule sync to commit 2c530daf (submodule HEAD db6df246d...), replacing the
old "as of March 12, 2026" timestamp with the current sync date; add a short
"Sync to 2c530daf" section that lists the primary changes pulled in from that
commit, explicitly document any conflict resolutions applied during the merge
(file names and brief resolution notes), and confirm you followed the fork
update checklist present in the document (e.g., tests run, build verified, and
changes reviewed).

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Verify ghostty submodule commit exists in manaflow-ai/ghostty fork

COMMIT="2c530daf06473067af537db209a0f3c598881281"

echo "=== Checking if commit exists in manaflow-ai/ghostty fork ==="
gh api repos/manaflow-ai/ghostty/commits/$COMMIT --jq '.sha // "COMMIT NOT FOUND"' 2>&1

echo ""
echo "=== Checking if commit is on main branch ==="
cd ghostty
git fetch origin main 2>/dev/null || echo "Could not fetch origin/main"
if git merge-base --is-ancestor $COMMIT origin/main 2>/dev/null; then
  echo "✓ Commit $COMMIT is on origin/main"
else
  echo "✗ Commit $COMMIT is NOT on origin/main (detached HEAD or temp branch?)"
fi
cd ..

Repository: manaflow-ai/cmux

Length of output: 414


🏁 Script executed:

#!/bin/bash
# Get detailed info about ghostty submodule state

echo "=== Current ghostty submodule state ==="
cd ghostty
echo "Current HEAD:"
git rev-parse HEAD
echo ""

echo "=== Is HEAD on a branch? ==="
git rev-parse --abbrev-ref HEAD
echo ""

echo "=== Check if commit is reachable from any branch ==="
git branch -r --contains 2c530daf06473067af537db209a0f3c598881281 || echo "Commit not found in any remote branch"
echo ""

echo "=== Local branches ==="
git branch -a | head -20
echo ""

echo "=== Recent commits ==="
git log --oneline -10
echo ""

echo "=== Git remote ==="
git remote -v

Repository: manaflow-ai/cmux

Length of output: 807


Submodule configuration is broken and must be fixed before merging.

The ghostty submodule has multiple critical issues:

  1. Detached HEAD: Submodule is at detached HEAD db6df246d... instead of being on a branch
  2. Wrong remote: Submodule remote incorrectly points to manaflow-ai/cmux instead of manaflow-ai/ghostty
  3. Unpushed commit: The commit 2c530daf06473067af537db209a0f3c598881281 referenced in the parent repo does not exist in any branch of the manaflow-ai/ghostty fork

The submodule must be fixed by:

  • Checking out the correct branch in the ghostty submodule
  • Pushing any local changes to the manaflow-ai/ghostty fork's main branch
  • Updating the submodule remote configuration if necessary
  • Only then updating the parent repo's submodule pointer
🧰 Tools
🪛 GitHub Actions: CI

[error] 1-1: Missing pinned GhosttyKit checksum for ghostty 2c530daf06473067af537db209a0f3c598881281 in scripts/ghosttykit-checksums.txt


[error] 1-1: Command failed with exit code 1 (process completed with exit code 1) due to missing pinned GhosttyKit checksum

🪛 GitHub Actions: macOS Compatibility

[error] 1-1: Missing pinned GhosttyKit checksum for ghostty 2c530daf06473067af537db209a0f3c598881281 in /Users/runner/work/cmux/cmux/scripts/ghosttykit-checksums.txt

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

In `@ghostty` at line 1, The ghostty submodule is in a detached HEAD at commit
db6df246... and references a commit 2c530daf0647... that isn’t present on the
manaflow-ai/ghostty fork and its remote is incorrectly set to manaflow-ai/cmux;
fix this by entering the ghostty submodule, re-pointing its remote to the
correct repo (manaflow-ai/ghostty), fetching remotes, checking out or creating
the intended branch (e.g., main) so HEAD is not detached, ensure the local
commit 2c530daf0647... is present on that branch (apply, cherry-pick, or
recreate the commit if needed) and push the branch to the manaflow-ai/ghostty
fork, then update the parent repo’s submodule pointer to the pushed commit and
commit that change in the parent repository.

Comment on lines +10 to +12
import AppKit
import QuartzCore

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

find . -name "demo-paperwm.swift" -type f

Repository: manaflow-ai/cmux

Length of output: 88


🌐 Web query:

CVDisplayLink CoreVideo framework Swift import

💡 Result:

To use CVDisplayLink from the CoreVideo framework in Swift on macOS, import the CoreVideo module with import CoreVideo. CVDisplayLink is a type and functions within this framework for creating high-priority display synchronization threads. Example basic setup in a Swift class (e.g., NSOpenGLView subclass): import CoreVideo // CoreVideo framework import for CVDisplayLink class MyView: NSOpenGLView { private var displayLink: CVDisplayLink? override func viewDidMoveToWindow { super.viewDidMoveToWindow if let window = window, window.isVisible { createDisplayLink } } private func createDisplayLink { var link: CVDisplayLink? let result = CVDisplayLinkCreateWithActiveCGDisplays(&link) guard result == kCVReturnSuccess, let displayLink = link else { return } self.displayLink = displayLink // Modern Swift handler (macOS 10.11+) CVDisplayLinkSetOutputHandler(displayLink, { (displayLink, now, outputTime, flagsIn, flagsOut) -> CVReturn in // Render frame here, e.g., self.renderFrame(at: outputTime.pointee) return kCVReturnSuccess }) CVDisplayLinkStart(displayLink) } deinit { if let displayLink = displayLink { CVDisplayLinkStop(displayLink) } } } Link CoreVideo.framework in your Xcode project target Build Phases > Link Binary With Libraries. This synchronizes rendering with display refresh rates, ideal for games or animations.

Citations:


🏁 Script executed:

cat -n ./scripts/demo-paperwm.swift | head -100

Repository: manaflow-ai/cmux

Length of output: 4732


🏁 Script executed:

cat -n ./scripts/demo-paperwm.swift | sed -n '1,300p' | tail -200

Repository: manaflow-ai/cmux

Length of output: 9715


🏁 Script executed:

cat -n ./scripts/demo-paperwm.swift | tail -100

Repository: manaflow-ai/cmux

Length of output: 3888


Add the missing CoreVideo import.

CVDisplayLink, CVDisplayLinkOutputCallback, and related CoreVideo functions are used throughout the display link setup (lines 285–296) and teardown (line 317) but CoreVideo is not imported. This will fail to compile.

💡 Suggested fix
 import AppKit
+import CoreVideo
 import QuartzCore
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import AppKit
import QuartzCore
import AppKit
import CoreVideo
import QuartzCore
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/demo-paperwm.swift` around lines 10 - 12, The file is missing the
CoreVideo import required for CVDisplayLink and related types; add an import
CoreVideo statement near the top alongside existing imports (so the
CVDisplayLink, CVDisplayLinkOutputCallback, CVDisplayLinkStart/Stop and teardown
code in the display link setup and teardown functions will compile).

Comment on lines +213 to +226
let distFromCenter = abs(relativeX) / (panelWidth + panelGap)
let zOffset = -abs(distFromCenter) * perspectiveZ
let scale = max(0.75, 1.0 - distFromCenter * 0.08)

// Subtle rotation for panels going off-screen
let rotation = -relativeX / (bounds.width * 1.5) * edgeRotation

// Opacity falloff
let opacity = max(0.3, 1.0 - distFromCenter * 0.25)

var transform = CATransform3DIdentity
transform = CATransform3DTranslate(transform, screenX + panelWidth / 2 - centerX, 0, zOffset)
transform = CATransform3DRotate(transform, rotation, 0, 1, 0)
transform = CATransform3DScale(transform, scale, scale, 1)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n scripts/demo-paperwm.swift | grep -A 30 -B 5 "perspectiveZ"

Repository: manaflow-ai/cmux

Length of output: 3568


Remove the unary - operator from zOffset calculation to correctly push side panels away from the camera.

perspectiveZ is -120 (negative). The current code multiplies it by -abs(distFromCenter), resulting in a positive zOffset that pushes panels toward the camera, contradicting the intended depth recession. The fix makes zOffset negative so panels correctly recede.

Suggested fix
-            let zOffset = -abs(distFromCenter) * perspectiveZ
+            let zOffset = abs(distFromCenter) * perspectiveZ
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let distFromCenter = abs(relativeX) / (panelWidth + panelGap)
let zOffset = -abs(distFromCenter) * perspectiveZ
let scale = max(0.75, 1.0 - distFromCenter * 0.08)
// Subtle rotation for panels going off-screen
let rotation = -relativeX / (bounds.width * 1.5) * edgeRotation
// Opacity falloff
let opacity = max(0.3, 1.0 - distFromCenter * 0.25)
var transform = CATransform3DIdentity
transform = CATransform3DTranslate(transform, screenX + panelWidth / 2 - centerX, 0, zOffset)
transform = CATransform3DRotate(transform, rotation, 0, 1, 0)
transform = CATransform3DScale(transform, scale, scale, 1)
let distFromCenter = abs(relativeX) / (panelWidth + panelGap)
let zOffset = abs(distFromCenter) * perspectiveZ
let scale = max(0.75, 1.0 - distFromCenter * 0.08)
// Subtle rotation for panels going off-screen
let rotation = -relativeX / (bounds.width * 1.5) * edgeRotation
// Opacity falloff
let opacity = max(0.3, 1.0 - distFromCenter * 0.25)
var transform = CATransform3DIdentity
transform = CATransform3DTranslate(transform, screenX + panelWidth / 2 - centerX, 0, zOffset)
transform = CATransform3DRotate(transform, rotation, 0, 1, 0)
transform = CATransform3DScale(transform, scale, scale, 1)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/demo-paperwm.swift` around lines 213 - 226, The z ordering is
inverted because zOffset is computed with a leading negative: change the zOffset
calculation in the transform block (variable zOffset, which currently uses
-abs(distFromCenter) * perspectiveZ) so it does not negate the product; compute
zOffset as abs(distFromCenter) * perspectiveZ (or otherwise remove the unary '-'
) so that given perspectiveZ is negative (−120) side panels receive a negative
zOffset and correctly recede from the camera.

Comment on lines +106 to +127
let app = NSApplication.shared
app.setActivationPolicy(.regular)

let w = NSWindow(contentRect: NSRect(x: 400, y: 400, width: 600, height: 400),
styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false)
w.title = "Tab Bar Test"

let content = NSView(frame: w.contentView!.bounds)
content.autoresizingMask = [.width, .height]
content.wantsLayer = true
content.layer?.backgroundColor = CGColor(gray: 0.1, alpha: 1)

let tabBar = TestTabBar(frame: NSRect(x: 0, y: content.bounds.height - 30, width: content.bounds.width, height: 30))
tabBar.autoresizingMask = [.width, .minYMargin]
content.addSubview(tabBar)

w.contentView = content
w.makeKeyAndOrderFront(nil)
app.activate(ignoringOtherApps: true)

print("Tab bar test running. Click and drag tabs to reorder.")
app.run()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the test-tabbar.swift file
fd test-tabbar.swift --type f

Repository: manaflow-ai/cmux

Length of output: 85


🏁 Script executed:

# Check demo-paperwm.swift for comparison
fd demo-paperwm.swift --type f

Repository: manaflow-ai/cmux

Length of output: 86


🏁 Script executed:

cat -n scripts/test-tabbar.swift

Repository: manaflow-ai/cmux

Length of output: 5841


🏁 Script executed:

cat -n scripts/demo-paperwm.swift | head -150

Repository: manaflow-ai/cmux

Length of output: 7556


🏁 Script executed:

cat -n scripts/demo-paperwm.swift | grep -A 10 -B 5 "ApplicationDelegate\|applicationShouldTerminateAfterLastWindowClosed\|NSApplication.shared"

Repository: manaflow-ai/cmux

Length of output: 1033


Exit the helper app when the last window closes.

Closing the window currently leaves this script running in the Dock because nothing opts into last-window termination. scripts/demo-paperwm.swift already handles that, so this helper should match.

💡 Suggested fix
+final class AppDelegate: NSObject, NSApplicationDelegate {
+    func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
+        true
+    }
+}
+
 let app = NSApplication.shared
+let delegate = AppDelegate()
+app.delegate = delegate
 app.setActivationPolicy(.regular)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let app = NSApplication.shared
app.setActivationPolicy(.regular)
let w = NSWindow(contentRect: NSRect(x: 400, y: 400, width: 600, height: 400),
styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false)
w.title = "Tab Bar Test"
let content = NSView(frame: w.contentView!.bounds)
content.autoresizingMask = [.width, .height]
content.wantsLayer = true
content.layer?.backgroundColor = CGColor(gray: 0.1, alpha: 1)
let tabBar = TestTabBar(frame: NSRect(x: 0, y: content.bounds.height - 30, width: content.bounds.width, height: 30))
tabBar.autoresizingMask = [.width, .minYMargin]
content.addSubview(tabBar)
w.contentView = content
w.makeKeyAndOrderFront(nil)
app.activate(ignoringOtherApps: true)
print("Tab bar test running. Click and drag tabs to reorder.")
app.run()
final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
true
}
}
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.setActivationPolicy(.regular)
let w = NSWindow(contentRect: NSRect(x: 400, y: 400, width: 600, height: 400),
styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false)
w.title = "Tab Bar Test"
let content = NSView(frame: w.contentView!.bounds)
content.autoresizingMask = [.width, .height]
content.wantsLayer = true
content.layer?.backgroundColor = CGColor(gray: 0.1, alpha: 1)
let tabBar = TestTabBar(frame: NSRect(x: 0, y: content.bounds.height - 30, width: content.bounds.width, height: 30))
tabBar.autoresizingMask = [.width, .minYMargin]
content.addSubview(tabBar)
w.contentView = content
w.makeKeyAndOrderFront(nil)
app.activate(ignoringOtherApps: true)
print("Tab bar test running. Click and drag tabs to reorder.")
app.run()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/test-tabbar.swift` around lines 106 - 127, Add an
NSApplicationDelegate that returns true from
applicationShouldTerminateAfterLastWindowClosed and assign it to the existing
app (let app = NSApplication.shared) so the helper quits when the last window
closes; specifically, create a small AppDelegate class implementing
applicationShouldTerminateAfterLastWindowClosed(_:)->Bool { return true } and
set app.delegate = AppDelegate() before calling app.run(), leaving TestTabBar
and the window setup unchanged.

Comment on lines +9087 to +9090
// Skip all shortcut handling when the niri canvas window is key.
// It handles its own shortcuts via sendEvent/performKeyEquivalent.
if event.window is NiriCanvasWindow { return false }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Resolve the canvas window from windowNumber/NSApp.keyWindow, not just event.window.

This guard misses the key-equivalent paths where AppKit delivers a key event with event.window == nil. In those cases the canvas is still key, but handleCustomShortcut keeps processing global app shortcuts against the last main window.

🛠️ Suggested fix
-        if event.window is NiriCanvasWindow { return false }
+        let shortcutWindow = event.window
+            ?? (event.windowNumber > 0 ? NSApp.window(withWindowNumber: event.windowNumber) : nil)
+            ?? NSApp.keyWindow
+        if shortcutWindow is NiriCanvasWindow { return false }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 9087 - 9090, Update the
shortcut-handling guard to resolve the actual window using the event's
windowNumber or NSApp.keyWindow when event.window is nil (instead of only
checking event.window). In the method that contains the current check (the block
using "if event.window is NiriCanvasWindow { return false }" inside
handleCustomShortcut / AppDelegate), obtain a resolvedWindow from event.window
?? NSApp.window(withWindowNumber: event.windowNumber) ?? NSApp.keyWindow and use
that to test "is NiriCanvasWindow" so key-equivalent events delivered with
event.window == nil are correctly treated as coming from the canvas.

}

// Title
let title = tab.title.isEmpty ? "Shell" : String(tab.title.prefix(20))
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Localize the visible canvas labels.

"Shell" and "Terminal Canvas" are user-visible UI strings, so they should come from String(localized: ..., defaultValue: ...) keys in Resources/Localizable.xcstrings, not bare English literals.

As per coding guidelines, "All user-facing strings must be localized using String(localized: "key.name", defaultValue: "English text") for every string shown in the UI."

Also applies to: 199-199, 367-370, 1034-1034

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

In `@Sources/NiriCanvasView.swift` at line 127, Replace hard-coded UI strings with
localized lookups: in NiriCanvasView where you set let title = tab.title.isEmpty
? "Shell" : String(tab.title.prefix(20)) and at the other noted locations (the
"Terminal Canvas" uses and other literal strings around lines referenced),
change the literals to use String(localized: "key.name", defaultValue: "Shell")
or appropriate keys (e.g., "terminal.canvas.title") and add corresponding
entries in Resources/Localizable.xcstrings; ensure you use descriptive key
names, keep the same default English text in defaultValue, and update all
occurrences mentioned ("Shell", "Terminal Canvas", and the other two literals)
to follow this pattern so user-facing strings are localized.

Comment on lines +238 to +240
func setSurfaces(_ surfaces: [TerminalSurface]) {
for p in panels { p.containerView.removeFromSuperview() }
panels = surfaces.map { makePanel(with: [$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.

⚠️ Potential issue | 🟠 Major

Discarded TerminalSurfaces need explicit teardown.

These paths let TerminalSurface instances fall out of panels without calling teardownSurface(). That bypasses the lifecycle transition used in Sources/GhosttyTerminalView.swift:3259-3290 / Sources/GhosttyTerminalView.swift:4080-4125 to seal the portal before the C surface is freed, so cleanup is left to deinit/request-close timing instead of the normal shutdown path.

Also applies to: 832-843, 961-965

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

In `@Sources/NiriCanvasView.swift` around lines 238 - 240, The setSurfaces
implementation is dropping TerminalSurface instances by replacing panels without
calling their teardown path; update setSurfaces (and the similar code at the
other occurrences) to iterate the existing panels' TerminalSurface(s) and call
teardownSurface() for each discarded surface before removing container views,
then update panels = surfaces.map { makePanel(with: [$0]) }; ensure you
reference the existing panels array, the makePanel(with:) helper, and the
teardownSurface() method so the portal sealing lifecycle used in
GhosttyTerminalView's teardown is executed for each surface being replaced.

Comment on lines +503 to +511
case .inTabBar(let targetLive, let insertIdx):
let live = liveIndices
guard targetLive < live.count else { break }
let targetPi = live[targetLive].panel

if targetPi == sourcePi {
reorderTab(from: sourceTab,
to: min(insertIdx, panels[sourcePi].tabs.count - 1),
inPanel: ObjectIdentifier(panels[sourcePi].containerView))
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Rightward same-panel drops land one slot too far.

insertIdx here is an insertion boundary in the pre-move array. When the source tab moves right within the same panel, removing it first shifts the target left by one, so this path currently drops after the intended slot.

💡 Proposed fix
         if targetPi == sourcePi {
+            let adjustedInsert = insertIdx > sourceTab ? insertIdx - 1 : insertIdx
             reorderTab(from: sourceTab,
-                           to: min(insertIdx, panels[sourcePi].tabs.count - 1),
+                           to: min(adjustedInsert, panels[sourcePi].tabs.count - 1),
                            inPanel: ObjectIdentifier(panels[sourcePi].containerView))
         } else {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/NiriCanvasView.swift` around lines 503 - 511, When handling case
.inTabBar you must adjust insertIdx for same-panel rightward moves because
removing the source first shifts targets left; compute an adjusted index (e.g.
let adjustedInsert = insertIdx - (sourceTab < insertIdx ? 1 : 0)), clamp it
against panels[sourcePi].tabs.count - 1, and pass adjustedInsert to
reorderTab(from:to:inPanel:) instead of the raw insertIdx; keep the rest of the
liveIndices/targetPi/sourcePi logic intact.

Comment on lines +547 to +560
// Remove from source
panels[srcPi].activeSurface?.hostedView.removeFromSuperview()
panels[srcPi].tabs.remove(at: srcTab)
if panels[srcPi].tabs.isEmpty {
panels[srcPi].closing = true
} else {
panels[srcPi].activeTab = min(panels[srcPi].activeTab, panels[srcPi].tabs.count - 1)
panels[srcPi].syncTabBar()
// Show new active in source
if let s = panels[srcPi].activeSurface {
s.hostedView.removeFromSuperview()
panels[srcPi].containerView.addSubview(s.hostedView)
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Keep the same active surface when removing a tab to its left.

Both source-panel removal paths recompute activeTab with min(...) only. If a background tab is dragged out from the left of the active tab, the active index should shift left by one to keep the same surface selected; today it silently jumps to the next tab instead.

💡 Proposed fix
         if panels[srcPi].tabs.isEmpty {
             panels[srcPi].closing = true
         } else {
-            panels[srcPi].activeTab = min(panels[srcPi].activeTab, panels[srcPi].tabs.count - 1)
+            if srcTab < panels[srcPi].activeTab {
+                panels[srcPi].activeTab -= 1
+            } else {
+                panels[srcPi].activeTab = min(panels[srcPi].activeTab, panels[srcPi].tabs.count - 1)
+            }
             panels[srcPi].syncTabBar()

Apply the same adjustment in both helpers.

Also applies to: 582-594

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

In `@Sources/NiriCanvasView.swift` around lines 547 - 560, When removing a tab
from a panel (the block manipulating panels[srcPi].tabs,
panels[srcPi].activeTab, panels[srcPi].activeSurface, and calling
panels[srcPi].syncTabBar), preserve the currently displayed surface if the
removed tab is left of the current activeTab: instead of always setting
activeTab = min(...), if srcTab < panels[srcPi].activeTab decrement
panels[srcPi].activeTab by 1; otherwise set it to min(...). Apply the identical
change in the other helper that performs tab removal (the other code path that
also updates panels[...].tabs, activeTab, activeSurface and syncTabBar) so the
activeSurface does not silently jump when a left-side tab is dragged out.

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.

2 issues found across 10 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/NiriCanvasView.swift">

<violation number="1" location="Sources/NiriCanvasView.swift:931">
P1: Avoid adding an app-level CVDisplayLink; the repo’s Ghostty performance guidance warns this can introduce typing latency. Drive animations via Ghostty wakeups or a scoped timer only while animating instead of a global display link.</violation>
</file>

<file name="scripts/demo-paperwm.swift">

<violation number="1" location="scripts/demo-paperwm.swift:294">
P1: Passing `self` as an unretained pointer to `CVDisplayLink` and stopping it in `deinit` creates a use-after-free race condition. The display link callback can fire on a background thread while or after `self` is deallocated.</violation>
</file>

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

return kCVReturnSuccess
}

CVDisplayLinkSetOutputCallback(displayLink, callback,
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Mar 26, 2026

Choose a reason for hiding this comment

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

P1: Passing self as an unretained pointer to CVDisplayLink and stopping it in deinit creates a use-after-free race condition. The display link callback can fire on a background thread while or after self is deallocated.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/demo-paperwm.swift, line 294:

<comment>Passing `self` as an unretained pointer to `CVDisplayLink` and stopping it in `deinit` creates a use-after-free race condition. The display link callback can fire on a background thread while or after `self` is deallocated.</comment>

<file context>
@@ -0,0 +1,368 @@
+            return kCVReturnSuccess
+        }
+
+        CVDisplayLinkSetOutputCallback(displayLink, callback,
+            Unmanaged.passUnretained(self).toOpaque())
+        CVDisplayLinkStart(displayLink)
</file context>
Fix with Cubic

…eys, instant close

Restores ~840 lines of uncommitted work that was accidentally reverted.

Tab titles: Listen for ghosttyDidSetTitle notification (registered in
setSurfaces, not setup, to avoid main window crash). Display last path
component in tab bar.

Tab bar colors: Use GhosttyApp.shared.defaultBackgroundColor for selected
tab bg. Bar bg = terminal bg darkened 15%. Remove bottom border. Reduce
tab bar height from 30px to 24px.

Scroll fixes: Use fractional visibility (99% threshold) instead of pixel
tolerance. Check against scrollOffset (current) not targetOffset. Add
100px peek when scrolling to reveal. Subtract one panelGap in pw() to
fix gap overflow.

Ctrl+Tab/Ctrl+Shift+Tab: Cycle tabs in focused panel. Handle in
NiriCanvasWindow.performKeyEquivalent (keyCode 48) before ghostty can
swallow it.

Ctrl+9: Jump to last tab in focused panel.

Instant column close: Remove panel from array immediately (no close
animation). Absorb width into split partner via splitPartnerId/
preSplitWidth tracking. Focus neighbor with least scroll cost.

Less vertical padding: viewH - 4 instead of viewH - 20.

No layout() override: Detect window resize in tick() by checking
bounds.width change, avoiding Auto Layout constraint loops.

Reduce peekWidth from 60px to 20px. Min tab width sized to content.
…sition logging

- Fix startup crash: defer startDisplayLink to setSurfaces, guard tick() for empty panels
- Remove NSSetUncaughtExceptionHandler (conflicts with Sentry)
- Tab bar: per-tab widths based on content, scrollable when overflowing, scroll consumed
- Font: use .AppleSystemUIFont (SF Pro) matching bonsplit tabs
- Fix pw() to subtract panelGap (fixes position shift on split/unsplit)
- Add Cmd+D splitRight with gap-aware half-width calculation
- Add position logging for debugging split/close position drift
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.

♻️ Duplicate comments (5)
Sources/NiriCanvasView.swift (5)

636-639: ⚠️ Potential issue | 🟠 Major

Adjust same-panel rightward drop index before reorder.

When source and destination are the same panel, insertion index is computed pre-removal. Rightward moves currently land one slot too far.

💡 Proposed fix
 if targetPi == sourcePi {
+    let adjustedInsert = insertIdx > sourceTab ? insertIdx - 1 : insertIdx
     reorderTab(from: sourceTab,
-              to: min(insertIdx, panels[sourcePi].tabs.count - 1),
+              to: min(adjustedInsert, panels[sourcePi].tabs.count - 1),
               inPanel: ObjectIdentifier(panels[sourcePi].containerView))
 } else {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/NiriCanvasView.swift` around lines 636 - 639, When sourcePi ==
targetPi the insertIdx is computed before removing sourceTab so rightward moves
land one slot too far; detect this case in the same-panel branch and if
insertIdx is greater than the source tab's current index decrement insertIdx by
1 before calling reorderTab (ensure the adjusted index is clamped within
0...panels[sourcePi].tabs.count-1). Update the call site that uses sourceTab,
insertIdx, panels[sourcePi].tabs.count and
ObjectIdentifier(panels[sourcePi].containerView) so reorderTab receives the
corrected destination index.

675-683: ⚠️ Potential issue | 🟠 Major

Preserve active surface identity when removing a tab to the left.

Both source-panel removal paths clamp with min(...) only. If a left-side background tab is removed, activeTab should decrement to keep the same active surface.

💡 Proposed fix
- panels[srcPi].activeTab = min(panels[srcPi].activeTab, panels[srcPi].tabs.count - 1)
+ if srcTab < panels[srcPi].activeTab {
+     panels[srcPi].activeTab -= 1
+ } else {
+     panels[srcPi].activeTab = min(panels[srcPi].activeTab, panels[srcPi].tabs.count - 1)
+ }

Also applies to: 710-717

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

In `@Sources/NiriCanvasView.swift` around lines 675 - 683, When removing a tab
from panels[srcPi] we currently only clamp activeTab with min(...), which fails
to preserve the same active surface when a left-side tab (index < activeTab) is
removed; update the logic in the removal paths (the block handling
panels[srcPi].tabs.remove(at: srcTab) and the duplicate block around the other
removal, e.g. the code invoking panels[srcPi].syncTabBar()) to adjust activeTab
like: if srcTab < panels[srcPi].activeTab then decrement panels[srcPi].activeTab
by 1, otherwise set panels[srcPi].activeTab = min(panels[srcPi].activeTab,
panels[srcPi].tabs.count - 1); ensure you guard against negative values when
tabs becomes empty and keep calling panels[srcPi].syncTabBar() after updating
activeTab.

47-47: ⚠️ Potential issue | 🟠 Major

Localize visible canvas labels and fallback tab titles.

"Shell" and "Terminal Canvas" are user-visible strings and should not be hard-coded in Swift UI/event-visible surfaces.

💡 Proposed fix
- let title = tab.title.isEmpty ? "Shell" : String(tab.title.prefix(20))
+ let title = tab.title.isEmpty
+     ? String(localized: "niri.canvas.tab.shell", defaultValue: "Shell")
+     : String(tab.title.prefix(20))

- let title = tabTitles[s.id] ?? "Shell"
+ let title = tabTitles[s.id] ?? String(localized: "niri.canvas.tab.shell", defaultValue: "Shell")

- ? (panels[srcPi].tabBar.tabs[tabIndex].title.isEmpty ? "Shell" : panels[srcPi].tabBar.tabs[tabIndex].title)
- : "Shell"
+ ? (panels[srcPi].tabBar.tabs[tabIndex].title.isEmpty
+      ? String(localized: "niri.canvas.tab.shell", defaultValue: "Shell")
+      : panels[srcPi].tabBar.tabs[tabIndex].title)
+ : String(localized: "niri.canvas.tab.shell", defaultValue: "Shell")

- win.title = "Terminal Canvas"
+ win.title = String(localized: "niri.canvas.window.title", defaultValue: "Terminal Canvas")

Also add these keys to Resources/Localizable.xcstrings.

As per coding guidelines, "All user-facing strings must be localized using String(localized: "key.name", defaultValue: "English text") for every string shown in the UI."

Also applies to: 193-193, 268-268, 497-499, 1315-1315

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

In `@Sources/NiriCanvasView.swift` at line 47, Replace hard-coded user-facing
strings in NiriCanvasView.swift (e.g., the fallback in let title =
tab.title.isEmpty ? "Shell" : String(tab.title.prefix(20)) and other occurrences
like "Terminal Canvas") with localized lookups using String(localized:
"key.name", defaultValue: "English text"); update each unique key to reflect
context (e.g., "canvas.tab.fallback.shell", "canvas.title.terminal") and add
those keys with the English defaults to Resources/Localizable.xcstrings so all
UI-visible strings use String(localized:..., defaultValue:...) instead of raw
literals.

1296-1298: ⚠️ Potential issue | 🟠 Major

Don’t swallow unhandled Command key events at window level.

The unconditional if cmd { return } consumes unhandled Cmd combinations and can block terminal-owned key paths.

💡 Proposed fix
 if (cmd || ctrl), let canvas = canvasView, canvas.performKeyEquivalent(with: event) { return }
- if cmd { return } // consume unhandled Cmd combos to prevent cmux crash
+ // Let unhandled command equivalents continue through normal responder routing.

Based on learnings, GhosttyNSView.performKeyEquivalent(with:) should receive unbound Command combinations rather than having them swallowed upstream.

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

In `@Sources/NiriCanvasView.swift` around lines 1296 - 1298, Remove the
unconditional swallowing of Cmd events so unhandled Command-key combos can
propagate to the terminal view; in NiriCanvasView.swift, delete or avoid the `if
cmd { return }` branch after the existing `if (cmd || ctrl), let canvas =
canvasView, canvas.performKeyEquivalent(with: event) { return }` check, ensuring
you only return when `canvas.performKeyEquivalent(with:)` actually handled the
event and otherwise let the event continue so
`GhosttyNSView.performKeyEquivalent(with:)` can receive unbound Command
combinations.

315-317: ⚠️ Potential issue | 🟠 Major

Teardown discarded TerminalSurfaces before replacing panels.

Line 315–317 removes old panel views and replaces panels, but dropped surfaces are not explicitly torn down first.

💡 Proposed fix
 func setSurfaces(_ surfaces: [TerminalSurface]) {
-    for p in panels { p.containerView.removeFromSuperview() }
+    let incomingIds = Set(surfaces.map(\.id))
+    for p in panels {
+        for tab in p.tabs where !incomingIds.contains(tab.id) {
+            tab.teardownSurface()
+        }
+        p.containerView.removeFromSuperview()
+    }
     panels = surfaces.map { makePanel(with: [$0]) }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/NiriCanvasView.swift` around lines 315 - 317, The current
setSurfaces(_:) replaces panel views but never tears down the discarded
TerminalSurface instances; before removing old panels call the teardown method
on each surface associated with the existing panels (e.g. iterate panels.flatMap
{ $0.surfaces } and invoke their teardown/tearDown/close method), then remove
their containerView and finally replace panels using makePanel(with:). Ensure
you reference the existing panels and makePanel(with:) when implementing the
teardown loop.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@Sources/NiriCanvasView.swift`:
- Around line 636-639: When sourcePi == targetPi the insertIdx is computed
before removing sourceTab so rightward moves land one slot too far; detect this
case in the same-panel branch and if insertIdx is greater than the source tab's
current index decrement insertIdx by 1 before calling reorderTab (ensure the
adjusted index is clamped within 0...panels[sourcePi].tabs.count-1). Update the
call site that uses sourceTab, insertIdx, panels[sourcePi].tabs.count and
ObjectIdentifier(panels[sourcePi].containerView) so reorderTab receives the
corrected destination index.
- Around line 675-683: When removing a tab from panels[srcPi] we currently only
clamp activeTab with min(...), which fails to preserve the same active surface
when a left-side tab (index < activeTab) is removed; update the logic in the
removal paths (the block handling panels[srcPi].tabs.remove(at: srcTab) and the
duplicate block around the other removal, e.g. the code invoking
panels[srcPi].syncTabBar()) to adjust activeTab like: if srcTab <
panels[srcPi].activeTab then decrement panels[srcPi].activeTab by 1, otherwise
set panels[srcPi].activeTab = min(panels[srcPi].activeTab,
panels[srcPi].tabs.count - 1); ensure you guard against negative values when
tabs becomes empty and keep calling panels[srcPi].syncTabBar() after updating
activeTab.
- Line 47: Replace hard-coded user-facing strings in NiriCanvasView.swift (e.g.,
the fallback in let title = tab.title.isEmpty ? "Shell" :
String(tab.title.prefix(20)) and other occurrences like "Terminal Canvas") with
localized lookups using String(localized: "key.name", defaultValue: "English
text"); update each unique key to reflect context (e.g.,
"canvas.tab.fallback.shell", "canvas.title.terminal") and add those keys with
the English defaults to Resources/Localizable.xcstrings so all UI-visible
strings use String(localized:..., defaultValue:...) instead of raw literals.
- Around line 1296-1298: Remove the unconditional swallowing of Cmd events so
unhandled Command-key combos can propagate to the terminal view; in
NiriCanvasView.swift, delete or avoid the `if cmd { return }` branch after the
existing `if (cmd || ctrl), let canvas = canvasView,
canvas.performKeyEquivalent(with: event) { return }` check, ensuring you only
return when `canvas.performKeyEquivalent(with:)` actually handled the event and
otherwise let the event continue so `GhosttyNSView.performKeyEquivalent(with:)`
can receive unbound Command combinations.
- Around line 315-317: The current setSurfaces(_:) replaces panel views but
never tears down the discarded TerminalSurface instances; before removing old
panels call the teardown method on each surface associated with the existing
panels (e.g. iterate panels.flatMap { $0.surfaces } and invoke their
teardown/tearDown/close method), then remove their containerView and finally
replace panels using makePanel(with:). Ensure you reference the existing panels
and makePanel(with:) when implementing the teardown loop.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c08b07a1-e939-4898-bda2-d31c848396da

📥 Commits

Reviewing files that changed from the base of the PR and between db6df24 and ba64092.

📒 Files selected for processing (1)
  • Sources/NiriCanvasView.swift

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.

2 issues 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. If appropriate, use sub-agents to investigate and fix each issue separately.


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

<violation number="1" location="Sources/NiriCanvasView.swift:160">
P2: The draw-time auto-scroll forces the selected tab into view every frame, which overrides manual tab-bar scrolling and can make overflow tabs unreachable.</violation>

<violation number="2" location="Sources/NiriCanvasView.swift:268">
P2: Tab titles are keyed per panel, but tab move paths don’t transfer the title mapping, so dragging a tab to another panel resets its label to "Shell".</violation>
</file>

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


mutating func syncTabBar() {
tabBar.tabs = tabs.map { s in
let title = tabTitles[s.id] ?? "Shell"
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Mar 28, 2026

Choose a reason for hiding this comment

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

P2: Tab titles are keyed per panel, but tab move paths don’t transfer the title mapping, so dragging a tab to another panel resets its label to "Shell".

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/NiriCanvasView.swift, line 268:

<comment>Tab titles are keyed per panel, but tab move paths don’t transfer the title mapping, so dragging a tab to another panel resets its label to "Shell".</comment>

<file context>
@@ -189,14 +249,25 @@ final class NiriCanvasView: NSView {
         mutating func syncTabBar() {
-            tabBar.tabs = tabs.map { NiriTabBarView.Tab(id: $0.id, title: "Shell") }
+            tabBar.tabs = tabs.map { s in
+                let title = tabTitles[s.id] ?? "Shell"
+                return NiriTabBarView.Tab(id: s.id, title: title)
+            }
</file context>
Fix with Cubic

Blue overlay shows drop zone during drag:
- Left/right 25% edge: creates new column (halves target width)
- Top/bottom 25% edge: creates new column (same width, for now)
- Center: adds tab to existing panel
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.

Actionable comments posted: 1

♻️ Duplicate comments (5)
Sources/NiriCanvasView.swift (5)

731-734: ⚠️ Potential issue | 🟠 Major

Same-panel rightward drag is still off by one.

Line 733 uses pre-removal insertion index directly; rightward moves in the same panel land one slot too far.

Proposed fix
if targetPi == sourcePi {
+    let adjustedInsert = insertIdx > sourceTab ? insertIdx - 1 : insertIdx
     reorderTab(from: sourceTab,
-               to: min(insertIdx, panels[sourcePi].tabs.count - 1),
+               to: min(adjustedInsert, panels[sourcePi].tabs.count - 1),
                inPanel: ObjectIdentifier(panels[sourcePi].containerView))
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/NiriCanvasView.swift` around lines 731 - 734, The same-panel
rightward drag lands one slot too far because you use the pre-removal insertIdx
directly; in the if targetPi == sourcePi branch adjust the insertion position
when moving right by checking if insertIdx > sourceTab and, if so, pass
min(insertIdx - 1, panels[sourcePi].tabs.count - 1) into reorderTab (instead of
min(insertIdx, ...)); update the call site that currently uses reorderTab(from:
sourceTab, to: min(insertIdx, panels[sourcePi].tabs.count - 1), inPanel:
ObjectIdentifier(panels[sourcePi].containerView)) to subtract one for rightward
same-panel moves.

1449-1459: ⚠️ Potential issue | 🟠 Major

Window-level key handling is still swallowing terminal input.

Line 1449 binds Ctrl+D (terminal EOF) to close-tab, and Line 1459 consumes any unhandled Command combo, preventing focused Ghostty from receiving unbound shortcuts.

Proposed fix
- if ctrl && !cmd && !opt && (ch == "d" || event.characters == "\u{04}") {
-     canvasView?.handleCtrlD(); return
- }
  if ctrl && !cmd && !opt && ch == "9" {
      canvasView?.switchToLastTabPublic(); return
  }
  if ctrl && !cmd && !opt, let n = Int(ch), n >= 1, n <= 8 {
      canvasView?.switchToTabPublic(n - 1); return
  }
  if (cmd || ctrl), let canvas = canvasView, canvas.performKeyEquivalent(with: event) { return }
- if cmd { return } // consume unhandled Cmd combos to prevent cmux crash
Based on learnings, `GhosttyNSView.performKeyEquivalent(with:)` must forward unbound Command combinations to terminal handling rather than being swallowed by higher-level shortcut code.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/NiriCanvasView.swift` around lines 1449 - 1459, The window-level key
handler in NiriCanvasView is swallowing terminal input (e.g., Ctrl+D EOF and
unbound Cmd combos) instead of letting focused GhosttyNSView handle them; update
the logic so Ctrl+D and numeric Ctrl shortcuts only act when the canvas/tab UI
is the active responder (e.g., not when the terminal/Ghostty is first
responder), and do not unconditionally consume unhandled Command combinations —
call canvas.performKeyEquivalent(with:) (or GhosttyNSView.performKeyEquivalent)
and if it returns false, allow the responder chain to receive the event rather
than returning/consuming it; adjust checks around handleCtrlD(),
switchToLastTabPublic(), switchToTabPublic(_:) and the final cmd-consume branch
so focused GhosttyNSView gets unbound shortcuts and terminal EOF.

315-318: ⚠️ Potential issue | 🟠 Major

Discarded TerminalSurfaces still skip explicit teardown in setSurfaces.

Replacing panels directly can drop live surfaces without running the expected shutdown path.

Proposed fix
func setSurfaces(_ surfaces: [TerminalSurface]) {
-    for p in panels { p.containerView.removeFromSuperview() }
+    for p in panels {
+        for surface in p.tabs {
+            surface.teardownSurface()
+        }
+        p.containerView.removeFromSuperview()
+    }
     panels = surfaces.map { makePanel(with: [$0]) }
     for p in panels { addSubview(p.containerView) }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/NiriCanvasView.swift` around lines 315 - 318, setSurfaces currently
replaces panels wholesale which can drop live TerminalSurface instances without
running their shutdown/teardown path; before you assign panels = surfaces.map {
... } capture the old panels, for each old panel call its explicit
teardown/cleanup method on the panel or the underlying TerminalSurface (the
codepath that shuts down connections/resources), then removeFromSuperview(), and
only after all old panels are properly torn down create new panels via
makePanel(with:) and add their containerView; ensure you reference panels,
setSurfaces(_:) and makePanel(with:) when making the change.

47-47: ⚠️ Potential issue | 🟠 Major

Localize visible canvas strings (Shell, Terminal Canvas).

These are UI-visible and should not be hardcoded literals.

Proposed fix
- let title = tab.title.isEmpty ? "Shell" : String(tab.title.prefix(20))
+ let title = tab.title.isEmpty
+     ? String(localized: "niri.canvas.tab.untitled", defaultValue: "Shell")
+     : String(tab.title.prefix(20))

- let title = tabTitles[s.id] ?? "Shell"
+ let title = tabTitles[s.id]
+     ?? String(localized: "niri.canvas.tab.untitled", defaultValue: "Shell")

- ? (panels[srcPi].tabBar.tabs[tabIndex].title.isEmpty ? "Shell" : panels[srcPi].tabBar.tabs[tabIndex].title)
- : "Shell"
+ ? (panels[srcPi].tabBar.tabs[tabIndex].title.isEmpty
+     ? String(localized: "niri.canvas.tab.untitled", defaultValue: "Shell")
+     : panels[srcPi].tabBar.tabs[tabIndex].title)
+ : String(localized: "niri.canvas.tab.untitled", defaultValue: "Shell")

- win.title = "Terminal Canvas"
+ win.title = String(localized: "niri.canvas.window.title", defaultValue: "Terminal Canvas")
As per coding guidelines, "All user-facing strings must be localized using `String(localized: "key.name", defaultValue: "English text")` for every string shown in the UI."

Also applies to: 193-193, 268-269, 507-508, 1477-1477

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

In `@Sources/NiriCanvasView.swift` at line 47, Replace hardcoded UI strings with
localized variants: where you build the tab title (the assignment using
tab.title and the literal "Shell") use String(localized: "niri.canvas.shell",
defaultValue: "Shell") and apply String(localized: "niri.canvas.terminalCanvas",
defaultValue: "Terminal Canvas") for any visible "Terminal Canvas" labels; find
other hardcoded UI strings (the other occurrences of "Shell" and "Terminal
Canvas" noted in the review) and replace them similarly using String(localized:
..., defaultValue: ...), keeping the original literal as the defaultValue and
using clear localization keys (e.g., "niri.canvas.shell",
"niri.canvas.terminalCanvas") so all user-facing text in NiriCanvasView is
localized.

768-769: ⚠️ Potential issue | 🟠 Major

Preserve active surface when removing a tab to its left.

These paths only clamp with min(...). If a tab left of the active tab is removed, active index must shift left by one to keep the same surface selected.

Proposed fix pattern (apply at all listed ranges)
- panels[srcPi].activeTab = min(panels[srcPi].activeTab, panels[srcPi].tabs.count - 1)
+ if srcTab < panels[srcPi].activeTab {
+     panels[srcPi].activeTab -= 1
+ } else {
+     panels[srcPi].activeTab = min(panels[srcPi].activeTab, panels[srcPi].tabs.count - 1)
+ }

Also applies to: 843-844, 878-879

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

In `@Sources/NiriCanvasView.swift` around lines 768 - 769, When removing a tab,
adjust panels[sourcePi].activeTab so it shifts left if the removed tab was to
the left of the active one: compute newActive = panels[sourcePi].activeTab -
(removedIndex < panels[sourcePi].activeTab ? 1 : 0), then clamp to valid range
e.g. panels[sourcePi].activeTab = max(0, min(newActive,
panels[sourcePi].tabs.count - 1)); finally call panels[sourcePi].syncTabBar().
Use the actual removed tab index variable from the surrounding code in place of
removedIndex; apply this change where panels[sourcePi].activeTab is currently
only clamped with min(...).
🧹 Nitpick comments (1)
Sources/NiriCanvasView.swift (1)

386-387: Use dlog instead of NSLog for debug event logging consistency.

This file is DEBUG-only and these are debug-event traces; keep them on the unified debug event log path.

Proposed refactor
- NSLog("niri.hitTest -> TAB BAR local=\(localPt) tbFrame=\(tb.frame)")
+ dlog("niri.hitTest -> TAB BAR local=\(localPt) tbFrame=\(tb.frame)")

- NSLog("niri.hitTest MISS near tabBar local=\(localPt) tbFrame=\(tabBar?.frame ?? .zero) result=\(type(of: result).self)")
+ dlog("niri.hitTest MISS near tabBar local=\(localPt) tbFrame=\(tabBar?.frame ?? .zero) result=\(type(of: result).self)")
Based on learnings, all DEBUG key/mouse/focus/split/tab debug events should be routed via `dlog(...)`.

Also applies to: 393-394

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

In `@Sources/NiriCanvasView.swift` around lines 386 - 387, Replace the debug NSLog
calls in the hit-testing debug block with dlog(...) to keep debug-event logs
consistent: change the NSLog("niri.hitTest -> TAB BAR local=\(localPt)
tbFrame=\(tb.frame)") (and the similar NSLog at the later occurrence around the
same hitTest logic) to dlog("niri.hitTest -> TAB BAR local=\(localPt)
tbFrame=\(tb.frame)") so the message, interpolated values (localPt, tb.frame)
and surrounding control flow (return tb) remain unchanged but use the unified
dlog debug-event path.
🤖 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/NiriCanvasView.swift`:
- Around line 755-767: The terminalEdge branch can crash when removing the only
tab from a panel that is also the target (sourcePi == targetPi) because panels
can become empty and subsequent accesses to panels[targetPi] go out of bounds;
change the logic in the case .terminalEdge handling (around variables sourcePi,
sourceTab, targetLive, liveIndices, panels) to detect the scenario where
removing the source tab will remove its panel (panels[sourcePi].tabs.count == 1)
and the target refers to that same panel, and handle it before any further
panels[...] indexing—either by early-returning, remapping targetPi to a safe
value, or recomputing liveIndices/targetPi after the removal and verifying guard
targetLive < live.count again; ensure all later reads of panels[targetPi] are
protected by the new guard so no out-of-bounds access occurs.

---

Duplicate comments:
In `@Sources/NiriCanvasView.swift`:
- Around line 731-734: The same-panel rightward drag lands one slot too far
because you use the pre-removal insertIdx directly; in the if targetPi ==
sourcePi branch adjust the insertion position when moving right by checking if
insertIdx > sourceTab and, if so, pass min(insertIdx - 1,
panels[sourcePi].tabs.count - 1) into reorderTab (instead of min(insertIdx,
...)); update the call site that currently uses reorderTab(from: sourceTab, to:
min(insertIdx, panels[sourcePi].tabs.count - 1), inPanel:
ObjectIdentifier(panels[sourcePi].containerView)) to subtract one for rightward
same-panel moves.
- Around line 1449-1459: The window-level key handler in NiriCanvasView is
swallowing terminal input (e.g., Ctrl+D EOF and unbound Cmd combos) instead of
letting focused GhosttyNSView handle them; update the logic so Ctrl+D and
numeric Ctrl shortcuts only act when the canvas/tab UI is the active responder
(e.g., not when the terminal/Ghostty is first responder), and do not
unconditionally consume unhandled Command combinations — call
canvas.performKeyEquivalent(with:) (or GhosttyNSView.performKeyEquivalent) and
if it returns false, allow the responder chain to receive the event rather than
returning/consuming it; adjust checks around handleCtrlD(),
switchToLastTabPublic(), switchToTabPublic(_:) and the final cmd-consume branch
so focused GhosttyNSView gets unbound shortcuts and terminal EOF.
- Around line 315-318: setSurfaces currently replaces panels wholesale which can
drop live TerminalSurface instances without running their shutdown/teardown
path; before you assign panels = surfaces.map { ... } capture the old panels,
for each old panel call its explicit teardown/cleanup method on the panel or the
underlying TerminalSurface (the codepath that shuts down connections/resources),
then removeFromSuperview(), and only after all old panels are properly torn down
create new panels via makePanel(with:) and add their containerView; ensure you
reference panels, setSurfaces(_:) and makePanel(with:) when making the change.
- Line 47: Replace hardcoded UI strings with localized variants: where you build
the tab title (the assignment using tab.title and the literal "Shell") use
String(localized: "niri.canvas.shell", defaultValue: "Shell") and apply
String(localized: "niri.canvas.terminalCanvas", defaultValue: "Terminal Canvas")
for any visible "Terminal Canvas" labels; find other hardcoded UI strings (the
other occurrences of "Shell" and "Terminal Canvas" noted in the review) and
replace them similarly using String(localized: ..., defaultValue: ...), keeping
the original literal as the defaultValue and using clear localization keys
(e.g., "niri.canvas.shell", "niri.canvas.terminalCanvas") so all user-facing
text in NiriCanvasView is localized.
- Around line 768-769: When removing a tab, adjust panels[sourcePi].activeTab so
it shifts left if the removed tab was to the left of the active one: compute
newActive = panels[sourcePi].activeTab - (removedIndex <
panels[sourcePi].activeTab ? 1 : 0), then clamp to valid range e.g.
panels[sourcePi].activeTab = max(0, min(newActive, panels[sourcePi].tabs.count -
1)); finally call panels[sourcePi].syncTabBar(). Use the actual removed tab
index variable from the surrounding code in place of removedIndex; apply this
change where panels[sourcePi].activeTab is currently only clamped with min(...).

---

Nitpick comments:
In `@Sources/NiriCanvasView.swift`:
- Around line 386-387: Replace the debug NSLog calls in the hit-testing debug
block with dlog(...) to keep debug-event logs consistent: change the
NSLog("niri.hitTest -> TAB BAR local=\(localPt) tbFrame=\(tb.frame)") (and the
similar NSLog at the later occurrence around the same hitTest logic) to
dlog("niri.hitTest -> TAB BAR local=\(localPt) tbFrame=\(tb.frame)") so the
message, interpolated values (localPt, tb.frame) and surrounding control flow
(return tb) remain unchanged but use the unified dlog debug-event path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8dbdf620-6cb8-4752-8f8d-206c991d18f9

📥 Commits

Reviewing files that changed from the base of the PR and between ba64092 and 7be1d94.

📒 Files selected for processing (1)
  • Sources/NiriCanvasView.swift

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.

4 issues 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. If appropriate, use sub-agents to investigate and fix each issue separately.


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

<violation number="1" location="Sources/NiriCanvasView.swift:557">
P2: Auto-scroll bounds should be based on viewport edges, not first/last panel frames; current checks can disable drag auto-scroll when panels overflow.</violation>

<violation number="2" location="Sources/NiriCanvasView.swift:750">
P1: Handle self-edge drops of a single-tab panel as a no-op before removing the source tab. Otherwise the source removal can empty `panels`, and subsequent edge-layout indexing can crash with out-of-bounds access.</violation>

<violation number="3" location="Sources/NiriCanvasView.swift:751">
P1: Guard against self-target center drops before calling `moveTab`; otherwise a single-tab panel can be marked closing and disappear.</violation>

<violation number="4" location="Sources/NiriCanvasView.swift:777">
P0: Handle the `panels.isEmpty` case after removing the source tab in `.terminalEdge`; current indexing can crash when dropping the last tab.</violation>
</file>

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

}

// Recompute targetPi after potential removal
let newLive2 = liveIndices
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Mar 28, 2026

Choose a reason for hiding this comment

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

P0: Handle the panels.isEmpty case after removing the source tab in .terminalEdge; current indexing can crash when dropping the last tab.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/NiriCanvasView.swift, line 777:

<comment>Handle the `panels.isEmpty` case after removing the source tab in `.terminalEdge`; current indexing can crash when dropping the last tab.</comment>

<file context>
@@ -647,16 +742,83 @@ final class NiriCanvasView: NSView {
+            }
+
+            // Recompute targetPi after potential removal
+            let newLive2 = liveIndices
+            switch edge {
+            case .left:
</file context>
Suggested change
let newLive2 = liveIndices
let newLive2 = liveIndices
if panels.isEmpty {
var newCol = makePanel(with: [surface])
newCol.targetWidth = 1.0
newCol.currentWidth = 1.0
newCol.presetIndex = -1
panels.insert(newCol, at: 0)
addSubview(newCol.containerView)
focusedIndex = 0
break
}
Fix with Cubic

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 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. If appropriate, use sub-agents to investigate and fix each issue separately.


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

<violation number="1" location="Sources/NiriCanvasView.swift:664">
P1: The same-panel single-tab edge-drop guard is incomplete: it excludes only left/right, so top/bottom drops can still reach a crash path when the source panel is removed.</violation>
</file>

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


if let edge {
// Skip edge drops on own panel with single tab (would just recreate same layout)
if isSamePanel && isSingleTab && (edge == .left || edge == .right) { return }
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Mar 28, 2026

Choose a reason for hiding this comment

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

P1: The same-panel single-tab edge-drop guard is incomplete: it excludes only left/right, so top/bottom drops can still reach a crash path when the source panel is removed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/NiriCanvasView.swift, line 664:

<comment>The same-panel single-tab edge-drop guard is incomplete: it excludes only left/right, so top/bottom drops can still reach a crash path when the source panel is removed.</comment>

<file context>
@@ -657,10 +657,16 @@ final class NiriCanvasView: NSView {
+
             if let edge {
+                // Skip edge drops on own panel with single tab (would just recreate same layout)
+                if isSamePanel && isSingleTab && (edge == .left || edge == .right) { return }
                 currentDropTarget = .init(kind: .terminalEdge(livePanel: li, edge: edge))
                 showDropZoneOverlay(in: termRect, edge: edge)
</file context>
Suggested change
if isSamePanel && isSingleTab && (edge == .left || edge == .right) { return }
if isSamePanel && isSingleTab { return }
Fix with Cubic

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.

♻️ Duplicate comments (6)
Sources/NiriCanvasView.swift (6)

315-317: ⚠️ Potential issue | 🟠 Major

setSurfaces(_:) drops old TerminalSurfaces without explicit teardown.

Replacing panels here removes UI containers but skips the explicit surface teardown path for discarded tabs.

Suggested fix
 func setSurfaces(_ surfaces: [TerminalSurface]) {
-    for p in panels { p.containerView.removeFromSuperview() }
+    for p in panels {
+        for tab in p.tabs {
+            tab.teardownSurface()
+        }
+        p.containerView.removeFromSuperview()
+    }
     panels = surfaces.map { makePanel(with: [$0]) }
     for p in panels { addSubview(p.containerView) }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/NiriCanvasView.swift` around lines 315 - 317, setSurfaces(_:)
currently replaces panels and removes container views but never invokes the
TerminalSurface teardown path, leaking/skip-cleanup discarded tabs; before
removing or reassigning panels, iterate the existing panels and call the
appropriate teardown/cleanup on their TerminalSurface instances (e.g. a
teardown/close/destroy method exposed by TerminalSurface) and only then
removeFromSuperview and replace panels via makePanel(with:). Ensure you
reference panels, setSurfaces(_:), TerminalSurface, and makePanel(with:) when
locating where to insert the teardown calls.

737-740: ⚠️ Potential issue | 🟠 Major

Same-panel rightward tab drops are still off by one.

insertIdx is computed pre-removal; when moving right within the same panel, it must be adjusted before calling reorderTab.

Suggested fix
 if targetPi == sourcePi {
+    let adjustedInsert = insertIdx > sourceTab ? insertIdx - 1 : insertIdx
     reorderTab(from: sourceTab,
-               to: min(insertIdx, panels[sourcePi].tabs.count - 1),
+               to: min(adjustedInsert, panels[sourcePi].tabs.count - 1),
                inPanel: ObjectIdentifier(panels[sourcePi].containerView))
 } else {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/NiriCanvasView.swift` around lines 737 - 740, When moving a tab
within the same panel (targetPi == sourcePi) the pre-removal insertIdx must be
adjusted for rightward moves; detect if targetPi == sourcePi and insertIdx >
sourceTab.index (or the current tab index in panels[sourcePi].tabs) and
decrement insertIdx by 1 before calling reorderTab; then call reorderTab(from:
sourceTab, to: min(adjustedInsertIdx, panels[sourcePi].tabs.count - 1), inPanel:
ObjectIdentifier(panels[sourcePi].containerView)) so the destination index
accounts for the removal.

664-665: ⚠️ Potential issue | 🔴 Critical

Self-edge drop of a single-tab panel can still crash (.top/.bottom).

The guard only blocks .left/.right; .top/.bottom on the same single-tab panel can remove the source panel and then index panels[...] when empty.

Suggested fix
 if let edge {
-    // Skip edge drops on own panel with single tab (would just recreate same layout)
-    if isSamePanel && isSingleTab && (edge == .left || edge == .right) { return }
+    // Skip any edge drop on own single-tab panel (no-op and avoids empty-array indexing later)
+    if isSamePanel && isSingleTab { return }
     currentDropTarget = .init(kind: .terminalEdge(livePanel: li, edge: edge))
     showDropZoneOverlay(in: termRect, edge: edge)

Also applies to: 761-766, 808-809

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

In `@Sources/NiriCanvasView.swift` around lines 664 - 665, The guard that prevents
self-edge drops only checks left/right and misses top/bottom, so a drag to
.top/.bottom of the same single-tab panel can remove the source and later index
an empty panels array; change the early-return condition used before assigning
currentDropTarget = .init(kind: .terminalEdge(livePanel: li, edge: edge)) to
return whenever isSamePanel && isSingleTab (i.e., treat all edges the same) or
explicitly include .top and .bottom in the check; apply the same fix to the
other identical blocks that set currentDropTarget (the other occurrences using
isSamePanel, isSingleTab, edge and .terminalEdge(livePanel:...)).

774-775: ⚠️ Potential issue | 🟠 Major

Active tab should shift left when removing a tab to its left.

All three removal paths clamp with min(...) only, which can switch the visible surface unexpectedly after drag/move.

Suggested fix pattern (apply in all three blocks)
- panels[srcPi].activeTab = min(panels[srcPi].activeTab, panels[srcPi].tabs.count - 1)
+ if srcTab < panels[srcPi].activeTab {
+     panels[srcPi].activeTab -= 1
+ } else {
+     panels[srcPi].activeTab = min(panels[srcPi].activeTab, panels[srcPi].tabs.count - 1)
+ }

Also applies to: 849-850, 884-885

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

In `@Sources/NiriCanvasView.swift` around lines 774 - 775, The activeTab
adjustment currently only clamps down with min(...) which can jump the visible
tab when a tab left of the active one is removed; instead update
panels[sourcePi].activeTab by decrementing when the removed tab index is less
than the current activeTab, otherwise clamp to the last index. Locate the three
blocks that set panels[sourcePi].activeTab and syncTabBar() and replace the
single min(...) assignment with logic: if removedIndex <
panels[sourcePi].activeTab then panels[sourcePi].activeTab -= 1 else
panels[sourcePi].activeTab = min(panels[sourcePi].activeTab,
panels[sourcePi].tabs.count - 1); then call panels[sourcePi].syncTabBar(); apply
this change for the occurrences around the existing panels[sourcePi].activeTab /
tabs.count updates.

47-47: ⚠️ Potential issue | 🟡 Minor

Localize visible canvas strings (Shell, window title).

These are user-facing labels/titles and should use String(localized:..., defaultValue:...) keys instead of bare literals.

As per coding guidelines, "All user-facing strings must be localized using String(localized: "key.name", defaultValue: "English text") ... Never use bare string literals in ... UI elements."

Also applies to: 193-193, 268-268, 507-508, 1483-1483

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

In `@Sources/NiriCanvasView.swift` at line 47, Replace bare user-facing string
literals with the localization API: update the title assignment (let title =
...) to use String(localized:..., defaultValue:...) instead of the literal
"Shell", and similarly replace any other visible labels/window titles in this
file (the other occurrences noted) to use localization keys (e.g.
"canvas.shell.title", "canvas.window.title", etc.) with appropriate defaultValue
text; ensure you use descriptive localization keys and keep the same logic
(e.g., tab.title.isEmpty ? String(localized:..., defaultValue:...) :
String(tab.title.prefix(20))).

1455-1466: ⚠️ Potential issue | 🟠 Major

Window-level key handling still steals terminal-owned input.

Ctrl+D is consumed here, and unhandled Cmd combos are swallowed (if cmd { return }), so terminal delivery is still blocked for unbound shortcuts.

Based on learnings, GhosttyNSView.performKeyEquivalent(with:) should forward unbound Command combinations (not swallow them) so terminal input remains intact.

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

In `@Sources/NiriCanvasView.swift` around lines 1455 - 1466, The window-level key
handler is swallowing terminal-owned input; change it so only truly-handled
shortcuts stop propagation: make canvasView?.handleCtrlD() return a Bool (or add
a boolean predicate) and only return from the key handler when that call returns
true, similarly use the Boolean result of canvas.performKeyEquivalent(with:) to
decide whether to return, and remove the unconditional swallow on Cmd combos
(replace the `if cmd { return }` with letting the event fall through or calling
super/GhosttyNSView.performKeyEquivalent(with:) so unbound Command combinations
are forwarded to the terminal). Ensure references: handleCtrlD(),
switchToLastTabPublic(), switchToTabPublic(), performKeyEquivalent(with:), and
GhosttyNSView.performKeyEquivalent(with:) are updated accordingly.
🧹 Nitpick comments (1)
Sources/NiriCanvasView.swift (1)

386-387: Use dlog(...) instead of NSLog(...) for DEBUG event traces.

This file already centralizes debug logging via nlog/dlog; switching these hit-test traces keeps logging consistent.

Based on learnings, all DEBUG key/mouse/focus/split/tab events should use dlog("message").

Also applies to: 393-394

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

In `@Sources/NiriCanvasView.swift` around lines 386 - 387, Replace debug traces
that call NSLog(...) inside the NiriCanvasView hit-test logic with the project's
debug logger dlog(...); specifically update the NSLog call that prints
"niri.hitTest -> TAB BAR local=\(localPt) tbFrame=\(tb.frame)" to use dlog and
change the other two NSLog calls around the same hitTest code path (the ones at
the nearby locations referenced in the comment) to dlog as well so all DEBUG
key/mouse/focus/split/tab events use the centralized dlog logging; locate these
in the hitTest implementation where localPt and tb.frame are logged (and the two
adjacent NSLog lines at 393-394) and swap them to dlog("...") with the same
message content.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@Sources/NiriCanvasView.swift`:
- Around line 315-317: setSurfaces(_:) currently replaces panels and removes
container views but never invokes the TerminalSurface teardown path,
leaking/skip-cleanup discarded tabs; before removing or reassigning panels,
iterate the existing panels and call the appropriate teardown/cleanup on their
TerminalSurface instances (e.g. a teardown/close/destroy method exposed by
TerminalSurface) and only then removeFromSuperview and replace panels via
makePanel(with:). Ensure you reference panels, setSurfaces(_:), TerminalSurface,
and makePanel(with:) when locating where to insert the teardown calls.
- Around line 737-740: When moving a tab within the same panel (targetPi ==
sourcePi) the pre-removal insertIdx must be adjusted for rightward moves; detect
if targetPi == sourcePi and insertIdx > sourceTab.index (or the current tab
index in panels[sourcePi].tabs) and decrement insertIdx by 1 before calling
reorderTab; then call reorderTab(from: sourceTab, to: min(adjustedInsertIdx,
panels[sourcePi].tabs.count - 1), inPanel:
ObjectIdentifier(panels[sourcePi].containerView)) so the destination index
accounts for the removal.
- Around line 664-665: The guard that prevents self-edge drops only checks
left/right and misses top/bottom, so a drag to .top/.bottom of the same
single-tab panel can remove the source and later index an empty panels array;
change the early-return condition used before assigning currentDropTarget =
.init(kind: .terminalEdge(livePanel: li, edge: edge)) to return whenever
isSamePanel && isSingleTab (i.e., treat all edges the same) or explicitly
include .top and .bottom in the check; apply the same fix to the other identical
blocks that set currentDropTarget (the other occurrences using isSamePanel,
isSingleTab, edge and .terminalEdge(livePanel:...)).
- Around line 774-775: The activeTab adjustment currently only clamps down with
min(...) which can jump the visible tab when a tab left of the active one is
removed; instead update panels[sourcePi].activeTab by decrementing when the
removed tab index is less than the current activeTab, otherwise clamp to the
last index. Locate the three blocks that set panels[sourcePi].activeTab and
syncTabBar() and replace the single min(...) assignment with logic: if
removedIndex < panels[sourcePi].activeTab then panels[sourcePi].activeTab -= 1
else panels[sourcePi].activeTab = min(panels[sourcePi].activeTab,
panels[sourcePi].tabs.count - 1); then call panels[sourcePi].syncTabBar(); apply
this change for the occurrences around the existing panels[sourcePi].activeTab /
tabs.count updates.
- Line 47: Replace bare user-facing string literals with the localization API:
update the title assignment (let title = ...) to use String(localized:...,
defaultValue:...) instead of the literal "Shell", and similarly replace any
other visible labels/window titles in this file (the other occurrences noted) to
use localization keys (e.g. "canvas.shell.title", "canvas.window.title", etc.)
with appropriate defaultValue text; ensure you use descriptive localization keys
and keep the same logic (e.g., tab.title.isEmpty ? String(localized:...,
defaultValue:...) : String(tab.title.prefix(20))).
- Around line 1455-1466: The window-level key handler is swallowing
terminal-owned input; change it so only truly-handled shortcuts stop
propagation: make canvasView?.handleCtrlD() return a Bool (or add a boolean
predicate) and only return from the key handler when that call returns true,
similarly use the Boolean result of canvas.performKeyEquivalent(with:) to decide
whether to return, and remove the unconditional swallow on Cmd combos (replace
the `if cmd { return }` with letting the event fall through or calling
super/GhosttyNSView.performKeyEquivalent(with:) so unbound Command combinations
are forwarded to the terminal). Ensure references: handleCtrlD(),
switchToLastTabPublic(), switchToTabPublic(), performKeyEquivalent(with:), and
GhosttyNSView.performKeyEquivalent(with:) are updated accordingly.

---

Nitpick comments:
In `@Sources/NiriCanvasView.swift`:
- Around line 386-387: Replace debug traces that call NSLog(...) inside the
NiriCanvasView hit-test logic with the project's debug logger dlog(...);
specifically update the NSLog call that prints "niri.hitTest -> TAB BAR
local=\(localPt) tbFrame=\(tb.frame)" to use dlog and change the other two NSLog
calls around the same hitTest code path (the ones at the nearby locations
referenced in the comment) to dlog as well so all DEBUG
key/mouse/focus/split/tab events use the centralized dlog logging; locate these
in the hitTest implementation where localPt and tb.frame are logged (and the two
adjacent NSLog lines at 393-394) and swap them to dlog("...") with the same
message content.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 23b6f825-06ab-46f5-9b7c-7bb542e4e42d

📥 Commits

Reviewing files that changed from the base of the PR and between 7be1d94 and 57a5bc1.

📒 Files selected for processing (1)
  • Sources/NiriCanvasView.swift

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 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. If appropriate, use sub-agents to investigate and fix each issue separately.


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

<violation number="1" location="Sources/NiriCanvasView.swift:541">
P2: Refresh the drop target before handling `.leftMouseUp`; otherwise per-frame drag auto-scroll can leave `currentDropTarget` stale and apply the drop to the wrong destination.</violation>
</file>

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

- Scroll speed ramps up over 0.4s (quadratic ease-in): starts slow, accelerates
- Speed also scales with proximity to edge (closer = faster)
- Drop overlay and target update during tick-driven scroll (follows content)
- Dwell timer resets when cursor leaves the scroll zone
- Clamp scroll to max (last panel right edge)
- After drop, restore scroll position and only adjust if focused panel is off-screen
- Drop target stays visually stable instead of jumping
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