Skip to content

Add debug logs for Cmd+F find bar refocus - #840

Merged
lawrencecchen merged 10 commits into
mainfrom
task-cmdf-find-refocus-logs
Mar 4, 2026
Merged

lawrencecchen merged 10 commits into
mainfrom
task-cmdf-find-refocus-logs

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Mar 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Adds debug logging across the Cmd+F find bar lifecycle to trace a bug where pressing Cmd+F after switching away from and back to the terminal window fails to reopen the find bar
  • Logs cover: menu action trigger, startSearch(), overlay mount/unmount (setSearchOverlay), search field focus changes, window didBecomeKey/didResignKey, applyFirstResponderIfNeeded guard decisions, ensureFocus guard, and moveFocus calls
  • Uses dlog (debug log file) in GhosttyTerminalView.swift and SurfaceSearchOverlay.swift (where NSLog is unavailable due to C-interop), and NSLog in TabManager.swift and cmuxApp.swift

Testing

  • Built and launched with ./scripts/reload.sh --tag cmdf-refocus-logs
  • Tail the debug log with tail -f /tmp/cmux-debug-cmdf-refocus-logs.log | grep find
  • Repro steps: Cmd+F → Escape → switch to another app → switch back → Cmd+F again

Related

  • Task: Debug Cmd+F find bar refocus failure after window switch

Summary by cubic

Fixes Cmd+F find bar not refocusing after window/app switches by tracking intent and restoring the correct first responder (search field or terminal). Adds focused debug logs and IME-safe guards, and switches to a native AppKit text field to prevent focus loss and accidental text replacement.

  • Bug Fixes

    • Track intent with SearchFocusTarget (searchField/terminal) and restore on window-become-key; Cmd+F/.ghosttySearchFocus set searchField; Escape with a non-empty query and terminal clicks set terminal; onFieldDidFocus resets to searchField; explicitly unfocus the terminal when search takes focus.
    • Replace SwiftUI TextField with an NSViewRepresentable-wrapped NSTextField; AppKit delegate handles Escape/Return with an IME-safe hasMarkedText guard; Coordinator focuses via .ghosttySearchFocus, rechecks isFocused to avoid stale requests, and skips makeFirstResponder when already focused to prevent text selection/replacement; programmatic text sync avoids stomping active IME composition.
    • Prevent focus stealing: field-editor-aware search overlay detection; skip applyFirstResponder when a search overlay is focused; never focus the NSHostingView; gate ensureFocus after active tab/pane checks; clear surfaceView.onFocus when unset; force terminal to yield responder on window resign.
  • Debug Logs

    • dlog trace for Cmd+F, startSearch (created/reuse + firstResponder), overlay mount/unmount, native field begin/end + Escape/Return, searchFocus notification handling, window didBecomeKey/didResignKey, applyFirstResponder/restoreSearchFocus decisions, and moveFocus.

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

Summary by CodeRabbit

  • New Features

    • Native AppKit-backed search field with improved keyboard handling (Escape/Return, Shift navigation) and IME-safe behavior; stronger focus synchronization and immediate focus restoration.
  • Bug Fixes

    • More reliable focus preservation/restoration between search field and terminal across overlay mount/unmount and window focus changes.
    • Graceful handling when no terminal panel is selected during search start.
  • Chores

    • Expanded DEBUG logging for search actions, overlay lifecycle, and focus routing.

Traces the full lifecycle: menu action, startSearch, overlay mount/unmount,
focus changes, window key/resign, applyFirstResponderIfNeeded guards, and
moveFocus calls. Helps reproduce the bug where Cmd+F fails to reopen after
switching away and back to the terminal window.
@vercel

vercel Bot commented Mar 4, 2026 •

Copy link
Copy Markdown

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

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

@coderabbitai

coderabbitai Bot commented Mar 4, 2026 •

Copy link
Copy Markdown

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

Replaces the SwiftUI TextField with an AppKit-backed native text field via NSViewRepresentable, adds IME-safe Escape/Return handling, focus synchronization (NotificationCenter + window-first-responder logic), and extensive DEBUG logging and focus-routing across terminal view, TabManager, and app Find menu.

Changes

Cohort / File(s) Summary
Search overlay (AppKit-backed)
Sources/Find/SurfaceSearchOverlay.swift
Replaced SwiftUI TextField with SearchTextFieldRepresentable (AppKit SearchNativeTextField + Coordinator). Adds bindings for text/focus, IME-safe Escape handling, Return/Shift-Return navigation, onEscape/onReturn callbacks, .ghosttySearchFocus observer, and DEBUG logging for native field lifecycle.
Terminal focus routing & restore
Sources/GhosttyTerminalView.swift
Added enum SearchFocusTarget and private(set) var searchFocusTarget; wired .ghosttySearchFocus observer; implemented restoreSearchFocus(window:), findTextField(in:), and isSearchOverlayOrDescendant(_:); updated window key handlers, setFocus, setSearchOverlay(_:), and first-responder paths to preserve/restore focus with debug logs.
Search lifecycle & logging
Sources/TabManager.swift
startSearch now guards missing selectedTerminalPanel and logs created vs reused searchState and first-responder info; searchSelection and hideFind include DEBUG logging for panel identifiers.
App menu logging
Sources/cmuxApp.swift
Added DEBUG-only NSLog when the Find menu (Cmd+F) action is invoked.
General debug additions
...
Multiple DEBUG-only NSLog calls added across overlay, terminal view, and TabManager to trace focus, mount/unmount, begin/end editing, escape, and focus restoration events.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant App as cmuxApp
    participant TabMgr as TabManager
    participant Surface as GhosttySurfaceScrollView
    participant Overlay as SurfaceSearchOverlay (SwiftUI)
    participant Native as SearchNativeTextField (AppKit)
    participant Window as NSWindow

    User->>App: Press Cmd+F
    App->>TabMgr: startSearch()
    TabMgr->>Surface: setSearchOverlay(searchState)
    Surface->>Overlay: mount SwiftUI overlay
    Overlay->>Native: Representable mounts native NSTextField
    Native->>Surface: post .ghosttySearchFocus notification
    Surface->>Window: restoreSearchFocus(window)
    Surface->>Native: make native field first responder
    User->>Native: Type / Press Escape / Press Return
    Native->>Overlay: Coordinator updates text & invokes onEscape/onReturn
    Surface->>Surface: update searchFocusTarget and preserve/restore focus
    Window->>Surface: windowDidResignKey / windowDidBecomeKey (focus reconciliation)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I swapped the field for threads of AppKit light,
