diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 4a22e3a171c3..4cf8735716c2 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -509,4 +509,41 @@ _cmux_fix_path() { _cmux_fix_path unset -f _cmux_fix_path +# tmux auto-CC: when the user runs `tmux attach` or `tmux new-session` +# in an interactive cmux terminal, automatically add -CC so native pane +# rendering activates. Use `command tmux ...` to bypass, or set +# CMUX_TMUX_AUTO_CC=0 to disable globally. +tmux() { + # Opt-out + [[ "${CMUX_TMUX_AUTO_CC:-1}" == "0" ]] && { command tmux "$@"; return $?; } + # Inside cmux, $TMUX may be inherited from the launcher env but + # this terminal is NOT really inside tmux — ignore it. + # Outside cmux (no CMUX_SOCKET_PATH), respect $TMUX as usual. + [[ -z "$CMUX_SOCKET_PATH" && -n "$TMUX" ]] && { command tmux "$@"; return $?; } + [[ "$-" == *i* ]] || { command tmux "$@"; return $?; } + [[ -t 1 ]] || { command tmux "$@"; return $?; } + # Already has -CC or -C + local arg; for arg in "$@"; do + [[ "$arg" == "-CC" || "$arg" == "-C" ]] && { command tmux "$@"; return $?; } + [[ "$arg" == "--" ]] && break + done + # Determine the tmux subcommand (skip global flags that take a value) + local subcmd="" skip_next=0 + for arg in "$@"; do + if (( skip_next )); then skip_next=0; continue; fi + case "$arg" in + -L|-S|-f|-c) skip_next=1; continue ;; # these flags consume the next arg + -*) continue ;; + *) subcmd="$arg"; break ;; + esac + done + # Only auto-add -CC for commands that start a tmux client + case "$subcmd" in + attach|attach-session|a|new|new-session|"") + command tmux -CC "$@" ;; + *) + command tmux "$@" ;; + esac +} + _cmux_install_prompt_command diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index e9bbf235f6dc..f29b52786746 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -567,8 +567,46 @@ _cmux_zshexit() { _cmux_stop_pr_poll_loop } +# tmux auto-CC: installed on first prompt (after .zshrc) so user plugins +# cannot shadow it. Use `command tmux ...` to bypass, or set +# CMUX_TMUX_AUTO_CC=0 to disable globally. +_cmux_install_tmux_wrapper() { + # Define/redefine the tmux function, overriding any plugin definition + tmux() { + [[ "${CMUX_TMUX_AUTO_CC:-1}" == "0" ]] && { command tmux "$@"; return $?; } + # Inside cmux, $TMUX may be inherited from the launcher env but + # this terminal is NOT really inside tmux — ignore it. + # Outside cmux (no CMUX_SOCKET_PATH), respect $TMUX as usual. + [[ -z "$CMUX_SOCKET_PATH" && -n "$TMUX" ]] && { command tmux "$@"; return $?; } + [[ -o interactive ]] || { command tmux "$@"; return $?; } + [[ -t 1 ]] || { command tmux "$@"; return $?; } + local arg; for arg in "$@"; do + [[ "$arg" == "-CC" || "$arg" == "-C" ]] && { command tmux "$@"; return $?; } + [[ "$arg" == "--" ]] && break + done + local subcmd="" skip_next=0 + for arg in "$@"; do + if (( skip_next )); then skip_next=0; continue; fi + case "$arg" in + -L|-S|-f|-c) skip_next=1; continue ;; + -*) continue ;; + *) subcmd="$arg"; break ;; + esac + done + case "$subcmd" in + attach|attach-session|a|new|new-session|"") + command tmux -CC "$@" ;; + *) + command tmux "$@" ;; + esac + } + # Only need to install once + add-zsh-hook -d precmd _cmux_install_tmux_wrapper +} + autoload -Uz add-zsh-hook add-zsh-hook preexec _cmux_preexec add-zsh-hook precmd _cmux_precmd add-zsh-hook precmd _cmux_fix_path +add-zsh-hook precmd _cmux_install_tmux_wrapper add-zsh-hook zshexit _cmux_zshexit diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 013e387f1af6..5371ab6a4732 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -361,10 +361,11 @@ func cmuxPasteboardImagePathForTesting(_ pasteboard: NSPasteboard) -> String? { enum TerminalOpenURLTarget: Equatable { case embeddedBrowser(URL) case external(URL) + case internalFile(URL) var url: URL { switch self { - case let .embeddedBrowser(url), let .external(url): + case let .embeddedBrowser(url), let .external(url), let .internalFile(url): return url } } @@ -457,13 +458,26 @@ func resolveTerminalOpenURLTarget(_ rawValue: String) -> TerminalOpenURLTarget? if NSString(string: trimmed).isAbsolutePath { #if DEBUG - dlog("link.resolve result=external(absolutePath) url=\(trimmed)") + dlog("link.resolve result=internalFile(absolutePath) path=\(trimmed)") #endif - return .external(URL(fileURLWithPath: trimmed)) + return .internalFile(URL(fileURLWithPath: trimmed)) } if let parsed = URL(string: trimmed), let scheme = parsed.scheme?.lowercased() { + if scheme == "file" { + let host = parsed.host?.lowercased() + if host == nil || host == "" || host == "localhost" { + #if DEBUG + dlog("link.resolve result=internalFile(fileURL) path=\(parsed.path)") + #endif + return .internalFile(parsed) + } + #if DEBUG + dlog("link.resolve result=external(remoteFileURL) url=\(parsed)") + #endif + return .external(parsed) + } if scheme == "http" || scheme == "https" { guard BrowserInsecureHTTPSettings.normalizeHost(parsed.host ?? "") != nil else { #if DEBUG @@ -2001,6 +2015,17 @@ class GhosttyApp { let callbackTabId = callbackContext?.tabId let callbackSurfaceId = callbackContext?.surfaceId + #if DEBUG + // Trace tmux-related actions to debug control mode activation + if action.tag == GHOSTTY_ACTION_TMUX_STATE || + action.tag == GHOSTTY_ACTION_TMUX_WINDOWS_CHANGED || + action.tag == GHOSTTY_ACTION_TMUX_PANE_UNREGISTERED { + let ctxOk = callbackContext != nil + let svOk = callbackContext?.surfaceView != nil + dlog("tmux.action tag=\(action.tag.rawValue) ctxOk=\(ctxOk) svOk=\(svOk) tabId=\(callbackTabId?.uuidString.prefix(5) ?? "nil")") + } + #endif + if action.tag == GHOSTTY_ACTION_SHOW_CHILD_EXITED { // The child (shell) exited. Ghostty will fall back to printing // "Process exited. Press any key..." into the terminal unless the host @@ -2291,6 +2316,17 @@ class GhosttyApp { surfaceView.updateKeyTable(action.action.key_table) return true } + case GHOSTTY_ACTION_MOUSE_OVER_LINK: + #if DEBUG + let mol = action.action.mouse_over_link + if let ptr = mol.url, mol.len > 0 { + let linkStr = String(data: Data(bytes: ptr, count: Int(mol.len)), encoding: .utf8) ?? "" + dlog("link.mouseOverLink url=\(linkStr)") + } else { + dlog("link.mouseOverLink url=nil (cleared)") + } + #endif + return true case GHOSTTY_ACTION_OPEN_URL: let openUrl = action.action.open_url guard let cstr = openUrl.url else { return false } @@ -2307,7 +2343,9 @@ class GhosttyApp { #endif return false } - if !BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser() { + if case .internalFile = target { + // Local file routing should not depend on the browser-link setting. + } else if !BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser() { #if DEBUG dlog("link.openURL cmuxBrowser=disabled, opening externally url=\(target.url)") #endif @@ -2316,6 +2354,50 @@ class GhosttyApp { } } switch target { + case let .internalFile(fileURL): + let path = fileURL.path + #if DEBUG + dlog("link.openURL target=internalFile, opening in markdown panel path=\(path)") + #endif + // Validate the path is a readable file (not a directory or missing). + // Fall back to system handler if validation fails. + var isDir: ObjCBool = false + let fileExists = FileManager.default.fileExists(atPath: path, isDirectory: &isDir) + guard fileExists, !isDir.boolValue else { + #if DEBUG + dlog("link.openURL internalFile not a readable file, falling back to external path=\(path)") + #endif + return performOnMain { + NSWorkspace.shared.open(fileURL) + } + } + let sourceWorkspaceId = callbackTabId ?? surfaceView.tabId + let sourcePanelId = callbackSurfaceId ?? surfaceView.terminalSurface?.id + guard let sourceWorkspaceId, let sourcePanelId else { + return performOnMain { + NSWorkspace.shared.open(fileURL) + } + } + return performOnMain { + guard let app = AppDelegate.shared, + let resolved = app.workspaceContainingPanel( + panelId: sourcePanelId, + preferredWorkspaceId: sourceWorkspaceId + ) else { + NSWorkspace.shared.open(fileURL) + return true + } + if resolved.workspace.newMarkdownSplit( + from: sourcePanelId, + orientation: .horizontal, + filePath: path + ) != nil { + return true + } + // Split creation failed — fall back to system handler + NSWorkspace.shared.open(fileURL) + return true + } case let .external(url): #if DEBUG dlog("link.openURL target=external, opening externally url=\(url)") @@ -2401,6 +2483,33 @@ class GhosttyApp { } } } + case GHOSTTY_ACTION_TMUX_STATE: + let entered = action.action.tmux_state == GHOSTTY_TMUX_STATE_ENTER + #if DEBUG + dlog("tmux.state entered=\(entered)") + #endif + return performOnMain { + surfaceView.tmuxControlMode = entered + if entered { + surfaceView.tmuxLoadPrefixConfig() + } else { + surfaceView.tmuxResetPrefixState() + surfaceView.tmuxCleanupAllPanes() + } + return true + } + case GHOSTTY_ACTION_TMUX_WINDOWS_CHANGED: + return performOnMain { + surfaceView.handleTmuxWindowsChanged() + return true + } + case GHOSTTY_ACTION_TMUX_PANE_UNREGISTERED: + let paneId = action.action.tmux_pane_unregistered.pane_id + let regId = action.action.tmux_pane_unregistered.reg_id + return performOnMain { + surfaceView.handleTmuxPaneUnregistered(paneId: paneId, regId: regId) + return true + } default: return false } @@ -3537,6 +3646,56 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { var desiredFocus: Bool = false var suppressingReparentFocus: Bool = false var tabId: UUID? + /// True when this surface is in tmux control mode. + var tmuxControlMode: Bool = false + + /// Tmux pane surface management (Phase 2). + struct TmuxPaneState { + let surface: ghostty_surface_t + let view: NSView + let regId: UInt32 + let writeContext: UnsafeMutablePointer + } + + /// Context passed through the C io_write_cb for a tmux pane surface. + class TmuxPaneWriteContext { + let hostSurface: ghostty_surface_t + let paneId: UInt + + init(hostSurface: ghostty_surface_t, paneId: UInt) { + self.hostSurface = hostSurface + self.paneId = paneId + } + } + + var tmuxPaneSurfaces: [UInt: TmuxPaneState] = [:] + var tmuxRegIdCounter: UInt32 = 0 + var tmuxPaneContainer: NSView? + var tmuxStatusBar: NSTextField? + var tmuxStatusBarTimer: Timer? + /// Pane IDs awaiting unregister ack before surface can be freed. + var tmuxPendingUnregister: [UInt: UInt32] = [:] + /// Last tmux client size we requested via refresh-client -C. + var tmuxRequestedClientSize: (cols: Int, rows: Int)? + + // MARK: - Tmux Prefix Layer + + /// Configuration loaded from tmux: prefix keys and key bindings. + struct TmuxPrefixConfig { + /// Keys that activate prefix mode, e.g. ["C-b", "C-Space"] + var prefixKeys: Set = [] + /// Bindings: tmux key name → tmux command, e.g. "d" → "detach-client" + var bindings: [String: String] = [:] + var loaded: Bool = false + } + + enum TmuxPrefixState { + case idle + case pending + } + + private var tmuxPrefixConfig = TmuxPrefixConfig() + private var tmuxPrefixState: TmuxPrefixState = .idle var onFocus: (() -> Void)? var onTriggerFlash: (() -> Void)? var backgroundColor: NSColor? @@ -4066,6 +4225,29 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { terminalSurface?.surface } + /// The pane ID that was last clicked or focused. Updated on mouseDown. + private var tmuxActivePaneId: UInt? + + /// Returns the active tmux pane state, or nil if not in tmux mode. + /// Returns the last-clicked pane, falling back to the first pane. + private var activeTmuxPane: TmuxPaneState? { + guard tmuxControlMode else { return nil } + if let id = tmuxActivePaneId, let state = tmuxPaneSurfaces[id] { + return state + } + // Fallback: first pane (single-pane case) + return tmuxPaneSurfaces.values.first + } + + /// Returns the surface that should receive keyboard/IME input. + /// In tmux control mode, returns the active pane surface; otherwise the host surface. + private var inputSurface: ghostty_surface_t? { + if let pane = activeTmuxPane { + return pane.surface + } + return surface + } + private func applySurfaceColorScheme(force: Bool = false) { guard let surface else { return } let bestMatch = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) @@ -4104,9 +4286,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } func performBindingAction(_ action: String) -> Bool { - guard let surface = surface else { return false } + // In tmux mode, route binding actions (paste, copy, etc.) to the + // pane surface so they operate on the pane's terminal content. + guard let target = inputSurface ?? surface else { return false } return action.withCString { cString in - ghostty_surface_binding_action(surface, cString, UInt(strlen(cString))) + ghostty_surface_binding_action(target, cString, UInt(strlen(cString))) } } @@ -4325,6 +4509,38 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // MARK: - Clipboard paste @IBAction func paste(_ sender: Any?) { + // In tmux mode, Ghostty's async clipboard flow would send the result + // back to the host surface (shared userdata). Instead, read the + // clipboard directly and route through the pane's send-keys path. + if let paneState = activeTmuxPane { + guard let str = NSPasteboard.general.string(forType: .string), + !str.isEmpty else { return } + let hostSurface = paneState.writeContext.pointee.hostSurface + let paneId = paneState.writeContext.pointee.paneId + // Split by newlines to avoid raw newlines breaking the tmux + // command stream. Each line is sent as literal text, with + // explicit Enter key events between lines. + let lines = str.components(separatedBy: .newlines) + for (i, line) in lines.enumerated() { + if !line.isEmpty, let data = line.data(using: .utf8) { + data.withUnsafeBytes { rawBuf in + let ptr = rawBuf.baseAddress!.assumingMemoryBound(to: UInt8.self) + ghostty_surface_tmux_send_keys(hostSurface, paneId, ptr, data.count, 0) + } + } + // Send Enter between lines (not after the last line) + if i < lines.count - 1 { + "Enter".withCString { cstr in + ghostty_surface_tmux_send_keys( + hostSurface, paneId, + UnsafePointer(OpaquePointer(cstr)), + strlen(cstr), 1 // key_type=1 (key name) + ) + } + } + } + return + } _ = performBindingAction("paste_from_clipboard") } @@ -4528,6 +4744,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let result = super.resignFirstResponder() if result { desiredFocus = false + // Cancel tmux prefix mode on focus loss + if tmuxPrefixState == .pending { tmuxPrefixState = .idle } } if result, let surface = surface { let now = CACurrentMediaTime() @@ -4541,6 +4759,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // For NSTextInputClient - accumulates text during key events private var keyTextAccumulator: [String]? = nil private var markedText = NSMutableAttributedString() + /// Cursor position within the IME preedit text (from setMarkedText selectedRange). + private var markedTextCursorOffset: Int = 0 private var lastPerformKeyEvent: TimeInterval? private struct SelectionSnapshot { let range: NSRange @@ -4755,13 +4975,62 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } let ensureSurfaceStart = ProcessInfo.processInfo.systemUptime #endif - guard let surface = ensureSurfaceReadyForInput() else { + guard var surface = ensureSurfaceReadyForInput() else { #if DEBUG ensureSurfaceMs = (ProcessInfo.processInfo.systemUptime - ensureSurfaceStart) * 1000.0 #endif super.keyDown(with: event) return } + // In tmux control mode, redirect keyboard input to the pane surface. + // This preserves the full keyDown flow (IME, interpretKeyEvents, etc.) + // while routing the encoded key bytes to the pane's Manual I/O backend. + // If tmux mode is active but pane surfaces aren't ready yet, drop the + // key event to avoid sending it to the host surface (which would eat it). + if tmuxControlMode && tmuxPaneSurfaces.isEmpty { + return + } + + // Tmux prefix key state machine: intercept prefix keys before + // they reach Ghostty's key handler or the pane surface. + // Only active when not composing IME text (markedText). + if tmuxControlMode && tmuxPrefixConfig.loaded && !hasMarkedText() { + if let key = tmuxKeyName(for: event) { + switch tmuxPrefixState { + case .idle: + if tmuxPrefixConfig.prefixKeys.contains(key) { + tmuxPrefixState = .pending + #if DEBUG + dlog("tmux.prefix PENDING key=\(key)") + #endif + return + } + case .pending: + tmuxPrefixState = .idle + if let command = tmuxPrefixConfig.bindings[key] { + #if DEBUG + dlog("tmux.prefix EXEC key=\(key) cmd=\(command)") + #endif + tmuxSendCommand(command) + } else { + #if DEBUG + dlog("tmux.prefix UNKNOWN key=\(key), beep") + #endif + NSSound.beep() + } + return + } + } else if tmuxPrefixState == .pending { + // Unmappable key while in prefix mode → cancel + tmuxPrefixState = .idle + NSSound.beep() + return + } + } + + if let paneState = activeTmuxPane { + surface = paneState.surface + } #if DEBUG ensureSurfaceMs = (ProcessInfo.processInfo.systemUptime - ensureSurfaceStart) * 1000.0 #endif @@ -4931,12 +5200,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // Capture the keyboard layout ID before interpretation so we can // detect if an IME changed it (e.g. toggling input methods). - // We only check when not already in a preedit state. - let keyboardIdBefore: String? = if (!markedTextBefore) { - KeyboardLayout.id - } else { - nil - } + // We capture in both composing and non-composing states so that + // switching input methods during composition can commit the preedit. + let keyboardIdBefore: String? = KeyboardLayout.id // Let the input system handle the event (for IME, dead keys, etc.) #if DEBUG @@ -4954,16 +5220,28 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #endif // If the keyboard layout changed, an input method grabbed the event. - // Sync preedit and return without sending the key to Ghostty. - if !markedTextBefore, let kbBefore = keyboardIdBefore, kbBefore != KeyboardLayout.id { + if let kbBefore = keyboardIdBefore, kbBefore != KeyboardLayout.id { + if markedTextBefore && markedText.length > 0 { + // Switching input methods mid-composition: commit the current + // preedit text as literal input so the user's typing isn't lost. + let commitText = markedText.string + markedText.mutableString.setString("") + syncPreedit(clearIfNeeded: true) + if !commitText.isEmpty { + keyTextAccumulator?.append(commitText) + } + // Fall through to send the accumulated text via ghostty_surface_key + } else { + // No composition was active — sync preedit and return. #if DEBUG - let syncPreeditStart = ProcessInfo.processInfo.systemUptime + let syncPreeditStart = ProcessInfo.processInfo.systemUptime #endif - syncPreedit(clearIfNeeded: markedTextBefore) + syncPreedit(clearIfNeeded: markedTextBefore) #if DEBUG - syncPreeditMs = (ProcessInfo.processInfo.systemUptime - syncPreeditStart) * 1000.0 + syncPreeditMs = (ProcessInfo.processInfo.systemUptime - syncPreeditStart) * 1000.0 #endif - return + return + } } // Sync the preedit state with Ghostty so it can render the IME @@ -5394,16 +5672,974 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ) } guard let surface = surface else { return } + + // In tmux mode, route mouse events to the pane surface so Ghostty's + // native link detection works on the pane's real Terminal content. + #if DEBUG + dlog("tmux.mouseDown check tmuxControlMode=\(tmuxControlMode) paneCount=\(tmuxPaneSurfaces.count)") + #endif + if let paneState = activeTmuxPane { + let paneSurface = paneState.surface + let point = paneState.view.convert(event.locationInWindow, from: nil) + let y = paneState.view.bounds.height - point.y + #if DEBUG + dlog("tmux.mouseDown routed to pane regId=\(paneState.regId) point=(\(String(format: "%.0f", point.x)),\(String(format: "%.0f", y))) cmd=\(event.modifierFlags.contains(.command))") + #endif + // Track and sync active pane with tmux so prefix commands target the right pane + let paneId = paneState.writeContext.pointee.paneId + tmuxActivePaneId = paneId + tmuxSendCommand("select-pane -t %\(paneId)") + // Cancel prefix mode on click + if tmuxPrefixState == .pending { tmuxPrefixState = .idle } + ghostty_surface_mouse_pos(paneSurface, point.x, y, modsFromEvent(event)) + _ = ghostty_surface_mouse_button(paneSurface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, modsFromEvent(event)) + return + } + let point = convert(event.locationInWindow, from: nil) ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, modsFromEvent(event)) } + /// Handle cmd+click in tmux control mode by querying pane text + /// and finding file paths at the clicked row. + private func handleTmuxCmdClick(surface: ghostty_surface_t, event: NSEvent) -> Bool { + let point = convert(event.locationInWindow, from: nil) + // Convert pixel position to terminal row (0-indexed, top=0) + guard cellSize.height > 0 else { return false } + let row = Int((bounds.height - point.y) / cellSize.height) + let col = Int(point.x / cellSize.width) + + #if DEBUG + dlog("tmux.cmdClick row=\(row) col=\(col)") + #endif + + // Query tmux pane text + var textResult = ghostty_text_s() + guard ghostty_surface_tmux_pane_text(surface, UInt.max, &textResult), + let ptr = textResult.text else { + #if DEBUG + dlog("tmux.cmdClick pane text query failed") + #endif + return false + } + defer { var mutable = textResult; ghostty_surface_free_text(surface, &mutable) } + + let fullText = String(cString: ptr) + let lines = fullText.components(separatedBy: "\n") + + guard row >= 0 && row < lines.count else { + #if DEBUG + dlog("tmux.cmdClick row \(row) out of range (lines=\(lines.count))") + #endif + return false + } + + let line = lines[row] + #if DEBUG + dlog("tmux.cmdClick line[\(row)]=\(line.prefix(120))") + #endif + + // Find file paths in the clicked line using a simple regex: + // absolute paths (/...) or ~/ paths + guard let regex = try? NSRegularExpression( + pattern: "(?:/[\\w.@~+-]+)+(?::\\d+)?|~/[\\w.@/+-]+(?::\\d+)?", + options: [] + ) else { return false } + + let nsLine = line as NSString + let matches = regex.matches(in: line, range: NSRange(location: 0, length: nsLine.length)) + + // Find the match that contains the click column + for match in matches { + let range = match.range + if col >= range.location && col < range.location + range.length { + let path = nsLine.substring(with: range) + #if DEBUG + dlog("tmux.cmdClick matched path=\(path) at col \(range.location)-\(range.location + range.length)") + #endif + return openTmuxClickedPath(path) + } + } + + // No match at click position — try the nearest match on the line + if let nearest = matches.first { + let path = nsLine.substring(with: nearest.range) + #if DEBUG + dlog("tmux.cmdClick nearest path=\(path) (click col \(col) not inside match)") + #endif + } + + return false + } + + /// Open a file path found via tmux cmd+click. + private func openTmuxClickedPath(_ rawPath: String) -> Bool { + // Strip optional :line_number suffix + let path: String + if let colonIdx = rawPath.lastIndex(of: ":"), + colonIdx > rawPath.startIndex, + rawPath[rawPath.index(after: colonIdx)...].allSatisfy(\.isNumber) { + path = String(rawPath[.. 0 else { + #if DEBUG + dlog("tmux.windowsChanged: no panes") + #endif + return + } + + var panes = [ghostty_tmux_pane_info_s](repeating: ghostty_tmux_pane_info_s(), count: Int(count)) + let written = ghostty_surface_tmux_panes(hostSurface, &panes, count) + + #if DEBUG + dlog("tmux.windowsChanged paneCount=\(written)") + #endif + + // Determine which panes are new vs existing vs removed + let currentPaneIds = Set(panes[0..