During IME composition (e.g. Japanese input), Ctrl+H should delete
composing characters via the IME, not bypass it and send a backspace
directly to the terminal. Add a hasMarkedText() check so the fast path
is only taken when no IME composition is active, letting
interpretKeyEvents() handle the key instead.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Fix orphaned child processes when closing workspace tabs
When closing a workspace tab via the sidebar X button, child processes
(login → zsh → claude) survived as orphans because TabManager.closeWorkspace()
only removed the workspace from the tabs array without explicitly freeing
Ghostty surfaces. It relied on ARC to cascade deallocation, but SwiftUI views
and Combine publishers held references, delaying or preventing
ghostty_surface_free() (which sends SIGHUP) from ever running.
This adds explicit teardown on the workspace close path:
- TerminalSurface.teardownSurface(): idempotent method to free the Ghostty
runtime surface eagerly, matching the existing deinit logic
- TerminalPanel.close() now calls teardownSurface() to ensure SIGHUP is sent
- Workspace.teardownAllPanels() iterates all panels and closes them
- TabManager.closeWorkspace() calls teardownAllPanels() before removing
the workspace from the tabs array
* Harden workspace teardown and ownership checks
* Address follow-up teardown review feedback
---------
Co-authored-by: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
JavaScript-based find using TreeWalker + <mark> highlights with
match counter, next/previous navigation, and drag-to-corner overlay
matching the existing terminal find bar.
- BrowserFindJavaScript: JS generation for search/next/prev/clear
- BrowserSearchOverlay: SwiftUI overlay with IME-safe onSubmit
- BrowserSearchState: Observable state (needle/selected/total)
- TabManager routing: Cmd+F/G dispatches to browser when focused
- Visibility filter: skips script/style/hidden/aria-hidden elements
- Stale DOM guard: isConnected check in next/previous scripts
- Navigation cleanup: clears find on didFinish and didFailNavigation
Co-authored-by: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Profiling shows the main thread spends ~24% of time in
AccessibilityViewGraph.needsUpdate walking invisible SwiftUI views
during every layout pass, blocking key event dequeue.
- Add .accessibilityHidden on inactive workspaces in the ZStack
(opacity-0 but still walked by the accessibility subsystem)
- Add .accessibilityHidden on NotificationsPage and the tabs
container when their respective selection is not active
- Mark GhosttyTerminalView's HostContainerView (empty portal
placeholder) as non-accessible since the terminal surface
lives in the AppKit portal layer above SwiftUI
* 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 #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.
Ghostty's keybinding system intercepts Cmd+V and routes it through
read_clipboard_cb, which only reads text. The paste(_:) NSResponder
method with image handling was never reached.
Move clipboard image save logic into GhosttyPasteboardHelper and call
it from read_clipboard_cb when the clipboard has no text but has image
data. This makes image paste work regardless of whether paste is
triggered via Ghostty keybinding (Cmd+V) or menu action (Edit > Paste).
Fixes regression from https://github.com/manaflow-ai/cmux/pull/562
* Vi mode P0 improvements: half-page scroll, visible cursor, gg fix
Three changes to keyboard copy mode (vi mode):
1. Ctrl+U/D now scroll half-page (was full-page). Ctrl+B/F remain
full-page. Uses Ghostty's scroll_page_fractional binding.
2. Entering copy mode now creates a 1-cell selection at the terminal
cursor via select_cursor_cell, giving the user a visible cursor
indicator. Visual mode (v) is tracked separately from Ghostty's
has_selection so the cursor selection doesn't make every motion
behave as visual. Exiting visual mode (v again) collapses back
to the cursor cell.
3. Single 'g' is now a prefix key requiring 'gg' to scroll to top,
matching standard vim behavior. Uses the same pendingG state
machine pattern as pendingYankLine for 'yy'.
Part of https://github.com/manaflow-ai/cmux/issues/846
* Fix viewport row refresh with persistent cursor selection
The 1-cell cursor selection made refreshKeyboardCopyModeViewportRowFromVisibleAnchor
always bail (has_selection was always true). Guard on keyboardCopyModeVisualActive
instead so viewport row updates work after scrolling in non-visual mode. Also
re-creates the cursor cell after refresh to preserve visibility.
Additionally: use performBindingAction(_:repeatCount:) for scrollHalfPage,
add state-reset assertion to testGGWithSelectionAdjustsToHome.
Addresses review feedback from CodeRabbit, Cubic, Codex, and Greptile.
* Add keyboard copy mode for terminal scrollback
* Show vim copy mode indicator in terminal
* Fix vi copy-mode symbol keys and pending yank handling
* Refine copy-mode badge wording and font
* Rename keyboard copy-mode badge to VI MODE
* Address PR feedback for copy-mode routing and keyup handling
* Refresh copy-mode viewport row after scrolling
* Add debug logging for cmd+click link handling
Logs every decision point in the link opening pipeline:
- mouseDown/mouseUp: modifier keys, click count, position
- resolveTerminalOpenURLTarget: input URL, classification (external/embedded/fallback)
- OPEN_URL action handler: routing decision (cmuxBrowser disabled, external, whitelist miss, embedded browser pane)
All logs are #if DEBUG only via dlog().
* Update tagged build link format for Claude Code cmd+clickability
Claude Code gets a markdown link with the real derived-data path
(file:///tmp/cmux-<tag>/Build/Products/Debug/cmux%20DEV%20<tag>.app)
which is cmd+clickable in cmux. Codex keeps the original format.
* Add equal sign separators to Claude Code build link format
* Fix cmd+click URL buffer overread in OPEN_URL handler
cmux was using String(cString:) to decode the URL from
GHOSTTY_ACTION_OPEN_URL, which reads until a null terminator.
The C struct provides both a pointer and a length field (url + len),
and the pointer is not guaranteed to be null-terminated. This caused
cmux to read past the URL bytes into adjacent memory, producing
corrupted URLs like "https://example.comcom" and multi-URL
concatenations.
Fix: use Data(bytes:count:) with the length field, matching how
vanilla Ghostty decodes the same struct in Ghostty.Action.swift.
* Address review feedback: extract debugModifierString helper
* Support pasting clipboard images as file paths in terminal
When the macOS clipboard contains only image data (e.g. from
Cmd+Ctrl+Shift+4 screenshot) and no text, Cmd+V now saves the image
as a temporary PNG file and pastes the file path into the terminal.
This allows CLI tools like Claude Code to receive pasted images.
The pasteboard heuristic only intercepts when there is image data
(TIFF/PNG) and no text/HTML/RTF, so normal text paste is unaffected.
Images over 10 MB are skipped and fall through to default behavior.
Closes#457
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix clipboard image paste: collision-free filenames and pasteAsPlainText validation
- Add UUID suffix to temp filenames to prevent overwrites when pasting
images multiple times in the same second
- Only enable Paste menu (not Paste as Plain Text) for image-only clipboard,
since pasteAsPlainText has no image-path handling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Shell-escape pasted image path before sending to terminal
Use escapeDropForShell on the clipboard image temp path, consistent
with how drag/drop paths are escaped, to avoid issues with
shell-special characters in the path.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add docstrings to paste and validation functions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Jose Masri <ae_jmsalame@contractor.indeed.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
* Honor Ghostty background-opacity across all cmux chrome
Parse background-opacity from Ghostty config and propagate it through
the entire chrome pipeline: bonsplit tab bar (via RRGGBBAA hex),
browser panel/omnibar, titlebar, empty panel, and window background.
Decouple glass effect from sidebar blend mode — bgGlassEnabled now
defaults to false so opacity works independently. Add
GhosttyBackgroundTheme helper for consistent color+opacity resolution
across all UI surfaces.
Fixes https://github.com/manaflow-ai/cmux/issues/263
* Titlebar and chrome opacity matches terminal background-opacity
Use CALayer-level opacity for the titlebar background instead of SwiftUI
Color alpha, matching the terminal's Metal compositing path. Account for
the double alpha stacking in the terminal area (Bonsplit container bg +
Ghostty renderer) so the titlebar visually matches.
Also fix opacity-only config changes not triggering titlebar refresh on
Cmd+Shift+, reload.
- Notification/focus flash uses workspace customColor (fallback: accent)
- Selection bar/indicator uses workspace customColor when set
- Flash color propagated through Panel.triggerFlash(color:) API
- Browser panel flash overlay uses workspace color
- Regression tests for flash color resolution
Fixes https://github.com/manaflow-ai/cmux/issues/557
Add nil guard in forceRefresh() to prevent dereferencing freed surface
pointer. Split else-if chains in Workspace.swift so
requestBackgroundSurfaceStartIfNeeded() runs if surface is freed during
the refresh call. Add regression test exercising the crash path.
Adds a "Send anonymous telemetry" toggle in Settings that lets users
disable Sentry crash reporting and PostHog analytics. The setting is
frozen at launch so toggling mid-session shows a restart hint. The hint
correctly clears if the user toggles back to the launch-time value.
Prevent crash (KERN_INVALID_ADDRESS at ghostty_surface_refresh) during
geometry reconcile after wake-from-sleep by adding proper lifetime
guards for freed surfaces:
- Re-read self.surface before each ghostty C call in forceRefresh()
instead of using a stale captured local that can outlive the surface
- Nil out self.surface in deinit before scheduling the async free Task,
so in-flight closures see nil and bail out
- Re-check surface validity in reconcileTerminalGeometryPass() after
reconcileGeometryNow() which can trigger AppKit layout that frees
surfaces
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Cmd-based keyboard shortcuts (Cmd+T, Cmd+Shift+L, etc.) were blocked
while an IME had active composition (marked text), affecting Chinese
Pinyin, Japanese, Korean, and all other input methods. Since Cmd is a
system modifier never consumed by IME input sequences, exempt
Cmd-modified events from the three IME bypass points in the key event
chain.
Fixes#440
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add otherMouseDown/otherMouseUp handlers to GhosttyNSView that forward
middle-click (button 2) to Ghostty as GHOSTTY_MOUSE_MIDDLE. Ghostty
handles this as paste_from_selection, reading from the selection
clipboard. Non-middle buttons are passed to super for default handling.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The ctrl fast path unconditionally returned after calling ghostty_surface_key,
even when it returned false (e.g. ignore keybindings), preventing IMEs from
receiving Ctrl-modified key events. Now falls through to interpretKeyEvents
when the key is not handled.
Also adds keyboard layout change detection around interpretKeyEvents (matching
Ghostty upstream) so that IME-triggered layout switches cause an early return
instead of sending the key to Ghostty.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>