I nudged focus home when keys took flight,
Escape tiptoes safe with IME in mind,
Return hops forward, Shift-bound rewind,
I log my hops and burrow out of sight.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.33% 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 'Add debug logs for Cmd+F find bar refocus' accurately captures the primary purpose of the PR: adding debug logging to trace Cmd+F find bar refocus behavior.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch task-cmdf-find-refocus-logs

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
Sources/TabManager.swift (2)

769-772: Same issue: use dlog() with #if DEBUG guard.

♻️ Suggested fix
 func hideFind() {
-    NSLog("Find: hideFind panel=%@", selectedTerminalPanel?.id.uuidString ?? "nil")
+#if DEBUG
+    dlog("Find: hideFind panel=\(selectedTerminalPanel?.id.uuidString ?? "nil")")
+#endif
     selectedTerminalPanel?.searchState = nil
 }

As per coding guidelines: "All debug events must go to the unified debug log, with free function dlog("message") wrapping all call sites in #if DEBUG / #endif"

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

In `@Sources/TabManager.swift` around lines 769 - 772, Replace the NSLog call in
hideFind with the unified debug logger: remove NSLog("Find: hideFind panel=%@",
...) and instead call dlog(...) wrapped inside a `#if` DEBUG / `#endif` block; leave
the assignment to selectedTerminalPanel?.searchState = nil unchanged so only the
logging is affected and use selectedTerminalPanel?.id.uuidString ?? "nil" as the
message payload in the dlog call.

729-742: Debug logging should use dlog() wrapped in #if DEBUG.

The NSLog calls here will run in release builds and don't go to the unified debug log. This violates the project's debug logging convention. The rest of this file consistently uses the correct pattern (e.g., lines 585-594, 1212-1216).

♻️ Suggested fix to use dlog with DEBUG guard
 func startSearch() {
     guard let panel = selectedTerminalPanel else {
-        NSLog("Find: startSearch SKIPPED no selectedTerminalPanel")
+#if DEBUG
+        dlog("Find: startSearch SKIPPED no selectedTerminalPanel")
+#endif
         return
     }
     let wasNil = panel.searchState == nil
     if wasNil {
         panel.searchState = TerminalSurface.SearchState()
     }
-    NSLog("Find: startSearch workspace=%@ panel=%@ created=%@ firstResponder=%@",
-          panel.workspaceId.uuidString, panel.id.uuidString,
-          wasNil ? "yes" : "no(reuse)",
-          String(describing: panel.surface.hostedView.window?.firstResponder))
+#if DEBUG
+    dlog("Find: startSearch workspace=\(panel.workspaceId.uuidString) panel=\(panel.id.uuidString) created=\(wasNil ? "yes" : "no(reuse)") firstResponder=\(String(describing: panel.surface.hostedView.window?.firstResponder))")
+#endif
     NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface)
     _ = panel.performBindingAction("start_search")
 }

As per coding guidelines: "All debug events (keys, mouse, focus, splits, tabs) must go to the unified debug log, with free function dlog("message") wrapping all call sites in #if DEBUG / #endif"

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

In `@Sources/TabManager.swift` around lines 729 - 742, Replace the release NSLog
calls in this startSearch block with debug-only dlog calls: wrap the logging in
`#if` DEBUG / `#endif` and call dlog(...) instead of NSLog, preserving the same
message content and the variables (panel.workspaceId.uuidString,
panel.id.uuidString, wasNil, String(describing:
panel.surface.hostedView.window?.firstResponder)); leave the guard,
panel.searchState creation (TerminalSurface.SearchState()),
NotificationCenter.post(name: .ghosttySearchFocus, object: panel.surface), and _
= panel.performBindingAction("start_search") unchanged.
🤖 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/cmuxApp.swift`:
- Line 457: Replace the direct NSLog call used for the Find menu debug event
with the unified debug logger: remove NSLog("Find: menu Cmd+F fired") and
instead call dlog("Find: menu Cmd+F fired") wrapped in a compile-time guard (`#if`
DEBUG / `#endif`); update the code site where NSLog is referenced so the debug
message is emitted only in debug builds via dlog.

---

Nitpick comments:
In `@Sources/TabManager.swift`:
- Around line 769-772: Replace the NSLog call in hideFind with the unified debug
logger: remove NSLog("Find: hideFind panel=%@", ...) and instead call dlog(...)
wrapped inside a `#if` DEBUG / `#endif` block; leave the assignment to
selectedTerminalPanel?.searchState = nil unchanged so only the logging is
affected and use selectedTerminalPanel?.id.uuidString ?? "nil" as the message
payload in the dlog call.
- Around line 729-742: Replace the release NSLog calls in this startSearch block
with debug-only dlog calls: wrap the logging in `#if` DEBUG / `#endif` and call
dlog(...) instead of NSLog, preserving the same message content and the
variables (panel.workspaceId.uuidString, panel.id.uuidString, wasNil,
String(describing: panel.surface.hostedView.window?.firstResponder)); leave the
guard, panel.searchState creation (TerminalSurface.SearchState()),
NotificationCenter.post(name: .ghosttySearchFocus, object: panel.surface), and _
= panel.performBindingAction("start_search") unchanged.

ℹ️ Review info
Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a3c643f0-b79d-4202-a5c4-f934f7d7b427

📥 Commits

Reviewing files that changed from the base of the PR and between c7bdd92 and 603df79.

📒 Files selected for processing (4)
  • Sources/Find/SurfaceSearchOverlay.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/TabManager.swift
  • Sources/cmuxApp.swift

Comment thread Sources/cmuxApp.swift Outdated
@greptile-apps

greptile-apps Bot commented Mar 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds comprehensive debug logging to trace the Cmd+F find bar lifecycle for diagnosing a refocus bug where the find bar fails to reopen after window switching. The logs cover the complete state machine: menu action trigger, search initialization, overlay mounting/unmounting, focus changes, window key transitions, and first responder guard decisions.

  • Uses dlog() in SurfaceSearchOverlay.swift and GhosttyTerminalView.swift (where NSLog is unavailable due to C-interop)
  • Uses NSLog in TabManager.swift and cmuxApp.swift
  • All logging properly wrapped in #if DEBUG blocks per project conventions
  • No behavior changes - purely additive debug instrumentation

Confidence Score: 5/5

  • Safe to merge - purely additive debug logging with no behavior changes
  • All changes are debug-only logging statements properly wrapped in #if DEBUG blocks, using correct logging functions per file context (dlog vs NSLog based on C-interop requirements), following established project conventions
  • No files require special attention

Important Files Changed

Filename Overview
Sources/Find/SurfaceSearchOverlay.swift added debug logs for escape key handling and search field focus changes, properly wrapped in #if DEBUG blocks
Sources/GhosttyTerminalView.swift added comprehensive debug logging for window key state changes, search overlay mount/unmount, focus movement, and first responder guard decisions
Sources/TabManager.swift enhanced startSearch() and hideFind() with detailed NSLog statements including workspace, panel, state creation, and first responder info
Sources/cmuxApp.swift added NSLog statement to track Cmd+F menu action trigger

Last reviewed commit: 603df79

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 4 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/cmuxApp.swift">

<violation number="1" location="Sources/cmuxApp.swift:457">
P3: Gate this debug-only log with `#if DEBUG` and route it through `dlog` so it doesn’t ship to release logs and shows up in the debug log file used for Cmd+F tracing.</violation>
</file>

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

Comment thread Sources/cmuxApp.swift Outdated
When the find bar is open and the user switches away and back, the
window's first responder was left as the NSWindow itself because
applyFirstResponderIfNeeded bailed on the searchState guard and nothing
refocused the find bar. This caused a dead state where neither the
search field nor the terminal accepted keyboard input.

Add a SearchFocusTarget state machine (.searchField / .terminal) to
GhosttySurfaceScrollView that tracks user intent. On window-become-key,
restoreSearchFocus() makes the correct view first responder based on
the target. Pressing Escape with a non-empty needle sets target to
.terminal so window reactivation preserves that intent. Cmd+F and
.ghosttySearchFocus notifications reset target to .searchField.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e5cb51c44b

ℹ️ About Codex in GitHub

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

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

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

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

Comment on lines 5187 to 5189
onMoveFocusToTerminal: { [weak self] in
self?.searchFocusTarget = .terminal
self?.moveFocus()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Update search focus target when field is manually refocused

Setting searchFocusTarget to .terminal on Escape is not paired with any update when the user later clicks back into the search field, so the target can become stale. In that state, switching to another app and back will run restoreSearchFocus and force focus to the terminal even though the search field was the last focused control, which is a user-visible regression for find-bar workflows after using Escape once.

Useful? React with 👍 / 👎.

Two bugs found from debug logs:

1. Other surfaces in the same window (without search active) were calling
   applyFirstResponderIfNeeded and stealing focus from the find bar's
   surface. Added a check: if current first responder is inside a search
   overlay NSHostingView, don't steal it.

2. window.makeFirstResponder(overlay) on the NSHostingView was wrong.
   It made the hosting view itself the responder, which ate keystrokes
   as performKeyEquivalent instead of routing them to the SwiftUI
   TextField inside. Removed that call, now only posting the
   .ghosttySearchFocus notification to let SwiftUI handle internal
   focus via @focusstate.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Prompt for AI agents (unresolved issues)

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


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

<violation number="1" location="Sources/GhosttyTerminalView.swift:5782">
P2: Search-overlay focus guard misses field-editor responders, so terminal focus can still be stolen while find text input is active.</violation>
</file>

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

Comment thread Sources/GhosttyTerminalView.swift Outdated

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 67bd247787

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread Sources/GhosttyTerminalView.swift Outdated
Comment on lines +5675 to +5678
if surfaceView.terminalSurface?.searchState != nil {
guard let window else { return }
restoreSearchFocus(window: window)
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve tab/pane gating before restoring search focus

In ensureFocus, the new early return for active search calls restoreSearchFocus before the existing selectedTabId/focused-pane guards run. That means a delayed retry can now re-focus a stale surface with an open find bar even after selection moved elsewhere, because the function no longer reaches the checks that previously prevented cross-tab/pane focus steals. This is user-visible during rapid workspace/pane switches with find open.

Useful? React with 👍 / 👎.

Comment thread Sources/Find/SurfaceSearchOverlay.swift Outdated
Comment on lines +56 to +59
.onChange(of: isSearchFieldFocused) { focused in
#if DEBUG
dlog("find.overlay.focusChanged surface=\(surfaceId.uuidString.prefix(5)) focused=\(focused)")
#endif

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Update search focus target when field focus changes

The new focus-change hook only logs and never updates searchFocusTarget, so clicking from the find field back into the terminal leaves the target stale as .searchField. On the next didBecomeKey, restoreSearchFocus will re-focus the search UI even though the user's last in-window focus was the terminal, which regresses expected Cmd+F workflows that keep the find bar open while returning to terminal input.

Useful? React with 👍 / 👎.

…h restore

The notification-only approach fails because SwiftUI @focusstate can't
propagate to AppKit when the first responder is the NSWindow itself
(no view in the responder chain to anchor the change). And making the
NSHostingView first responder eats keys as performKeyEquivalent.

Now walks the hosting view's subview tree to find the actual editable
NSTextField backing the SwiftUI TextField, and calls
window.makeFirstResponder directly on it. Falls back to notification
if the text field isn't found.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 5781-5825: The current focus-guard in applyFirstResponder misses
cases where window.firstResponder is the shared NSTextView field editor; update
the guard so if window.firstResponder is an NSTextView (the field editor) you
obtain its delegate (e.g., firstResponder.delegate) and treat that delegate as
the editing control to check with isSearchOverlayOrDescendant (cast delegate to
NSView or NSTextField as appropriate and walk its superview chain). Concretely:
in the early return check replace/augment the existing "if let fr =
window.firstResponder as? NSView" branch to also handle "if let fe =
window.firstResponder as? NSTextView, let editorOwner = fe.delegate as? NSView"
and call isSearchOverlayOrDescendant(editorOwner) so field-editor-backed search
TextFields prevent other surfaces from stealing focus.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c0f88cdd-b7e1-46ba-b5e6-13bcb0b3d31a

📥 Commits

Reviewing files that changed from the base of the PR and between 603df79 and 67bd247.

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

Comment thread Sources/GhosttyTerminalView.swift
restoreSearchFocus now does both:
1. AppKit: makeFirstResponder(nsTextField) so typing works immediately
2. SwiftUI: post .ghosttySearchFocus so @focusstate syncs and
   .onExitCommand (Escape) and .onKeyPress (Return) still work

Also: clicking the terminal while find bar is open now sets
searchFocusTarget to .terminal, so window reactivation correctly
restores terminal focus instead of jumping back to the search field.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)

1955-1978: ⚠️ Potential issue | 🟠 Major

Use DEBUG-gated dlog here; avoid raw needle logging in release builds.

Line 1958, Line 1972, and Line 1977 use NSLog directly, and Line 1972 logs the full search needle. This violates the repository debug-log policy and leaks user-entered query text to system logs.

Proposed fix
-                NSLog("Find: search state created tab=%@ surface=%@", tabId.uuidString, id.uuidString)
+                `#if` DEBUG
+                dlog("find.searchState created tab=\(tabId.uuidString.prefix(5)) surface=\(id.uuidString.prefix(5))")
+                `#endif`
                 searchNeedleCancellable = searchState.$needle
                     .removeDuplicates()
@@
                     .switchToLatest()
                     .sink { [weak self] needle in
-                        NSLog("Find: needle updated tab=%@ surface=%@ needle=%@", self?.tabId.uuidString ?? "unknown", self?.id.uuidString ?? "unknown", needle)
+                        `#if` DEBUG
+                        dlog(
+                            "find.needle updated tab=\(self?.tabId.uuidString.prefix(5) ?? "unknown") " +
+                            "surface=\(self?.id.uuidString.prefix(5) ?? "unknown") chars=\(needle.count)"
+                        )
+                        `#endif`
                         _ = self?.performBindingAction("search:\(needle)")
                     }
             } else if oldValue != nil {
                 searchNeedleCancellable = nil
-                NSLog("Find: search state cleared tab=%@ surface=%@", tabId.uuidString, id.uuidString)
+                `#if` DEBUG
+                dlog("find.searchState cleared tab=\(tabId.uuidString.prefix(5)) surface=\(id.uuidString.prefix(5))")
+                `#endif`
                 _ = performBindingAction("end_search")
             }

As per coding guidelines **/*.swift: “All debug events (keys, mouse, focus, splits, tabs) must go to the unified debug log, with free function dlog("message") wrapping all call sites in #if DEBUG / #endif”.

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

In `@Sources/GhosttyTerminalView.swift` around lines 1955 - 1978, Replace the raw
NSLog calls in the searchState didSet block (the calls around
hostedView.cancelFocusRequest(), inside the search needle sink, and when
clearing search state) with the project's debug-only logger: wrap logging in `#if`
DEBUG / `#endif` and call dlog(...) instead of NSLog; specifically, change the
NSLog near hostedView.cancelFocusRequest(), the NSLog inside the sink that
currently prints the raw needle, and the NSLog when clearing search state to use
dlog so no raw search needle or user-entered text is emitted in release builds,
while keeping searchNeedleCancellable, performBindingAction("search:\(needle)"),
and performBindingAction("end_search") behavior unchanged.
♻️ Duplicate comments (1)
Sources/GhosttyTerminalView.swift (1)

5788-5854: ⚠️ Potential issue | 🔴 Critical

Handle field-editor first responder in the search-overlay focus guard.

Line 5789 + Line 5847 only walk NSView ancestry. During text editing, AppKit usually sets window.firstResponder to the shared field-editor NSTextView, not the NSTextField view in the overlay tree. This can bypass the guard and let other surfaces steal focus while find is active.

Proposed fix
-        if let fr = window.firstResponder as? NSView, isSearchOverlayOrDescendant(fr) {
+        if let fr = window.firstResponder, isSearchOverlayOrDescendant(fr) {
 `#if` DEBUG
             dlog("find.applyFirstResponder SKIP surface=\(surfaceShort) reason=searchOverlayFocused")
 `#endif`
             return
         }
@@
-    private func isSearchOverlayOrDescendant(_ view: NSView) -> Bool {
-        var current: NSView? = view
+    private func isSearchOverlayOrDescendant(_ responder: NSResponder) -> Bool {
+        if let editor = responder as? NSTextView,
+           editor.isFieldEditor,
+           let editedView = editor.delegate as? NSView {
+            return isSearchOverlayOrDescendant(editedView)
+        }
+
+        guard let view = responder as? NSView else { return false }
+        var current: NSView? = view
         while let v = current {
             if v is NSHostingView<SurfaceSearchOverlay> { return true }
             current = v.superview
         }
         return false
     }
In macOS AppKit, while editing an NSTextField, does NSWindow.firstResponder become the NSTextField itself or a shared NSTextView field editor? What is the recommended way to map that first responder back to the edited control?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` around lines 5788 - 5854, The guard misses
cases where window.firstResponder is the shared field-editor NSTextView (so
editing a search NSTextField won't be detected); update logic to map a
NSTextView field editor back to its owning NSTextField before checking ancestry.
Specifically, in the focus-guard and in isSearchOverlayOrDescendant(_:) handle
when the responder is an NSTextView: if let fe = window.firstResponder as?
NSTextView, get fe.delegate as? NSView (or as? NSTextField) and use that view
for the isSearchOverlayOrDescendant check; also ensure findTextField(in:)
remains the same to locate backing text fields. Target symbols:
isSearchOverlayOrDescendant(_:), restoreSearchFocus(window:), and the initial
focus-guard that inspects window.firstResponder.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 1955-1978: Replace the raw NSLog calls in the searchState didSet
block (the calls around hostedView.cancelFocusRequest(), inside the search
needle sink, and when clearing search state) with the project's debug-only
logger: wrap logging in `#if` DEBUG / `#endif` and call dlog(...) instead of NSLog;
specifically, change the NSLog near hostedView.cancelFocusRequest(), the NSLog
inside the sink that currently prints the raw needle, and the NSLog when
clearing search state to use dlog so no raw search needle or user-entered text
is emitted in release builds, while keeping searchNeedleCancellable,
performBindingAction("search:\(needle)"), and performBindingAction("end_search")
behavior unchanged.

---

Duplicate comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 5788-5854: The guard misses cases where window.firstResponder is
the shared field-editor NSTextView (so editing a search NSTextField won't be
detected); update logic to map a NSTextView field editor back to its owning
NSTextField before checking ancestry. Specifically, in the focus-guard and in
isSearchOverlayOrDescendant(_:) handle when the responder is an NSTextView: if
let fe = window.firstResponder as? NSTextView, get fe.delegate as? NSView (or
as? NSTextField) and use that view for the isSearchOverlayOrDescendant check;
also ensure findTextField(in:) remains the same to locate backing text fields.
Target symbols: isSearchOverlayOrDescendant(_:), restoreSearchFocus(window:),
and the initial focus-guard that inspects window.firstResponder.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0e934c63-7312-44f7-92b2-68b754832d5f

📥 Commits

Reviewing files that changed from the base of the PR and between 67bd247 and 3d41035.

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

The core issue: SwiftUI @focusstate does not sync with AppKit's
first responder after window resign/become-key cycles. This caused
the find bar to lose all keyboard input after switching windows.

Previous attempts to bridge SwiftUI and AppKit focus (notifications,
makeFirstResponder on the backing NSTextField, belt-and-suspenders
approaches) all failed because SwiftUI event handlers (.onExitCommand
for Escape, .onKeyPress for Return) require @focusstate to be set.

Fix: replace the SwiftUI TextField with an NSViewRepresentable-wrapped
NSTextField (SearchTextFieldRepresentable), following the proven
OmnibarNativeTextField pattern already in BrowserPanelView.swift.

- Escape and Return handled via control(_:textView:doCommandBy:)
  at the AppKit delegate level, no @focusstate needed
- Focus restored via .ghosttySearchFocus notification observed
  directly by the Coordinator, calling makeFirstResponder immediately
- hasMarkedText() guard preserves CJK IME composition (issue #118)
- isProgrammaticMutation guard prevents text binding cursor reset
- Removes findTextField(in:) subview walk hack

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 2 files (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/Find/SurfaceSearchOverlay.swift">

<violation number="1" location="Sources/Find/SurfaceSearchOverlay.swift:346">
P2: The deferred focus block can apply a stale focus request and steal focus back to the search field after focus intent has changed. Re-check `isFocused` inside the async block before calling `makeFirstResponder`.</violation>
</file>

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

Comment thread Sources/Find/SurfaceSearchOverlay.swift

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 996133fc0e

ℹ️ About Codex in GitHub

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

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

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

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

Comment on lines +5825 to +5828
var current: NSView? = view
while let v = current {
if v is NSHostingView<SurfaceSearchOverlay> { return true }
current = v.superview

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Treat field-editor responders as search overlay focus

isSearchOverlayOrDescendant only walks superview and matches NSHostingView<SurfaceSearchOverlay>, but when the find field is actively editing AppKit sets window.firstResponder to the shared NSTextView field editor instead of a descendant of the hosting view. In that common state this helper returns false, so another surface in the same window can still pass applyFirstResponderIfNeeded and call makeFirstResponder(surfaceView), stealing focus from an open find bar after didBecomeKey/surface focus churn.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

5788-5831: ⚠️ Potential issue | 🔴 Critical

Search-overlay focus guard still misses AppKit field-editor responders.

At Line 5789 and Line 5824, the guard only walks NSView ancestry. During active NSTextField editing, window.firstResponder is commonly the shared field-editor NSTextView, which is outside the overlay hierarchy; focus can still be stolen.

🐛 Proposed fix
-        if let fr = window.firstResponder as? NSView, isSearchOverlayOrDescendant(fr) {
+        if let fr = window.firstResponder, isSearchOverlayOrDescendant(fr) {
 `#if` DEBUG
             dlog("find.applyFirstResponder SKIP surface=\(surfaceShort) reason=searchOverlayFocused")
 `#endif`
             return
         }
@@
-    private func isSearchOverlayOrDescendant(_ view: NSView) -> Bool {
-        var current: NSView? = view
+    private func isSearchOverlayOrDescendant(_ responder: NSResponder) -> Bool {
+        if let editor = responder as? NSTextView,
+           editor.isFieldEditor,
+           let editedView = editor.delegate as? NSView {
+            return isSearchOverlayOrDescendant(editedView)
+        }
+        guard let view = responder as? NSView else { return false }
+        var current: NSView? = view
         while let v = current {
             if v is NSHostingView<SurfaceSearchOverlay> { return true }
             current = v.superview
         }
         return false
     }

Run this read-only check to confirm the guard handles field-editor responders:

#!/bin/bash
rg -n -C3 'applyFirstResponderIfNeeded|isSearchOverlayOrDescendant|firstResponder as\? NSView|isFieldEditor|delegate as\? NSView' Sources/GhosttyTerminalView.swift

Expected result after fix: isSearchOverlayOrDescendant accepts NSResponder and includes an NSTextView field-editor delegate path.

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

In `@Sources/GhosttyTerminalView.swift` around lines 5788 - 5831, The focus-guard
currently only accepts NSView and misses when window.firstResponder is the
shared field-editor NSTextView; update the guard to accept NSResponder and walk
both view superview ancestry and field-editor delegate chains. Change callers
that do "window.firstResponder as? NSView" (used in applyFirstResponderIfNeeded
and restoreSearchFocus guards) to pass the raw NSResponder into
isSearchOverlayOrDescendant(_:) and modify isSearchOverlayOrDescendant to take
an NSResponder, treat NSView by walking superview -> superview, and if the
responder is an NSTextView (field editor) follow its delegate (delegate as?
NSView) and then walk that view's superview chain checking for
NSHostingView<SurfaceSearchOverlay>. Ensure behavior and DEBUG logging remain
the same.
🤖 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/GhosttyTerminalView.swift`:
- Around line 5788-5831: The focus-guard currently only accepts NSView and
misses when window.firstResponder is the shared field-editor NSTextView; update
the guard to accept NSResponder and walk both view superview ancestry and
field-editor delegate chains. Change callers that do "window.firstResponder as?
NSView" (used in applyFirstResponderIfNeeded and restoreSearchFocus guards) to
pass the raw NSResponder into isSearchOverlayOrDescendant(_:) and modify
isSearchOverlayOrDescendant to take an NSResponder, treat NSView by walking
superview -> superview, and if the responder is an NSTextView (field editor)
follow its delegate (delegate as? NSView) and then walk that view's superview
chain checking for NSHostingView<SurfaceSearchOverlay>. Ensure behavior and
DEBUG logging remain the same.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c9da2a33-a4ac-4441-8d88-ca07ea0ad21e

📥 Commits

Reviewing files that changed from the base of the PR and between 3d41035 and 996133f.

📒 Files selected for processing (2)
  • Sources/Find/SurfaceSearchOverlay.swift
  • Sources/GhosttyTerminalView.swift

The Ghostty cursor kept blinking even when the search field was focused
because ghostty_surface_set_focus(false) was only called via
surfaceView.resignFirstResponder. After window switching, the surface
view may not have been the first responder, so resign was never called.

Fix: call surface.setFocus(false) in both the .ghosttySearchFocus
notification observer and directly in restoreSearchFocus. This ensures
the cursor stops blinking regardless of previous first-responder state.
1. isSearchOverlayOrDescendant now accepts NSResponder and follows
   the field-editor delegate chain back to the owning NSTextField.
   Previously, when the search field was being edited, the shared
   NSTextView field editor was the first responder (outside the
   overlay hierarchy), so the guard missed it and other surfaces
   could steal focus.

2. Converted all NSLog calls in TabManager (startSearch, hideFind,
   searchSelection), cmuxApp (Find menu), and GhosttyTerminalView
   (searchState didSet) to dlog() wrapped in #if DEBUG. Avoids
   leaking search needle text to system logs in release builds.

3. Added isFocused re-check inside the deferred focus block in
   SearchTextFieldRepresentable to prevent stale focus requests
   from stealing focus back after intent has changed.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 5690-5696: The current early return for an active search bypasses
the existing guard/retry flow and can skip retrying visibility/key-window
checks; move the search-active path so it executes after the existing "guard let
window else { return }" and within the retry logic block (i.e., keep the check
of surfaceView.terminalSurface?.searchState and the call to
restoreSearchFocus(window:) inside the same guarded/retry flow used for
ensureFocus) so that restoreSearchFocus(window:) is only attempted when window
is available and will participate in the retry behavior when
reparenting/reactivation occurs; adjust the control flow around isActive and the
guard-let window to nest the search-active branch rather than returning early.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6424b5c2-0348-4519-869a-60483c0fde33

📥 Commits

Reviewing files that changed from the base of the PR and between 996133f and 98b2d0a.

📒 Files selected for processing (4)
  • Sources/Find/SurfaceSearchOverlay.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/TabManager.swift
  • Sources/cmuxApp.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • Sources/Find/SurfaceSearchOverlay.swift

Comment on lines 5690 to 5696
guard isActive else { return }
guard surfaceView.terminalSurface?.searchState == nil else { return }
if surfaceView.terminalSurface?.searchState != nil {
guard let window else { return }
restoreSearchFocus(window: window)
return
}
guard let window else { return }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Keep the search-active ensureFocus path inside the existing guard/retry flow.

At Line [5691], active search returns before visibility/key-window checks and retry logic. In transient reparent/reactivation states, this can drop focus restoration instead of retrying.

♻️ Proposed adjustment
-        guard isActive else { return }
-        if surfaceView.terminalSurface?.searchState != nil {
-            guard let window else { return }
-            restoreSearchFocus(window: window)
-            return
-        }
-        guard let window else { return }
+        guard isActive else { return }
+        guard let window else { return }
         guard surfaceView.isVisibleInUI else {
             retry()
             return
         }
         guard !isHiddenForFocus, hasUsablePortalGeometry else {
@@
             retry()
             return
         }
+
+        if surfaceView.terminalSurface?.searchState != nil {
+            if !window.isKeyWindow {
+                window.makeKeyAndOrderFront(nil)
+            }
+            restoreSearchFocus(window: window)
+            if searchFocusTarget == .terminal, !isSurfaceViewFirstResponder() {
+                retry()
+            }
+            return
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` around lines 5690 - 5696, The current
early return for an active search bypasses the existing guard/retry flow and can
skip retrying visibility/key-window checks; move the search-active path so it
executes after the existing "guard let window else { return }" and within the
retry logic block (i.e., keep the check of
surfaceView.terminalSurface?.searchState and the call to
restoreSearchFocus(window:) inside the same guarded/retry flow used for
ensureFocus) so that restoreSearchFocus(window:) is only attempted when window
is available and will participate in the retry behavior when
reparenting/reactivation occurs; adjust the control flow around isActive and the
guard-let window to nest the search-active branch rather than returning early.

Every keystroke updated searchState.needle (@published), which triggered
a SwiftUI re-render → ensureFocus → restoreSearchFocus → posted
.ghosttySearchFocus notification → Coordinator called makeFirstResponder
unconditionally. makeFirstResponder on an already-editing NSTextField
ends the editing session and restarts with all text selected, so the
next typed character replaced the previous one ("hi" → "i").

Fix: check if the field is already first responder before calling
makeFirstResponder in the notification handler.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7bc4809f57

ℹ️ About Codex in GitHub

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

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

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

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

Comment on lines 5121 to +5123
func setFocusHandler(_ handler: (() -> Void)?) {
surfaceView.onFocus = handler
surfaceView.onFocus = { [weak self] in
// When the terminal surface gains focus (click, tab, etc.), update the

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear onFocus when handler is nil

setFocusHandler now always installs a closure, even when callers pass nil, so teardown paths like dismantleNSView no longer truly unregister focus callbacks. In this state, incidental terminal focus events can still mutate searchFocusTarget to .terminal while the find bar is open, which changes later didBecomeKey restoration behavior away from the search field despite no user focus action.

Useful? React with 👍 / 👎.

- Add onFieldDidFocus callback so clicking back into the search field
  after Escape updates searchFocusTarget = .searchField, fixing stale
  focus restoration after window switches.
- Guard updateNSView text sync with !editor.hasMarkedText() to prevent
  stomping active CJK IME composition.
- Move ensureFocus search state check after tab/pane selection guards
  so search focus isn't restored on non-active tabs/panes.
- Clear surfaceView.onFocus when setFocusHandler(nil) is called.
@lawrencecchen
lawrencecchen merged commit ace5393 into main Mar 4, 2026
12 checks passed
@lawrencecchen
lawrencecchen deleted the task-cmdf-find-refocus-logs branch March 4, 2026 10:30
bn-l pushed a commit to bn-l/cmux that referenced this pull request Apr 3, 2026
* Add debug logs for Cmd+F find bar focus/refocus state machine

Traces the full lifecycle: menu action, startSearch, overlay mount/unmount,
focus changes, window key/resign, applyFirstResponderIfNeeded guards, and
moveFocus calls. Helps reproduce the bug where Cmd+F fails to reopen after
switching away and back to the terminal window.

* Fix Cmd+F find bar focus loss after window switch

When the find bar is open and the user switches away and back, the
window's first responder was left as the NSWindow itself because
applyFirstResponderIfNeeded bailed on the searchState guard and nothing
refocused the find bar. This caused a dead state where neither the
search field nor the terminal accepted keyboard input.

Add a SearchFocusTarget state machine (.searchField / .terminal) to
GhosttySurfaceScrollView that tracks user intent. On window-become-key,
restoreSearchFocus() makes the correct view first responder based on
the target. Pressing Escape with a non-empty needle sets target to
.terminal so window reactivation preserves that intent. Cmd+F and
.ghosttySearchFocus notifications reset target to .searchField.

* Fix multi-surface focus stealing and NSHostingView responder issue

Two bugs found from debug logs:

1. Other surfaces in the same window (without search active) were calling
   applyFirstResponderIfNeeded and stealing focus from the find bar's
   surface. Added a check: if current first responder is inside a search
   overlay NSHostingView, don't steal it.

2. window.makeFirstResponder(overlay) on the NSHostingView was wrong.
   It made the hosting view itself the responder, which ate keystrokes
   as performKeyEquivalent instead of routing them to the SwiftUI
   TextField inside. Removed that call, now only posting the
   .ghosttySearchFocus notification to let SwiftUI handle internal
   focus via @focusstate.

* Use AppKit NSTextField focus instead of SwiftUI @focusstate for search restore

The notification-only approach fails because SwiftUI @focusstate can't
propagate to AppKit when the first responder is the NSWindow itself
(no view in the responder chain to anchor the change). And making the
NSHostingView first responder eats keys as performKeyEquivalent.

Now walks the hosting view's subview tree to find the actual editable
NSTextField backing the SwiftUI TextField, and calls
window.makeFirstResponder directly on it. Falls back to notification
if the text field isn't found.

* Two-phase focus restore: AppKit + SwiftUI sync, click-to-terminal fix

restoreSearchFocus now does both:
1. AppKit: makeFirstResponder(nsTextField) so typing works immediately
2. SwiftUI: post .ghosttySearchFocus so @focusstate syncs and
   .onExitCommand (Escape) and .onKeyPress (Return) still work

Also: clicking the terminal while find bar is open now sets
searchFocusTarget to .terminal, so window reactivation correctly
restores terminal focus instead of jumping back to the search field.

* Replace SwiftUI TextField with NSViewRepresentable for find bar

The core issue: SwiftUI @focusstate does not sync with AppKit's
first responder after window resign/become-key cycles. This caused
the find bar to lose all keyboard input after switching windows.

Previous attempts to bridge SwiftUI and AppKit focus (notifications,
makeFirstResponder on the backing NSTextField, belt-and-suspenders
approaches) all failed because SwiftUI event handlers (.onExitCommand
for Escape, .onKeyPress for Return) require @focusstate to be set.

Fix: replace the SwiftUI TextField with an NSViewRepresentable-wrapped
NSTextField (SearchTextFieldRepresentable), following the proven
OmnibarNativeTextField pattern already in BrowserPanelView.swift.

- Escape and Return handled via control(_:textView:doCommandBy:)
  at the AppKit delegate level, no @focusstate needed
- Focus restored via .ghosttySearchFocus notification observed
  directly by the Coordinator, calling makeFirstResponder immediately
- hasMarkedText() guard preserves CJK IME composition (issue manaflow-ai#118)
- isProgrammaticMutation guard prevents text binding cursor reset
- Removes findTextField(in:) subview walk hack

* Explicitly unfocus terminal surface when find bar takes focus

The Ghostty cursor kept blinking even when the search field was focused
because ghostty_surface_set_focus(false) was only called via
surfaceView.resignFirstResponder. After window switching, the surface
view may not have been the first responder, so resign was never called.

Fix: call surface.setFocus(false) in both the .ghosttySearchFocus
notification observer and directly in restoreSearchFocus. This ensures
the cursor stops blinking regardless of previous first-responder state.

* Address review findings: field-editor guard, NSLog→dlog, stale focus

1. isSearchOverlayOrDescendant now accepts NSResponder and follows
   the field-editor delegate chain back to the owning NSTextField.
   Previously, when the search field was being edited, the shared
   NSTextView field editor was the first responder (outside the
   overlay hierarchy), so the guard missed it and other surfaces
   could steal focus.

2. Converted all NSLog calls in TabManager (startSearch, hideFind,
   searchSelection), cmuxApp (Find menu), and GhosttyTerminalView
   (searchState didSet) to dlog() wrapped in #if DEBUG. Avoids
   leaking search needle text to system logs in release builds.

3. Added isFocused re-check inside the deferred focus block in
   SearchTextFieldRepresentable to prevent stale focus requests
   from stealing focus back after intent has changed.

* Guard against re-focusing already-focused search field

Every keystroke updated searchState.needle (@published), which triggered
a SwiftUI re-render → ensureFocus → restoreSearchFocus → posted
.ghosttySearchFocus notification → Coordinator called makeFirstResponder
unconditionally. makeFirstResponder on an already-editing NSTextField
ends the editing session and restarts with all text selected, so the
next typed character replaced the previous one ("hi" → "i").

Fix: check if the field is already first responder before calling
makeFirstResponder in the notification handler.

* Address review findings: stale focus target, IME guard, tab/pane gating

- Add onFieldDidFocus callback so clicking back into the search field
  after Escape updates searchFocusTarget = .searchField, fixing stale
  focus restoration after window switches.
- Guard updateNSView text sync with !editor.hasMarkedText() to prevent
  stomping active CJK IME composition.
- Move ensureFocus search state check after tab/pane selection guards
  so search focus isn't restored on non-active tabs/panes.
- Clear surfaceView.onFocus when setFocusHandler(nil) is called.

This branch was successfully deployed

1 active deployment
Preview — f5754e82 Deployed Mar 4, 2026 by vercel[bot]
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