From 2d454df50f9fad5754944c571f12512d4994a920 Mon Sep 17 00:00:00 2001 From: Vadim Kostin Date: Mon, 23 Feb 2026 10:40:29 +0800 Subject: [PATCH 01/30] feat(sidebar): show linked pull request metadata --- README.md | 4 +- .../cmux-bash-integration.bash | 45 ++++++ .../cmux-zsh-integration.zsh | 65 +++++++- Sources/ContentView.swift | 153 ++++++++++++++++++ Sources/TerminalController.swift | 139 ++++++++++++++++ Sources/Workspace.swift | 82 ++++++++++ Sources/cmuxApp.swift | 12 ++ tests/cmux.py | 31 ++++ tests/test_sidebar_pr.py | 94 +++++++++++ 9 files changed, 622 insertions(+), 3 deletions(-) create mode 100644 tests/test_sidebar_pr.py diff --git a/README.md b/README.md index e2c10ae0..9f651d95 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Split a browser alongside your terminal with a scriptable API ported from

Vertical + horizontal tabs

-Sidebar shows git branch, working directory, listening ports, and latest notification text. Split horizontally and vertically. +Sidebar shows git branch, linked PR status/number, working directory, listening ports, and latest notification text. Split horizontally and vertically. Vertical tabs and split panes @@ -96,7 +96,7 @@ I run a lot of Claude Code and Codex sessions in parallel. I was using Ghostty w I tried a few coding orchestrators but most of them were Electron/Tauri apps and the performance bugged me. I also just prefer the terminal since GUI orchestrators lock you into their workflow. So I built cmux as a native macOS app in Swift/AppKit. It uses libghostty for terminal rendering and reads your existing Ghostty config for themes, fonts, and colors. -The main additions are the sidebar and notification system. The sidebar has vertical tabs that show git branch, working directory, listening ports, and the latest notification text for each workspace. The notification system picks up terminal sequences (OSC 9/99/777) and has a CLI (`cmux notify`) you can wire into agent hooks for Claude Code, OpenCode, etc. When an agent is waiting, its pane gets a blue ring and the tab lights up in the sidebar, so I can tell which one needs me across splits and tabs. Cmd+Shift+U jumps to the most recent unread. +The main additions are the sidebar and notification system. The sidebar has vertical tabs that show git branch, linked PR status/number, working directory, listening ports, and the latest notification text for each workspace. The notification system picks up terminal sequences (OSC 9/99/777) and has a CLI (`cmux notify`) you can wire into agent hooks for Claude Code, OpenCode, etc. When an agent is waiting, its pane gets a blue ring and the tab lights up in the sidebar, so I can tell which one needs me across splits and tabs. Cmd+Shift+U jumps to the most recent unread. The in-app browser has a scriptable API ported from [agent-browser](https://github.com/vercel-labs/agent-browser). Agents can snapshot the accessibility tree, get element refs, click, fill forms, and evaluate JS. You can split a browser pane next to your terminal and have Claude Code interact with your dev server directly. diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 4f8c832f..ef3c3ce7 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -28,6 +28,9 @@ _CMUX_PWD_LAST_PWD="${_CMUX_PWD_LAST_PWD:-}" _CMUX_GIT_LAST_PWD="${_CMUX_GIT_LAST_PWD:-}" _CMUX_GIT_LAST_RUN="${_CMUX_GIT_LAST_RUN:-0}" _CMUX_GIT_JOB_PID="${_CMUX_GIT_JOB_PID:-}" +_CMUX_PR_LAST_PWD="${_CMUX_PR_LAST_PWD:-}" +_CMUX_PR_LAST_RUN="${_CMUX_PR_LAST_RUN:-0}" +_CMUX_PR_JOB_PID="${_CMUX_PR_JOB_PID:-}" _CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}" _CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}" @@ -115,6 +118,48 @@ _cmux_prompt_command() { _CMUX_GIT_JOB_PID=$! fi + # Pull request metadata (number/state/url): + # refresh on cwd change and periodically to avoid stale status. + if [[ -n "$_CMUX_PR_JOB_PID" ]] && kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then + if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]]; then + kill "$_CMUX_PR_JOB_PID" >/dev/null 2>&1 || true + _CMUX_PR_JOB_PID="" + fi + fi + + if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]] || (( now - _CMUX_PR_LAST_RUN >= 60 )); then + if [[ -z "$_CMUX_PR_JOB_PID" ]] || ! kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then + _CMUX_PR_LAST_PWD="$pwd" + _CMUX_PR_LAST_RUN=$now + { + local branch pr_tsv number state url status_opt="" + branch=$(git branch --show-current 2>/dev/null) + if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then + _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + else + pr_tsv="$(gh pr view --json number,state,url --jq '[.number, .state, .url] | @tsv' 2>/dev/null || true)" + if [[ -z "$pr_tsv" ]]; then + _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + else + IFS=$'\t' read -r number state url <<< "$pr_tsv" + if [[ -z "$number" || -z "$url" ]]; then + _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + else + case "$state" in + MERGED) status_opt="--state=merged" ;; + OPEN) status_opt="--state=open" ;; + CLOSED) status_opt="--state=closed" ;; + *) status_opt="" ;; + esac + _cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + fi + fi + fi + } >/dev/null 2>&1 & + _CMUX_PR_JOB_PID=$! + fi + fi + # Ports: lightweight kick to the app's batched scanner every ~10s. if (( now - _CMUX_PORTS_LAST_RUN >= 10 )); then _cmux_ports_kick diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 6c9575f0..0f5d483a 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -34,6 +34,10 @@ typeset -g _CMUX_GIT_HEAD_LAST_PWD="" typeset -g _CMUX_GIT_HEAD_PATH="" typeset -g _CMUX_GIT_HEAD_MTIME=0 typeset -g _CMUX_HAVE_ZSTAT=0 +typeset -g _CMUX_PR_LAST_PWD="" +typeset -g _CMUX_PR_LAST_RUN=0 +typeset -g _CMUX_PR_JOB_PID="" +typeset -g _CMUX_PR_FORCE=0 typeset -g _CMUX_PORTS_LAST_RUN=0 typeset -g _CMUX_CMD_START=0 @@ -143,7 +147,8 @@ _cmux_preexec() { local cmd="${1## }" case "$cmd" in git\ *|git|gh\ *|lazygit|lazygit\ *|tig|tig\ *|gitui|gitui\ *|stg\ *|jj\ *) - _CMUX_GIT_FORCE=1 ;; + _CMUX_GIT_FORCE=1 + _CMUX_PR_FORCE=1 ;; esac # Register TTY + kick batched port scan for foreground commands (servers). @@ -200,6 +205,7 @@ _cmux_precmd() { # Treat HEAD file change like a git command — force-replace any # running probe so the sidebar picks up the new branch immediately. _CMUX_GIT_FORCE=1 + _CMUX_PR_FORCE=1 should_git=1 fi fi @@ -249,6 +255,63 @@ _cmux_precmd() { fi fi + # Pull request metadata (number/state/url): + # - refresh on cwd change, explicit git/gh commands, and occasionally for status drift + # - keep this independent from the git probe cadence to avoid hitting GitHub too often + local should_pr=0 + if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]]; then + should_pr=1 + elif (( _CMUX_PR_FORCE )); then + should_pr=1 + elif (( now - _CMUX_PR_LAST_RUN >= 60 )); then + should_pr=1 + fi + + if (( should_pr )); then + local can_launch_pr=1 + if [[ -n "$_CMUX_PR_JOB_PID" ]] && kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then + if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]] || (( _CMUX_PR_FORCE )); then + kill "$_CMUX_PR_JOB_PID" >/dev/null 2>&1 || true + _CMUX_PR_JOB_PID="" + else + can_launch_pr=0 + fi + fi + + if (( can_launch_pr )); then + _CMUX_PR_FORCE=0 + _CMUX_PR_LAST_PWD="$pwd" + _CMUX_PR_LAST_RUN=$now + { + local branch pr_tsv number state url status_opt="" + branch=$(git branch --show-current 2>/dev/null) + if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then + _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + else + pr_tsv="$(gh pr view --json number,state,url --jq '[.number, .state, .url] | @tsv' 2>/dev/null || true)" + if [[ -z "$pr_tsv" ]]; then + _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + else + local IFS=$'\t' + read -r number state url <<< "$pr_tsv" + if [[ -z "$number" ]] || [[ -z "$url" ]]; then + _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + else + case "$state" in + MERGED) status_opt="--state=merged" ;; + OPEN) status_opt="--state=open" ;; + CLOSED) status_opt="--state=closed" ;; + *) status_opt="" ;; + esac + _cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + fi + fi + fi + } >/dev/null 2>&1 &! + _CMUX_PR_JOB_PID=$! + fi + fi + # Ports: lightweight kick to the app's batched scanner. # - Periodic scan to avoid stale values. # - Forced scan when a long-running command returns to the prompt (common when stopping a server). diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 82de33b1..b1236f89 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2481,6 +2481,7 @@ private struct TabItemView: View { @AppStorage("sidebarShowGitBranch") private var sidebarShowGitBranch = true @AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout @AppStorage("sidebarShowGitBranchIcon") private var sidebarShowGitBranchIcon = false + @AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true @AppStorage("sidebarShowPorts") private var sidebarShowPorts = true @AppStorage("sidebarShowLog") private var sidebarShowLog = true @AppStorage("sidebarShowProgress") private var sidebarShowProgress = true @@ -2767,6 +2768,33 @@ private struct TabItemView: View { } } + // Pull request row + if sidebarShowPullRequest, let pullRequest = primaryPullRequestDisplay { + Button(action: { + updateSelection() + NSWorkspace.shared.open(pullRequest.url) + }) { + HStack(spacing: 4) { + PullRequestStatusIcon( + status: pullRequest.status, + color: pullRequestForegroundColor + ) + Text("PR #\(pullRequest.number)") + .underline() + Text(pullRequestStatusLabel(pullRequest.status)) + if pullRequest.extraCount > 0 { + Text("+\(pullRequest.extraCount)") + .opacity(0.75) + } + Spacer(minLength: 0) + } + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(pullRequestForegroundColor) + } + .buttonStyle(.plain) + .help("Open Pull Request #\(pullRequest.number)") + } + // Ports row if sidebarShowPorts, !tab.listeningPorts.isEmpty { Text(tab.listeningPorts.map { ":\($0)" }.joined(separator: ", ")) @@ -3270,6 +3298,36 @@ private struct TabItemView: View { return entries.isEmpty ? nil : entries.joined(separator: " | ") } + private struct PullRequestDisplay { + let number: Int + let url: URL + let status: SidebarPullRequestStatus + let extraCount: Int + } + + private var primaryPullRequestDisplay: PullRequestDisplay? { + let pullRequests = tab.sidebarPullRequestsInDisplayOrder() + guard let first = pullRequests.first else { return nil } + return PullRequestDisplay( + number: first.number, + url: first.url, + status: first.status, + extraCount: max(0, pullRequests.count - 1) + ) + } + + private var pullRequestForegroundColor: Color { + isActive ? .white.opacity(0.75) : .secondary + } + + private func pullRequestStatusLabel(_ status: SidebarPullRequestStatus) -> String { + switch status { + case .open: return "open" + case .merged: return "merged" + case .closed: return "closed" + } + } + private func logLevelIcon(_ level: SidebarLogLevel) -> String { switch level { case .info: return "circle.fill" @@ -3311,6 +3369,101 @@ private struct TabItemView: View { return trimmed } + private struct PullRequestStatusIcon: View { + let status: SidebarPullRequestStatus + let color: Color + private static let frameSize: CGFloat = 14 + + var body: some View { + switch status { + case .open: + PullRequestOpenIcon(color: color) + case .merged: + PullRequestMergedIcon(color: color) + case .closed: + Image(systemName: "xmark.circle") + .font(.system(size: 9, weight: .regular)) + .foregroundColor(color) + .frame(width: Self.frameSize, height: Self.frameSize) + } + } + } + + private struct PullRequestOpenIcon: View { + let color: Color + private static let stroke = StrokeStyle(lineWidth: 1.35, lineCap: .round, lineJoin: .round) + private static let nodeDiameter: CGFloat = 3.4 + private static let frameSize: CGFloat = 14 + + var body: some View { + ZStack { + Path { path in + path.move(to: CGPoint(x: 3.0, y: 4.8)) + path.addLine(to: CGPoint(x: 3.0, y: 9.2)) + + path.move(to: CGPoint(x: 4.8, y: 3.0)) + path.addLine(to: CGPoint(x: 9.4, y: 3.0)) + path.addLine(to: CGPoint(x: 11.0, y: 4.6)) + path.addLine(to: CGPoint(x: 11.0, y: 9.2)) + } + .stroke(color, style: Self.stroke) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 3.0, y: 3.0) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 3.0, y: 11.0) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 11.0, y: 11.0) + } + .frame(width: Self.frameSize, height: Self.frameSize) + } + } + + private struct PullRequestMergedIcon: View { + let color: Color + private static let stroke = StrokeStyle(lineWidth: 1.35, lineCap: .round, lineJoin: .round) + private static let nodeDiameter: CGFloat = 3.4 + private static let frameSize: CGFloat = 14 + + var body: some View { + ZStack { + Path { path in + path.move(to: CGPoint(x: 4.6, y: 4.6)) + path.addLine(to: CGPoint(x: 7.1, y: 7.0)) + path.addLine(to: CGPoint(x: 9.2, y: 7.0)) + + path.move(to: CGPoint(x: 4.6, y: 9.4)) + path.addLine(to: CGPoint(x: 7.1, y: 7.0)) + } + .stroke(color, style: Self.stroke) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 3.0, y: 3.0) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 3.0, y: 11.0) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 11.0, y: 7.0) + } + .frame(width: Self.frameSize, height: Self.frameSize) + } + } + private func applyTabColor(_ hex: String?, targetIds: [UUID]) { for targetId in targetIds { tabManager.setTabColor(tabId: targetId, color: hex) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 3f61f26b..532f9dd3 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -189,6 +189,16 @@ class TerminalController { return current.branch != branch || current.isDirty != isDirty } + nonisolated static func shouldReplacePullRequest( + current: SidebarPullRequestState?, + number: Int, + url: URL, + status: SidebarPullRequestStatus + ) -> Bool { + guard let current else { return true } + return current.number != number || current.url != url || current.status != status + } + nonisolated static func shouldReplacePorts(current: [Int]?, next: [Int]) -> Bool { let currentSorted = Array(Set(current ?? [])).sorted() let nextSorted = Array(Set(next)).sorted() @@ -733,6 +743,12 @@ class TerminalController { case "clear_git_branch": return clearGitBranch(args) + case "report_pr": + return reportPullRequest(args) + + case "clear_pr": + return clearPullRequest(args) + case "report_ports": return reportPorts(args) @@ -7879,6 +7895,8 @@ class TerminalController { clear_progress [--tab=X] - Clear progress bar report_git_branch [--status=dirty] [--tab=X] [--panel=Y] - Report git branch clear_git_branch [--tab=X] [--panel=Y] - Clear git branch + report_pr [--state=open|merged|closed] [--tab=X] [--panel=Y] - Report pull request + clear_pr [--tab=X] [--panel=Y] - Clear pull request report_ports [port2...] [--tab=X] [--panel=Y] - Report listening ports report_tty [--tab=X] [--panel=Y] - Register TTY for batched port scanning ports_kick [--tab=X] [--panel=Y] - Request batched port scan for panel @@ -11092,6 +11110,119 @@ class TerminalController { return result } + private func reportPullRequest(_ args: String) -> String { + let parsed = parseOptions(args) + guard parsed.positional.count >= 2 else { + return "ERROR: Missing pull request number or URL — usage: report_pr [--state=open|merged|closed] [--tab=X] [--panel=Y]" + } + + let rawNumber = parsed.positional[0].trimmingCharacters(in: .whitespacesAndNewlines) + let numberToken = rawNumber.hasPrefix("#") ? String(rawNumber.dropFirst()) : rawNumber + guard let number = Int(numberToken), number > 0 else { + return "ERROR: Invalid pull request number '\(rawNumber)'" + } + + let rawURL = parsed.positional[1].trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: rawURL), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return "ERROR: Invalid pull request URL '\(rawURL)'" + } + + let statusRaw = (parsed.options["state"] ?? "open").lowercased() + guard let status = SidebarPullRequestStatus(rawValue: statusRaw) else { + return "ERROR: Invalid pull request state '\(statusRaw)' — use: open, merged, closed" + } + + var result = "OK" + DispatchQueue.main.sync { + guard let tab = resolveTabForReport(args) else { + result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" + return + } + let validSurfaceIds = Set(tab.panels.keys) + tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) + + let panelArg = parsed.options["panel"] ?? parsed.options["surface"] + let surfaceId: UUID + if let panelArg { + if panelArg.isEmpty { + result = "ERROR: Missing panel id — usage: report_pr [--state=open|merged|closed] [--tab=X] [--panel=Y]" + return + } + guard let parsedId = UUID(uuidString: panelArg) else { + result = "ERROR: Invalid panel id '\(panelArg)'" + return + } + surfaceId = parsedId + } else { + guard let focused = tab.focusedPanelId else { + result = "ERROR: Missing panel id (no focused surface)" + return + } + surfaceId = focused + } + + guard validSurfaceIds.contains(surfaceId) else { + result = "ERROR: Panel not found '\(surfaceId.uuidString)'" + return + } + + guard Self.shouldReplacePullRequest( + current: tab.panelPullRequests[surfaceId], + number: number, + url: url, + status: status + ) else { + return + } + + tab.updatePanelPullRequest(panelId: surfaceId, number: number, url: url, status: status) + } + return result + } + + private func clearPullRequest(_ args: String) -> String { + let parsed = parseOptions(args) + var result = "OK" + DispatchQueue.main.sync { + guard let tab = resolveTabForReport(args) else { + result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" + return + } + let validSurfaceIds = Set(tab.panels.keys) + tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) + + let panelArg = parsed.options["panel"] ?? parsed.options["surface"] + let surfaceId: UUID + if let panelArg { + if panelArg.isEmpty { + result = "ERROR: Missing panel id — usage: clear_pr [--tab=X] [--panel=Y]" + return + } + guard let parsedId = UUID(uuidString: panelArg) else { + result = "ERROR: Invalid panel id '\(panelArg)'" + return + } + surfaceId = parsedId + } else { + guard let focused = tab.focusedPanelId else { + result = "ERROR: Missing panel id (no focused surface)" + return + } + surfaceId = focused + } + + guard validSurfaceIds.contains(surfaceId) else { + result = "ERROR: Panel not found '\(surfaceId.uuidString)'" + return + } + + tab.clearPanelPullRequest(panelId: surfaceId) + } + return result + } + private func reportPorts(_ args: String) -> String { let parsed = parseOptions(args) guard !parsed.positional.isEmpty else { @@ -11383,6 +11514,12 @@ class TerminalController { lines.append("git_branch=none") } + if let pr = tab.pullRequest { + lines.append("pr=#\(pr.number) \(pr.status.rawValue) \(pr.url.absoluteString)") + } else { + lines.append("pr=none") + } + if tab.listeningPorts.isEmpty { lines.append("ports=none") } else { @@ -11426,6 +11563,8 @@ class TerminalController { tab.progress = nil tab.gitBranch = nil tab.panelGitBranches.removeAll() + tab.pullRequest = nil + tab.panelPullRequests.removeAll() tab.surfaceListeningPorts.removeAll() tab.listeningPorts.removeAll() } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index bb0345d4..e634b8b1 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -37,6 +37,18 @@ struct SidebarGitBranchState { let isDirty: Bool } +enum SidebarPullRequestStatus: String { + case open + case merged + case closed +} + +struct SidebarPullRequestState: Equatable { + let number: Int + let url: URL + let status: SidebarPullRequestStatus +} + enum SidebarBranchOrdering { struct BranchEntry: Equatable { let name: String @@ -117,6 +129,43 @@ enum SidebarBranchOrdering { } } + static func orderedUniquePullRequests( + orderedPanelIds: [UUID], + panelPullRequests: [UUID: SidebarPullRequestState], + fallbackPullRequest: SidebarPullRequestState? + ) -> [SidebarPullRequestState] { + func statusPriority(_ status: SidebarPullRequestStatus) -> Int { + switch status { + case .merged: return 3 + case .open: return 2 + case .closed: return 1 + } + } + + var orderedNumbers: [Int] = [] + var pullRequestsByNumber: [Int: SidebarPullRequestState] = [:] + + for panelId in orderedPanelIds { + guard let state = panelPullRequests[panelId] else { continue } + let number = state.number + if pullRequestsByNumber[number] == nil { + orderedNumbers.append(number) + pullRequestsByNumber[number] = state + continue + } + guard let existing = pullRequestsByNumber[number] else { continue } + if statusPriority(state.status) > statusPriority(existing.status) { + pullRequestsByNumber[number] = state + } + } + + if orderedNumbers.isEmpty, let fallbackPullRequest { + return [fallbackPullRequest] + } + + return orderedNumbers.compactMap { pullRequestsByNumber[$0] } + } + static func orderedUniqueBranchDirectoryEntries( orderedPanelIds: [UUID], panelBranches: [UUID: SidebarGitBranchState], @@ -300,6 +349,8 @@ final class Workspace: Identifiable, ObservableObject { @Published var progress: SidebarProgressState? @Published var gitBranch: SidebarGitBranchState? @Published var panelGitBranches: [UUID: SidebarGitBranchState] = [:] + @Published var pullRequest: SidebarPullRequestState? + @Published var panelPullRequests: [UUID: SidebarPullRequestState] = [:] @Published var surfaceListeningPorts: [UUID: [Int]] = [:] @Published var listeningPorts: [Int] = [] var surfaceTTYNames: [UUID: String] = [:] @@ -803,6 +854,24 @@ final class Workspace: Identifiable, ObservableObject { } } + func updatePanelPullRequest(panelId: UUID, number: Int, url: URL, status: SidebarPullRequestStatus) { + let state = SidebarPullRequestState(number: number, url: url, status: status) + let existing = panelPullRequests[panelId] + if existing != state { + panelPullRequests[panelId] = state + } + if panelId == focusedPanelId { + pullRequest = state + } + } + + func clearPanelPullRequest(panelId: UUID) { + panelPullRequests.removeValue(forKey: panelId) + if panelId == focusedPanelId { + pullRequest = nil + } + } + @discardableResult func updatePanelTitle(panelId: UUID, title: String) -> Bool { let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) @@ -851,6 +920,7 @@ final class Workspace: Identifiable, ObservableObject { manualUnreadMarkedAt = manualUnreadMarkedAt.filter { validSurfaceIds.contains($0.key) } surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) } surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) } + panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) } recomputeListeningPorts() } @@ -901,6 +971,14 @@ final class Workspace: Identifiable, ObservableObject { ) } + func sidebarPullRequestsInDisplayOrder() -> [SidebarPullRequestState] { + SidebarBranchOrdering.orderedUniquePullRequests( + orderedPanelIds: sidebarOrderedPanelIds(), + panelPullRequests: panelPullRequests, + fallbackPullRequest: pullRequest + ) + } + // MARK: - Panel Operations /// Create a new split with a terminal panel @@ -1797,6 +1875,7 @@ final class Workspace: Identifiable, ObservableObject { currentDirectory = dir } gitBranch = panelGitBranches[targetPanelId] + pullRequest = panelPullRequests[targetPanelId] } /// Reconcile focus/first-responder convergence. @@ -2018,6 +2097,7 @@ extension Workspace: BonsplitDelegate { currentDirectory = dir } gitBranch = panelGitBranches[panelId] + pullRequest = panelPullRequests[panelId] // Post notification NotificationCenter.default.post( @@ -2156,6 +2236,7 @@ extension Workspace: BonsplitDelegate { surfaceIdToPanelId.removeValue(forKey: tabId) panelDirectories.removeValue(forKey: panelId) panelGitBranches.removeValue(forKey: panelId) + panelPullRequests.removeValue(forKey: panelId) panelTitles.removeValue(forKey: panelId) panelCustomTitles.removeValue(forKey: panelId) pinnedPanelIds.remove(panelId) @@ -2248,6 +2329,7 @@ extension Workspace: BonsplitDelegate { panels.removeValue(forKey: panelId) panelDirectories.removeValue(forKey: panelId) panelGitBranches.removeValue(forKey: panelId) + panelPullRequests.removeValue(forKey: panelId) panelTitles.removeValue(forKey: panelId) panelCustomTitles.removeValue(forKey: panelId) pinnedPanelIds.remove(panelId) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 1d4f57fb..73dc6980 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2568,6 +2568,7 @@ struct SettingsView: View { @AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue + @AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true @State private var shortcutResetToken = UUID() @State private var topBlurOpacity: Double = 0 @State private var topBlurBaselineOffset: CGFloat? @@ -2773,6 +2774,16 @@ struct SettingsView: View { .pickerStyle(.menu) } + SettingsCardDivider() + + SettingsCardRow( + "Show Pull Requests in Sidebar", + subtitle: "Display PR status, number, and a clickable link when available." + ) { + Toggle("", isOn: $sidebarShowPullRequest) + .labelsHidden() + .controlSize(.small) + } } SettingsSectionHeader(title: "Workspace Colors") @@ -3300,6 +3311,7 @@ struct SettingsView: View { workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue + sidebarShowPullRequest = true showOpenAccessConfirmation = false pendingOpenAccessMode = nil socketPasswordDraft = "" diff --git a/tests/cmux.py b/tests/cmux.py index 23c1f4b7..aca7f4cb 100755 --- a/tests/cmux.py +++ b/tests/cmux.py @@ -572,6 +572,37 @@ class cmux: if not response.startswith("OK"): raise cmuxError(response) + def report_pr( + self, + number: int, + url: str, + state: str = None, + tab: str = None, + panel: str = None, + ) -> None: + """Report pull-request metadata for sidebar display.""" + cmd = f"report_pr {number} {url}" + if state: + cmd += f" --state={state}" + if tab: + cmd += f" --tab={tab}" + if panel: + cmd += f" --panel={panel}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + + def clear_pr(self, tab: str = None, panel: str = None) -> None: + """Clear pull-request metadata for sidebar display.""" + cmd = "clear_pr" + if tab: + cmd += f" --tab={tab}" + if panel: + cmd += f" --panel={panel}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + def report_ports(self, *ports: int, tab: str = None) -> None: """Report listening ports for sidebar display.""" port_str = " ".join(str(p) for p in ports) diff --git a/tests/test_sidebar_pr.py b/tests/test_sidebar_pr.py new file mode 100644 index 00000000..e6c9cbfd --- /dev/null +++ b/tests/test_sidebar_pr.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +""" +End-to-end test for sidebar pull-request metadata. + +Validates: +1) report_pr writes sidebar PR state +2) state transition open -> merged is reflected +3) clear_pr removes PR metadata +""" + +from __future__ import annotations + +import os +import sys +import time + +# Add the directory containing cmux.py to the path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from cmux import cmux, cmuxError # noqa: E402 + + +def _parse_sidebar_state(text: str) -> dict[str, str]: + data: dict[str, str] = {} + for raw in (text or "").splitlines(): + line = raw.rstrip("\n") + if not line or line.startswith(" "): + continue + if "=" not in line: + continue + k, v = line.split("=", 1) + data[k.strip()] = v.strip() + return data + + +def _wait_for_state_field( + client: cmux, + key: str, + expected: str, + timeout: float = 8.0, + interval: float = 0.1, +) -> dict[str, str]: + start = time.time() + while time.time() - start < timeout: + state = _parse_sidebar_state(client.sidebar_state()) + if state.get(key) == expected: + return state + time.sleep(interval) + raise AssertionError(f"Timed out waiting for {key}={expected!r}") + + +def main() -> int: + tag = os.environ.get("CMUX_TAG") or "" + if not tag: + print("Tip: set CMUX_TAG= when running this test to avoid socket conflicts.") + + pr_number = 123 + pr_url = f"https://github.com/manaflow-ai/cmux/pull/{pr_number}" + + try: + with cmux() as client: + new_tab_id = client.new_tab() + client.select_tab(new_tab_id) + time.sleep(0.6) + + tab_id = client.current_workspace() + surfaces = client.list_surfaces() + if not surfaces: + raise AssertionError("No surfaces found in selected workspace") + panel_id = surfaces[0][1] + + client.report_pr(pr_number, pr_url, state="open", tab=tab_id, panel=panel_id) + _wait_for_state_field(client, "pr", f"#{pr_number} open {pr_url}") + + client.report_pr(pr_number, pr_url, state="merged", tab=tab_id, panel=panel_id) + _wait_for_state_field(client, "pr", f"#{pr_number} merged {pr_url}") + + client.clear_pr(tab=tab_id, panel=panel_id) + _wait_for_state_field(client, "pr", "none") + + try: + client.close_tab(new_tab_id) + except Exception: + pass + + print("Sidebar PR metadata test passed.") + return 0 + except (cmuxError, AssertionError) as e: + print(f"Sidebar PR metadata test failed: {e}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From a33e231c790d7a3c26b63f3eef9477251439b43d Mon Sep 17 00:00:00 2001 From: Vadim Kostin Date: Mon, 23 Feb 2026 10:52:21 +0800 Subject: [PATCH 02/30] fix(browser): avoid unavailable symbolColorRenderingMode API --- Sources/Panels/BrowserPanelView.swift | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 65f2f25e..3b017ebd 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -156,13 +156,10 @@ private struct OmnibarAddressButtonStyleBody: View { } private extension View { - @ViewBuilder func cmuxFlatSymbolColorRendering() -> some View { - if #available(macOS 26.0, *) { - self.symbolColorRenderingMode(.flat) - } else { - self - } + // `symbolColorRenderingMode(.flat)` is not available in the current SDK + // used by CI/local builds. Keep this modifier as a compatibility no-op. + self } } From f2ecb4877b86d17cc7df5990c4ddf107c49138ca Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:19:38 -0800 Subject: [PATCH 03/30] feat(sidebar): add generic metadata rows and commands --- Sources/ContentView.swift | 158 +++++++++++++++++++++++------- Sources/TerminalController.swift | 159 +++++++++++++++++++++++++------ Sources/Workspace.swift | 16 ++++ Sources/cmuxApp.swift | 13 +++ tests/cmux.py | 67 ++++++++++++- tests/test_sidebar_meta.py | 126 ++++++++++++++++++++++++ 6 files changed, 473 insertions(+), 66 deletions(-) create mode 100644 tests/test_sidebar_meta.py diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b1236f89..41893bb6 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2485,7 +2485,7 @@ private struct TabItemView: View { @AppStorage("sidebarShowPorts") private var sidebarShowPorts = true @AppStorage("sidebarShowLog") private var sidebarShowLog = true @AppStorage("sidebarShowProgress") private var sidebarShowProgress = true - @AppStorage("sidebarShowStatusPills") private var sidebarShowStatusPills = true + @AppStorage("sidebarShowStatusPills") private var sidebarShowMetadata = true @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) private var activeTabIndicatorStyleRaw = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue @@ -2665,16 +2665,16 @@ private struct TabItemView: View { .multilineTextAlignment(.leading) } - if sidebarShowStatusPills, !tab.statusEntries.isEmpty { - SidebarStatusPillsRow( - entries: tab.statusEntries.values.sorted(by: { (lhs, rhs) in - if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } - return lhs.key < rhs.key - }), - isActive: usesInvertedActiveForeground, - onFocus: { updateSelection() } - ) - .transition(.opacity.combined(with: .move(edge: .top))) + if sidebarShowMetadata { + let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder() + if !metadataEntries.isEmpty { + SidebarMetadataRows( + entries: metadataEntries, + isActive: usesInvertedActiveForeground, + onFocus: { updateSelection() } + ) + .transition(.opacity.combined(with: .move(edge: .top))) + } } // Latest log entry @@ -3535,30 +3535,19 @@ private struct TabItemView: View { } } -private struct SidebarStatusPillsRow: View { +private struct SidebarMetadataRows: View { let entries: [SidebarStatusEntry] let isActive: Bool let onFocus: () -> Void @State private var isExpanded: Bool = false + private let collapsedEntryLimit = 3 var body: some View { VStack(alignment: .leading, spacing: 2) { - Text(statusText) - .font(.system(size: 10)) - .foregroundColor(isActive ? .white.opacity(0.8) : .secondary) - .lineLimit(isExpanded ? nil : 3) - .truncationMode(.tail) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - .onTapGesture { - onFocus() - guard shouldShowToggle else { return } - withAnimation(.easeInOut(duration: 0.15)) { - isExpanded.toggle() - } - } + ForEach(visibleEntries, id: \.key) { entry in + SidebarMetadataEntryRow(entry: entry, isActive: isActive, onFocus: onFocus) + } if shouldShowToggle { Button(isExpanded ? "Show less" : "Show more") { @@ -3573,21 +3562,116 @@ private struct SidebarStatusPillsRow: View { .frame(maxWidth: .infinity, alignment: .leading) } } - .help(statusText) + .help(helpText) } - private var statusText: String { - entries - .map { entry in - let value = entry.value.trimmingCharacters(in: .whitespacesAndNewlines) - if !value.isEmpty { return value } - return entry.key - } - .joined(separator: "\n") + private var visibleEntries: [SidebarStatusEntry] { + guard !isExpanded, entries.count > collapsedEntryLimit else { return entries } + return Array(entries.prefix(collapsedEntryLimit)) + } + + private var helpText: String { + entries.map { entry in + let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? entry.key : trimmed + } + .joined(separator: "\n") } private var shouldShowToggle: Bool { - entries.count > 1 || statusText.count > 120 + entries.count > collapsedEntryLimit + } +} + +private struct SidebarMetadataEntryRow: View { + let entry: SidebarStatusEntry + let isActive: Bool + let onFocus: () -> Void + + var body: some View { + Group { + if let url = entry.url { + Button { + onFocus() + NSWorkspace.shared.open(url) + } label: { + rowContent(underlined: true) + } + .buttonStyle(.plain) + .help(url.absoluteString) + } else { + rowContent(underlined: false) + .contentShape(Rectangle()) + .onTapGesture { onFocus() } + } + } + } + + @ViewBuilder + private func rowContent(underlined: Bool) -> some View { + HStack(spacing: 4) { + if let icon = iconView { + icon + .foregroundColor(foregroundColor.opacity(0.95)) + } + metadataText(underlined: underlined) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 0) + } + .font(.system(size: 10)) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var foregroundColor: Color { + if let raw = entry.color, let explicit = Color(hex: raw) { + return explicit + } + return isActive ? .white.opacity(0.8) : .secondary + } + + private var iconView: AnyView? { + guard let iconRaw = entry.icon?.trimmingCharacters(in: .whitespacesAndNewlines), + !iconRaw.isEmpty else { + return nil + } + if iconRaw.hasPrefix("emoji:") { + let value = String(iconRaw.dropFirst("emoji:".count)) + guard !value.isEmpty else { return nil } + return AnyView(Text(value).font(.system(size: 10))) + } + if iconRaw.hasPrefix("text:") { + let value = String(iconRaw.dropFirst("text:".count)) + guard !value.isEmpty else { return nil } + return AnyView(Text(value).font(.system(size: 9, weight: .semibold))) + } + let symbolName: String + if iconRaw.hasPrefix("sf:") { + symbolName = String(iconRaw.dropFirst("sf:".count)) + } else { + symbolName = iconRaw + } + guard !symbolName.isEmpty else { return nil } + return AnyView(Image(systemName: symbolName).font(.system(size: 9, weight: .medium))) + } + + @ViewBuilder + private func metadataText(underlined: Bool) -> some View { + let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines) + let display = trimmed.isEmpty ? entry.key : trimmed + if entry.format == .markdown, + let attributed = try? AttributedString( + markdown: display, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) { + Text(attributed) + .underline(underlined) + .foregroundColor(foregroundColor) + } else { + Text(display) + .underline(underlined) + .foregroundColor(foregroundColor) + } } } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 532f9dd3..78f4e88a 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -165,10 +165,19 @@ class TerminalController { key: String, value: String, icon: String?, - color: String? + color: String?, + url: URL?, + priority: Int, + format: SidebarMetadataFormat ) -> Bool { guard let current else { return true } - return current.key != key || current.value != value || current.icon != icon || current.color != color + return current.key != key || + current.value != value || + current.icon != icon || + current.color != color || + current.url != url || + current.priority != priority || + current.format != format } nonisolated static func shouldReplaceProgress( @@ -716,12 +725,21 @@ class TerminalController { case "set_status": return setStatus(args) + case "report_meta": + return reportMeta(args) + case "clear_status": return clearStatus(args) + case "clear_meta": + return clearMeta(args) + case "list_status": return listStatus(args) + case "list_meta": + return listMeta(args) + case "log": return appendLog(args) @@ -7885,9 +7903,12 @@ class TerminalController { clear_notifications - Clear all notifications set_app_focus - Override app focus state simulate_app_active - Trigger app active handler - set_status [--icon=X] [--color=#hex] [--tab=X] - Set a status entry + set_status [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] - Set a status entry + report_meta [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] - Set sidebar metadata entry clear_status [--tab=X] - Remove a status entry + clear_meta [--tab=X] - Remove sidebar metadata entry list_status [--tab=X] - List all status entries + list_meta [--tab=X] - List sidebar metadata entries log [--level=X] [--source=X] [--tab=X] -- - Append a log entry clear_log [--tab=X] - Clear log entries list_log [--limit=N] [--tab=X] - List log entries @@ -10826,16 +10847,59 @@ class TerminalController { return tabManager.tabs.first(where: { $0.id == selectedId }) } - private func setStatus(_ args: String) -> String { + private func parseSidebarMetadataFormat(_ raw: String) -> SidebarMetadataFormat? { + switch raw.lowercased() { + case "plain": + return .plain + case "markdown", "md": + return .markdown + default: + return nil + } + } + + private func normalizedOptionValue(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func upsertSidebarMetadata(_ args: String, missingError: String) -> String { guard tabManager != nil else { return "ERROR: TabManager not available" } let parsed = parseOptionsNoStop(args) - guard parsed.positional.count >= 2 else { - return "ERROR: Missing status key or value — usage: set_status [--icon=X] [--color=#hex] [--tab=X]" - } + guard parsed.positional.count >= 2 else { return missingError } + let key = parsed.positional[0] let value = parsed.positional[1...].joined(separator: " ") - let icon = parsed.options["icon"] - let color = parsed.options["color"] + let icon = normalizedOptionValue(parsed.options["icon"]) + let color = normalizedOptionValue(parsed.options["color"]) + + let formatRaw = normalizedOptionValue(parsed.options["format"]) ?? SidebarMetadataFormat.plain.rawValue + guard let format = parseSidebarMetadataFormat(formatRaw) else { + return "ERROR: Invalid metadata format '\(formatRaw)' — use: plain, markdown" + } + + let priority: Int + if let rawPriority = normalizedOptionValue(parsed.options["priority"]) { + guard let parsedPriority = Int(rawPriority) else { + return "ERROR: Invalid metadata priority '\(rawPriority)' — must be an integer" + } + priority = max(-9999, min(9999, parsedPriority)) + } else { + priority = 0 + } + + let parsedURL: URL? + if let rawURL = normalizedOptionValue(parsed.options["url"] ?? parsed.options["link"]) { + guard let candidate = URL(string: rawURL), + let scheme = candidate.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return "ERROR: Invalid metadata URL '\(rawURL)' — expected http(s) URL" + } + parsedURL = candidate + } else { + parsedURL = nil + } var result = "OK" DispatchQueue.main.sync { @@ -10848,7 +10912,10 @@ class TerminalController { key: key, value: value, icon: icon, - color: color + color: color, + url: parsedURL, + priority: priority, + format: format ) else { return } @@ -10857,16 +10924,19 @@ class TerminalController { value: value, icon: icon, color: color, + url: parsedURL, + priority: priority, + format: format, timestamp: Date() ) } return result } - private func clearStatus(_ args: String) -> String { + private func clearSidebarMetadata(_ args: String, usage: String) -> String { let parsed = parseOptions(args) guard let key = parsed.positional.first, parsed.positional.count == 1 else { - return "ERROR: Missing status key — usage: clear_status [--tab=X]" + return "ERROR: Missing metadata key — usage: \(usage)" } var result = "OK" @@ -10882,28 +10952,63 @@ class TerminalController { return result } - private func listStatus(_ args: String) -> String { + private func sidebarMetadataLine(_ entry: SidebarStatusEntry) -> String { + var line = "\(entry.key)=\(entry.value)" + if let icon = entry.icon { line += " icon=\(icon)" } + if let color = entry.color { line += " color=\(color)" } + if let url = entry.url { line += " url=\(url.absoluteString)" } + if entry.priority != 0 { line += " priority=\(entry.priority)" } + if entry.format != .plain { line += " format=\(entry.format.rawValue)" } + return line + } + + private func listSidebarMetadata(_ args: String, emptyMessage: String) -> String { var result = "" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { result = "ERROR: Tab not found" return } - if tab.statusEntries.isEmpty { - result = "No status entries" + let entries = tab.sidebarStatusEntriesInDisplayOrder() + if entries.isEmpty { + result = emptyMessage return } - let lines = tab.statusEntries.values.sorted(by: { $0.key < $1.key }).map { entry in - var line = "\(entry.key)=\(entry.value)" - if let icon = entry.icon { line += " icon=\(icon)" } - if let color = entry.color { line += " color=\(color)" } - return line - } - result = lines.joined(separator: "\n") + result = entries.map(sidebarMetadataLine).joined(separator: "\n") } return result } + private func setStatus(_ args: String) -> String { + upsertSidebarMetadata( + args, + missingError: "ERROR: Missing status key or value — usage: set_status [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X]" + ) + } + + private func reportMeta(_ args: String) -> String { + upsertSidebarMetadata( + args, + missingError: "ERROR: Missing metadata key or value — usage: report_meta [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X]" + ) + } + + private func clearStatus(_ args: String) -> String { + clearSidebarMetadata(args, usage: "clear_status [--tab=X]") + } + + private func clearMeta(_ args: String) -> String { + clearSidebarMetadata(args, usage: "clear_meta [--tab=X]") + } + + private func listStatus(_ args: String) -> String { + listSidebarMetadata(args, emptyMessage: "No status entries") + } + + private func listMeta(_ args: String) -> String { + listSidebarMetadata(args, emptyMessage: "No metadata entries") + } + private func appendLog(_ args: String) -> String { let parsed = parseOptions(args) guard !parsed.positional.isEmpty else { @@ -11533,12 +11638,10 @@ class TerminalController { lines.append("progress=none") } - lines.append("status_count=\(tab.statusEntries.count)") - for entry in tab.statusEntries.values.sorted(by: { $0.key < $1.key }) { - var line = " \(entry.key)=\(entry.value)" - if let icon = entry.icon { line += " icon=\(icon)" } - if let color = entry.color { line += " color=\(color)" } - lines.append(line) + let statusEntries = tab.sidebarStatusEntriesInDisplayOrder() + lines.append("status_count=\(statusEntries.count)") + for entry in statusEntries { + lines.append(" \(sidebarMetadataLine(entry))") } lines.append("log_count=\(tab.logEntries.count)") diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index e634b8b1..fe49ccb5 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -9,9 +9,17 @@ struct SidebarStatusEntry { let value: String let icon: String? let color: String? + let url: URL? + let priority: Int + let format: SidebarMetadataFormat let timestamp: Date } +enum SidebarMetadataFormat: String { + case plain + case markdown +} + enum SidebarLogLevel: String { case info case progress @@ -979,6 +987,14 @@ final class Workspace: Identifiable, ObservableObject { ) } + func sidebarStatusEntriesInDisplayOrder() -> [SidebarStatusEntry] { + statusEntries.values.sorted { lhs, rhs in + if lhs.priority != rhs.priority { return lhs.priority > rhs.priority } + if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } + return lhs.key < rhs.key + } + } + // MARK: - Panel Operations /// Create a new split with a terminal panel diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 73dc6980..20d85821 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2569,6 +2569,7 @@ struct SettingsView: View { @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue @AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true + @AppStorage("sidebarShowStatusPills") private var sidebarShowMetadata = true @State private var shortcutResetToken = UUID() @State private var topBlurOpacity: Double = 0 @State private var topBlurBaselineOffset: CGFloat? @@ -2784,6 +2785,17 @@ struct SettingsView: View { .labelsHidden() .controlSize(.small) } + + SettingsCardDivider() + + SettingsCardRow( + "Show Custom Metadata in Sidebar", + subtitle: "Display metadata rows from report_meta/set_status, including icons and optional links." + ) { + Toggle("", isOn: $sidebarShowMetadata) + .labelsHidden() + .controlSize(.small) + } } SettingsSectionHeader(title: "Workspace Colors") @@ -3312,6 +3324,7 @@ struct SettingsView: View { sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue sidebarShowPullRequest = true + sidebarShowMetadata = true showOpenAccessConfirmation = false pendingOpenAccessMode = nil socketPasswordDraft = "" diff --git a/tests/cmux.py b/tests/cmux.py index aca7f4cb..2c022319 100755 --- a/tests/cmux.py +++ b/tests/cmux.py @@ -500,7 +500,17 @@ class cmux: if not response.startswith("OK"): raise cmuxError(response) - def set_status(self, key: str, value: str, icon: str = None, color: str = None, tab: str = None) -> None: + def set_status( + self, + key: str, + value: str, + icon: str = None, + color: str = None, + url: str = None, + priority: int = None, + format: str = None, + tab: str = None, + ) -> None: """Set a sidebar status entry.""" # Put options before `--` so value can contain arbitrary tokens like `--tab`. cmd = f"set_status {key}" @@ -508,6 +518,12 @@ class cmux: cmd += f" --icon={icon}" if color: cmd += f" --color={color}" + if url: + cmd += f" --url={_quote_option_value(url)}" + if priority is not None: + cmd += f" --priority={priority}" + if format: + cmd += f" --format={format}" if tab: cmd += f" --tab={tab}" cmd += f" -- {_quote_option_value(value)}" @@ -524,6 +540,55 @@ class cmux: if not response.startswith("OK"): raise cmuxError(response) + def report_meta( + self, + key: str, + value: str, + icon: str = None, + color: str = None, + url: str = None, + priority: int = None, + format: str = None, + tab: str = None, + ) -> None: + """Report a sidebar metadata entry.""" + cmd = f"report_meta {key}" + if icon: + cmd += f" --icon={icon}" + if color: + cmd += f" --color={color}" + if url: + cmd += f" --url={_quote_option_value(url)}" + if priority is not None: + cmd += f" --priority={priority}" + if format: + cmd += f" --format={format}" + if tab: + cmd += f" --tab={tab}" + cmd += f" -- {_quote_option_value(value)}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + + def clear_meta(self, key: str, tab: str = None) -> None: + """Remove a sidebar metadata entry.""" + cmd = f"clear_meta {key}" + if tab: + cmd += f" --tab={tab}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + + def list_meta(self, tab: str = None) -> str: + """List sidebar metadata entries.""" + cmd = "list_meta" + if tab: + cmd += f" --tab={tab}" + response = self._send_command(cmd) + if response.startswith("ERROR"): + raise cmuxError(response) + return response + def log(self, message: str, level: str = None, source: str = None, tab: str = None) -> None: """Append a sidebar log entry.""" # TerminalController.parseOptions treats any --* token as an option until diff --git a/tests/test_sidebar_meta.py b/tests/test_sidebar_meta.py new file mode 100644 index 00000000..7d5af6f0 --- /dev/null +++ b/tests/test_sidebar_meta.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +""" +End-to-end test for generic sidebar metadata commands. + +Validates: +1) report_meta stores icon/url/priority/format metadata +2) metadata list ordering follows priority +3) set_status remains compatible as an alias-style metadata writer +4) clear_meta removes metadata entries +""" + +from __future__ import annotations + +import os +import sys +import time + +# Add the directory containing cmux.py to the path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from cmux import cmux, cmuxError # noqa: E402 + + +def _parse_sidebar_state(text: str) -> dict[str, str]: + data: dict[str, str] = {} + for raw in (text or "").splitlines(): + line = raw.rstrip("\n") + if not line or line.startswith(" "): + continue + if "=" not in line: + continue + k, v = line.split("=", 1) + data[k.strip()] = v.strip() + return data + + +def _wait_for_state_field( + client: cmux, + key: str, + expected: str, + timeout: float = 8.0, + interval: float = 0.1, +) -> dict[str, str]: + start = time.time() + while time.time() - start < timeout: + state = _parse_sidebar_state(client.sidebar_state()) + if state.get(key) == expected: + return state + time.sleep(interval) + raise AssertionError(f"Timed out waiting for {key}={expected!r}") + + +def main() -> int: + tag = os.environ.get("CMUX_TAG") or "" + if not tag: + print("Tip: set CMUX_TAG= when running this test to avoid socket conflicts.") + + pr_url = "https://github.com/manaflow-ai/cmux/pull/337" + + try: + with cmux() as client: + new_tab_id = client.new_tab() + client.select_tab(new_tab_id) + time.sleep(0.6) + + tab_id = client.current_workspace() + + client.report_meta( + "task", + "**Review** PR 337", + icon="sf:doc.text.magnifyingglass", + url=pr_url, + priority=50, + format="markdown", + tab=tab_id, + ) + client.report_meta( + "context", + "issue-336-sidebar-pr-metadata", + icon="text:CTX", + priority=10, + tab=tab_id, + ) + _wait_for_state_field(client, "status_count", "2") + + listed = client.list_meta(tab=tab_id).splitlines() + if len(listed) != 2: + raise AssertionError(f"Expected 2 metadata entries, got {len(listed)}: {listed}") + + if not listed[0].startswith("task="): + raise AssertionError(f"Expected first entry to be task metadata. Got: {listed[0]}") + if "priority=50" not in listed[0]: + raise AssertionError(f"Expected task entry to include priority. Got: {listed[0]}") + if "format=markdown" not in listed[0]: + raise AssertionError(f"Expected markdown format in task entry. Got: {listed[0]}") + if f"url={pr_url}" not in listed[0]: + raise AssertionError(f"Expected URL in task entry. Got: {listed[0]}") + + client.set_status("agent", "in progress", icon="text:AI", priority=80, tab=tab_id) + _wait_for_state_field(client, "status_count", "3") + + listed = client.list_meta(tab=tab_id).splitlines() + if not listed[0].startswith("agent="): + raise AssertionError(f"Expected highest-priority agent entry first. Got: {listed[0]}") + + client.clear_meta("task", tab=tab_id) + _wait_for_state_field(client, "status_count", "2") + + listed = client.list_meta(tab=tab_id).splitlines() + if any(line.startswith("task=") for line in listed): + raise AssertionError(f"Task metadata should be cleared. Got: {listed}") + + try: + client.close_tab(new_tab_id) + except Exception: + pass + + print("Sidebar metadata test passed.") + return 0 + except (cmuxError, AssertionError) as e: + print(f"Sidebar metadata test failed: {e}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From 93c167ff02dd859b603cdf7ec9275b65bb5b1346 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:22:38 -0800 Subject: [PATCH 04/30] Add Sentry breadcrumbs for claude-hook socket failures --- CLI/cmux.swift | 222 +++++++++++++++++- GhosttyTabs.xcodeproj/project.pbxproj | 15 ++ .../test_claude_hook_missing_socket_error.py | 83 +++++++ 3 files changed, 317 insertions(+), 3 deletions(-) create mode 100644 tests/test_claude_hook_missing_socket_error.py diff --git a/CLI/cmux.swift b/CLI/cmux.swift index da0161b3..44f98629 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1,6 +1,9 @@ import Foundation import Darwin import Security +#if canImport(Sentry) +import Sentry +#endif struct CLIError: Error, CustomStringConvertible { let message: String @@ -8,6 +11,177 @@ struct CLIError: Error, CustomStringConvertible { var description: String { message } } +private final class ClaudeHookSentryTelemetry { + private let enabled: Bool + private let subcommand: String + private let socketPath: String + private let envSocketPath: String? + private let workspaceId: String? + private let surfaceId: String? + private let disabledByEnv: Bool + +#if canImport(Sentry) + private static let startupLock = NSLock() + private static var started = false + private static let dsn = "https://ecba1ec90ecaee02a102fba931b6d2b3@o4507547940749312.ingest.us.sentry.io/4510796264636416" +#endif + + init(command: String, commandArgs: [String], socketPath: String, processEnv: [String: String]) { + self.enabled = command == "claude-hook" + self.subcommand = commandArgs.first?.lowercased() ?? "help" + self.socketPath = socketPath + self.envSocketPath = processEnv["CMUX_SOCKET_PATH"] + self.workspaceId = processEnv["CMUX_WORKSPACE_ID"] + self.surfaceId = processEnv["CMUX_SURFACE_ID"] + self.disabledByEnv = processEnv["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] == "1" + } + + func breadcrumb(_ message: String, data: [String: Any] = [:]) { + guard shouldEmit else { return } +#if canImport(Sentry) + Self.ensureStarted() + var payload = baseContext() + for (key, value) in data { + payload[key] = value + } + let crumb = Breadcrumb(level: .info, category: "claude-hook.cli") + crumb.message = message + crumb.data = payload + SentrySDK.addBreadcrumb(crumb) +#endif + } + + func captureError(stage: String, error: Error) { + guard shouldEmit else { return } +#if canImport(Sentry) + Self.ensureStarted() + var context = baseContext() + context["stage"] = stage + context["error"] = String(describing: error) + for (key, value) in socketDiagnostics() { + context[key] = value + } + let subcommand = self.subcommand + _ = SentrySDK.capture(error: error) { scope in + scope.setLevel(.error) + scope.setTag(value: "cmux-cli", key: "component") + scope.setTag(value: subcommand, key: "claude_hook_subcommand") + scope.setContext(value: context, key: "claude_hook") + } + SentrySDK.flush(timeout: 2.0) +#endif + } + + private var shouldEmit: Bool { + enabled && !disabledByEnv + } + + private func baseContext() -> [String: Any] { + var context: [String: Any] = [ + "subcommand": subcommand, + "requested_socket_path": socketPath, + "env_socket_path": envSocketPath ?? "" + ] + if let workspaceId { + context["workspace_id"] = workspaceId + } + if let surfaceId { + context["surface_id"] = surfaceId + } + return context + } + + private func socketDiagnostics() -> [String: Any] { + var context: [String: Any] = [ + "cwd": FileManager.default.currentDirectoryPath, + "uid": Int(getuid()), + "euid": Int(geteuid()) + ] + + var st = stat() + if lstat(socketPath, &st) == 0 { + context["socket_exists"] = true + context["socket_mode"] = String(format: "%o", Int(st.st_mode & 0o7777)) + context["socket_owner_uid"] = Int(st.st_uid) + context["socket_owner_gid"] = Int(st.st_gid) + context["socket_file_type"] = Self.fileTypeDescription(mode: st.st_mode) + } else { + let code = errno + context["socket_exists"] = false + context["socket_errno"] = Int(code) + context["socket_errno_description"] = String(cString: strerror(code)) + } + + let tmpSockets = Self.discoverTmpCmuxSockets(limit: 10) + if !tmpSockets.isEmpty { + context["tmp_cmux_sockets"] = tmpSockets + } + let taggedSockets = tmpSockets.filter { $0 != "/tmp/cmux.sock" } + if socketPath == "/tmp/cmux.sock", + (envSocketPath?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true), + !taggedSockets.isEmpty { + context["possible_root_cause"] = "CMUX_SOCKET_PATH missing while tagged sockets exist" + } + + return context + } + + private static func fileTypeDescription(mode: mode_t) -> String { + switch mode & mode_t(S_IFMT) { + case mode_t(S_IFSOCK): + return "socket" + case mode_t(S_IFREG): + return "regular" + case mode_t(S_IFDIR): + return "directory" + case mode_t(S_IFLNK): + return "symlink" + default: + return "other" + } + } + + private static func discoverTmpCmuxSockets(limit: Int) -> [String] { + guard let entries = try? FileManager.default.contentsOfDirectory(atPath: "/tmp") else { + return [] + } + var sockets: [String] = [] + for name in entries.sorted() { + guard name.hasPrefix("cmux"), name.hasSuffix(".sock") else { continue } + let fullPath = "/tmp/\(name)" + var st = stat() + guard lstat(fullPath, &st) == 0 else { continue } + guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { continue } + sockets.append(fullPath) + if sockets.count >= limit { + break + } + } + return sockets + } + +#if canImport(Sentry) + private static func ensureStarted() { + startupLock.lock() + defer { startupLock.unlock() } + guard !started else { return } + SentrySDK.start { options in + options.dsn = dsn +#if DEBUG + options.environment = "development-cli" +#else + options.environment = "production-cli" +#endif + options.debug = false + options.sendDefaultPii = true + options.attachStacktrace = true + options.tracesSampleRate = 0.0 + } + started = true + } +#endif +} + struct WindowInfo { let index: Int let id: String @@ -503,6 +677,12 @@ struct CMUXCLI { let command = args[index] let commandArgs = Array(args[(index + 1)...]) + let hookTelemetry = ClaudeHookSentryTelemetry( + command: command, + commandArgs: commandArgs, + socketPath: socketPath, + processEnv: ProcessInfo.processInfo.environment + ) if command == "version" { print(versionSummary()) @@ -518,7 +698,18 @@ struct CMUXCLI { } let client = SocketClient(path: socketPath) - try client.connect() + hookTelemetry.breadcrumb( + "socket.connect.attempt", + data: ["command": command] + ) + do { + try client.connect() + hookTelemetry.breadcrumb("socket.connect.success") + } catch { + hookTelemetry.breadcrumb("socket.connect.failure") + hookTelemetry.captureError(stage: "socket_connect", error: error) + throw error + } defer { client.close() } if let socketPassword = SocketPasswordResolver.resolve(explicit: socketPasswordArg) { @@ -1103,7 +1294,15 @@ struct CMUXCLI { print(response) case "claude-hook": - try runClaudeHook(commandArgs: commandArgs, client: client) + hookTelemetry.breadcrumb("claude-hook.dispatch") + do { + try runClaudeHook(commandArgs: commandArgs, client: client, telemetry: hookTelemetry) + hookTelemetry.breadcrumb("claude-hook.completed") + } catch { + hookTelemetry.breadcrumb("claude-hook.failure") + hookTelemetry.captureError(stage: "claude_hook_dispatch", error: error) + throw error + } case "set-status": let (icon, r1) = parseOption(commandArgs, name: "--icon") @@ -4332,7 +4531,11 @@ struct CMUXCLI { } } - private func runClaudeHook(commandArgs: [String], client: SocketClient) throws { + private func runClaudeHook( + commandArgs: [String], + client: SocketClient, + telemetry: ClaudeHookSentryTelemetry + ) throws { let subcommand = commandArgs.first?.lowercased() ?? "help" let hookArgs = Array(commandArgs.dropFirst()) let hookWsFlag = optionValue(hookArgs, name: "--workspace") @@ -4341,11 +4544,21 @@ struct CMUXCLI { let rawInput = String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8) ?? "" let parsedInput = parseClaudeHookInput(rawInput: rawInput) let sessionStore = ClaudeHookSessionStore() + telemetry.breadcrumb( + "claude-hook.input", + data: [ + "subcommand": subcommand, + "has_session_id": parsedInput.sessionId != nil, + "has_workspace_flag": hookWsFlag != nil, + "has_surface_flag": optionValue(hookArgs, name: "--surface") != nil + ] + ) let fallbackWorkspaceId = try resolveWorkspaceIdForClaudeHook(workspaceArg, client: client) let fallbackSurfaceId = try? resolveSurfaceId(surfaceArg, workspaceId: fallbackWorkspaceId, client: client) switch subcommand { case "session-start", "active": + telemetry.breadcrumb("claude-hook.session-start") let workspaceId = fallbackWorkspaceId let surfaceId = try resolveSurfaceIdForClaudeHook( surfaceArg, @@ -4370,6 +4583,7 @@ struct CMUXCLI { print("OK") case "stop", "idle": + telemetry.breadcrumb("claude-hook.stop") let consumedSession = try? sessionStore.consume( sessionId: parsedInput.sessionId, workspaceId: fallbackWorkspaceId, @@ -4398,6 +4612,7 @@ struct CMUXCLI { } case "notification", "notify": + telemetry.breadcrumb("claude-hook.notification") let summary = summarizeClaudeHookNotification(rawInput: rawInput) var workspaceId = fallbackWorkspaceId @@ -4442,6 +4657,7 @@ struct CMUXCLI { print(response) case "help", "--help", "-h": + telemetry.breadcrumb("claude-hook.help") print( """ cmux claude-hook [--workspace ] [--surface ] diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index dc05ce47..d8bc7236 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -35,6 +35,7 @@ A5001094 /* NotificationsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001091 /* NotificationsPage.swift */; }; A5001095 /* TerminalNotificationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001092 /* TerminalNotificationStore.swift */; }; A5001250 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = A5001251 /* Sentry */; }; + B9000024A1B2C3D4E5F60719 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = A5001251 /* Sentry */; }; A5001270 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = A5001271 /* PostHog */; }; A5001303 /* SurfaceSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001301 /* SurfaceSearchOverlay.swift */; }; A50012F1 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F0 /* Backport.swift */; }; @@ -242,6 +243,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + B9000024A1B2C3D4E5F60719 /* Sentry in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -465,6 +467,9 @@ ); dependencies = ( ); + packageProductDependencies = ( + A5001251 /* Sentry */, + ); name = "cmux-cli"; productName = cmux; productReference = B9000004A1B2C3D4E5F60719 /* cmux */; @@ -801,6 +806,11 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path", + "@executable_path/../Frameworks", + ); MACOSX_DEPLOYMENT_TARGET = 14.0; PRODUCT_NAME = cmux; PRODUCT_MODULE_NAME = cmux_cli; @@ -814,6 +824,11 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path", + "@executable_path/../Frameworks", + ); MACOSX_DEPLOYMENT_TARGET = 14.0; PRODUCT_NAME = cmux; PRODUCT_MODULE_NAME = cmux_cli; diff --git a/tests/test_claude_hook_missing_socket_error.py b/tests/test_claude_hook_missing_socket_error.py new file mode 100644 index 00000000..96357164 --- /dev/null +++ b/tests/test_claude_hook_missing_socket_error.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +""" +Regression test: claude-hook stop surfaces a clear socket-connect error when target socket is missing. +""" + +from __future__ import annotations + +import glob +import os +import shutil +import subprocess +import tempfile + + +def resolve_cmux_cli() -> str: + explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI") + if explicit and os.path.exists(explicit) and os.access(explicit, os.X_OK): + return explicit + + candidates: list[str] = [] + candidates.extend(glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux"))) + candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")) + candidates = [p for p in candidates if os.path.exists(p) and os.access(p, os.X_OK)] + if candidates: + candidates.sort(key=os.path.getmtime, reverse=True) + return candidates[0] + + in_path = shutil.which("cmux") + if in_path: + return in_path + + raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.") + + +def main() -> int: + try: + cli_path = resolve_cmux_cli() + except Exception as exc: + print(f"FAIL: {exc}") + return 1 + + missing_socket = os.path.join(tempfile.gettempdir(), f"cmux-missing-{os.getpid()}.sock") + try: + if os.path.exists(missing_socket): + os.remove(missing_socket) + except OSError: + pass + + env = os.environ.copy() + env["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" + env.pop("CMUX_SOCKET_PATH", None) + + proc = subprocess.run( + [cli_path, "--socket", missing_socket, "claude-hook", "stop"], + input="{}", + text=True, + capture_output=True, + env=env, + check=False, + ) + + if proc.returncode == 0: + print("FAIL: expected non-zero exit when socket is missing") + print(f"stdout={proc.stdout}") + print(f"stderr={proc.stderr}") + return 1 + + expected_prefixes = [ + f"Error: Socket not found at {missing_socket}", + f"Error: Failed to connect to socket at {missing_socket}", + ] + if not any(prefix in proc.stderr for prefix in expected_prefixes): + print("FAIL: missing expected socket error text") + print(f"expected one of: {expected_prefixes!r}") + print(f"stderr: {proc.stderr!r}") + return 1 + + print("PASS: claude-hook stop missing-socket error is explicit") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 589153b1b263cf7da0d9f826780a3f21a0ce7027 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 24 Feb 2026 20:31:45 -0800 Subject: [PATCH 05/30] Fix browser context-menu download targeting and debug traces --- Sources/Panels/CmuxWebView.swift | 877 +++++++++++++++++++++++++++---- 1 file changed, 768 insertions(+), 109 deletions(-) diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index bcd77ed2..c8244dd2 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -1,6 +1,7 @@ import AppKit import Bonsplit import ObjectiveC +import UniformTypeIdentifiers import WebKit /// WKWebView tends to consume some Command-key equivalents (e.g. Cmd+N/Cmd+W), @@ -258,11 +259,179 @@ final class CmuxWebView: WKWebView { private var fallbackDownloadLinkedFileTarget: AnyObject? private var fallbackDownloadLinkedFileAction: Selector? + private static func makeContextDownloadTraceID(prefix: String) -> String { +#if DEBUG + return "\(prefix)-\(UUID().uuidString.prefix(8))" +#else + return prefix +#endif + } + + private func debugContextDownload(_ message: @autoclosure () -> String) { +#if DEBUG + dlog(message()) +#endif + } + + private static func selectorName(_ selector: Selector?) -> String { + guard let selector else { return "nil" } + return NSStringFromSelector(selector) + } + + private func debugLogContextMenuDownloadCandidate(_ item: NSMenuItem, index: Int) { + let identifier = item.identifier?.rawValue ?? "nil" + let title = item.title + let actionName = Self.selectorName(item.action) + let idToken = Self.normalizedContextMenuToken(identifier) + let titleToken = Self.normalizedContextMenuToken(title) + let actionToken = Self.normalizedContextMenuToken(actionName) + guard idToken.contains("download") + || titleToken.contains("download") + || actionToken.contains("download") else { + return + } + debugContextDownload( + "browser.ctxdl.menu item index=\(index) id=\(identifier) title=\(title) action=\(actionName)" + ) + } + + private struct ParsedDataURL { + let data: Data + let mimeType: String? + } + + private static func parseDataURL(_ url: URL) -> ParsedDataURL? { + let absolute = url.absoluteString + guard absolute.hasPrefix("data:"), + let commaIndex = absolute.firstIndex(of: ",") else { + return nil + } + + let headerStart = absolute.index(absolute.startIndex, offsetBy: 5) + let header = String(absolute[headerStart.. String? { + guard let mimeType, !mimeType.isEmpty else { return nil } + if #available(macOS 11.0, *) { + if let preferred = UTType(mimeType: mimeType)?.preferredFilenameExtension, !preferred.isEmpty { + return preferred + } + } + switch mimeType.lowercased() { + case "image/jpeg": + return "jpg" + case "image/png": + return "png" + case "image/webp": + return "webp" + case "image/gif": + return "gif" + case "text/html": + return "html" + case "text/plain": + return "txt" + default: + return nil + } + } + + private static func suggestedFilenameForDataURL( + mimeType: String?, + suggestedFilename: String? + ) -> String { + if let suggested = suggestedFilename?.trimmingCharacters(in: .whitespacesAndNewlines), + !suggested.isEmpty { + return suggested + } + let ext = filenameExtension(forMIMEType: mimeType) ?? "bin" + let base = (mimeType?.lowercased().hasPrefix("image/") ?? false) ? "image" : "download" + return "\(base).\(ext)" + } + + private static func normalizedContextMenuToken(_ value: String?) -> String { + guard let value else { return "" } + let lowered = value.lowercased() + let alphanumerics = CharacterSet.alphanumerics + let scalars = lowered.unicodeScalars.filter { alphanumerics.contains($0) } + return String(String.UnicodeScalarView(scalars)) + } + + private func isDownloadImageMenuItem(_ item: NSMenuItem) -> Bool { + let identifier = Self.normalizedContextMenuToken(item.identifier?.rawValue) + if identifier.contains("downloadimage") { + return true + } + + let title = Self.normalizedContextMenuToken(item.title) + if title.contains("downloadimage") { + return true + } + + if let action = item.action { + let actionName = Self.normalizedContextMenuToken(NSStringFromSelector(action)) + if actionName.contains("downloadimage") { + return true + } + } + + return false + } + + private func isDownloadLinkedFileMenuItem(_ item: NSMenuItem) -> Bool { + let identifier = Self.normalizedContextMenuToken(item.identifier?.rawValue) + if identifier.contains("downloadlinkedfile") + || identifier.contains("downloadlinktodisk") { + return true + } + + let title = Self.normalizedContextMenuToken(item.title) + if title.contains("downloadlinkedfile") + || title.contains("downloadlinktodisk") { + return true + } + + if let action = item.action { + let actionName = Self.normalizedContextMenuToken(NSStringFromSelector(action)) + if actionName.contains("downloadlinkedfile") + || actionName.contains("downloadlinktodisk") { + return true + } + } + + return false + } + private func isDownloadableScheme(_ url: URL) -> Bool { let scheme = url.scheme?.lowercased() ?? "" return scheme == "http" || scheme == "https" || scheme == "file" } + private func isDataURLScheme(_ url: URL) -> Bool { + let scheme = url.scheme?.lowercased() ?? "" + return scheme == "data" + } + + private func isDownloadSupportedScheme(_ url: URL) -> Bool { + return isDownloadableScheme(url) || isDataURLScheme(url) + } + private func isOurDownloadMenuAction(target: AnyObject?, action: Selector?) -> Bool { guard target === self else { return false } return action == #selector(contextMenuDownloadImage(_:)) @@ -271,7 +440,7 @@ final class CmuxWebView: WKWebView { private func resolveGoogleRedirectURL(_ url: URL) -> URL? { guard let host = url.host?.lowercased(), host.contains("google.") else { return nil } - guard var comps = URLComponents(url: url, resolvingAgainstBaseURL: false), + guard let comps = URLComponents(url: url, resolvingAgainstBaseURL: false), let queryItems = comps.queryItems else { return nil } let map = Dictionary(uniqueKeysWithValues: queryItems.map { ($0.name.lowercased(), $0.value ?? "") }) let candidates = ["imgurl", "mediaurl", "url", "q"] @@ -299,6 +468,43 @@ final class CmuxWebView: WKWebView { resolveGoogleRedirectURL(url) ?? url } + private func isLikelyFaviconURL(_ url: URL) -> Bool { + let lower = url.absoluteString.lowercased() + if lower.contains("favicon") { return true } + let name = url.lastPathComponent.lowercased() + return name.hasPrefix("favicon") + } + + private func isLikelyImageURL(_ url: URL) -> Bool { + if isDataURLScheme(url) { + guard let parsed = Self.parseDataURL(url), + let mime = parsed.mimeType?.lowercased() else { + return false + } + return mime.hasPrefix("image/") + } + guard isDownloadableScheme(url) else { return false } + let ext = url.pathExtension.lowercased() + if [ + "jpg", "jpeg", "png", "webp", "gif", "bmp", + "svg", "avif", "heic", "heif", "tif", "tiff", "ico" + ].contains(ext) { + return true + } + let lower = url.absoluteString.lowercased() + if lower.contains("imgurl=") + || lower.contains("mediaurl=") + || lower.contains("encrypted-tbn") + || lower.contains("format=jpg") + || lower.contains("format=jpeg") + || lower.contains("format=png") + || lower.contains("format=webp") + || lower.contains("format=gif") { + return true + } + return false + } + private func captureFallbackForMenuItemIfNeeded(_ item: NSMenuItem) { let target = item.target as AnyObject? let action = item.action @@ -331,49 +537,121 @@ final class CmuxWebView: WKWebView { let flippedY = bounds.height - point.y let js = """ (() => { - const nodes = document.elementsFromPoint(\(point.x), \(flippedY)); - for (const start of nodes) { - let elChain = []; - let seen = new Set(); - let walk = (node) => { - let chain = []; - let localSeen = new Set(); - let visit = (n) => { - while (n && !localSeen.has(n)) { - localSeen.add(n); - chain.push(n); - n = n.parentElement; - } - }; - visit(node); - if (node && node.tagName === 'PICTURE') { - const img = node.querySelector('img'); - if (img) visit(img); + const x = \(point.x); + const y = \(flippedY); + const normalize = (raw) => { + if (!raw || typeof raw !== 'string') return ''; + const trimmed = raw.trim(); + if (!trimmed) return ''; + if (trimmed.startsWith('//')) return window.location.protocol + trimmed; + return trimmed; + }; + const firstSrcsetURL = (srcset) => { + if (!srcset || typeof srcset !== 'string') return ''; + const first = srcset.split(',').map((part) => part.trim()).find(Boolean); + if (!first) return ''; + const urlPart = first.split(/\\s+/)[0]; + return normalize(urlPart); + }; + const firstBackgroundURL = (value) => { + if (!value || value === 'none') return ''; + const match = /url\\((['"]?)(.*?)\\1\\)/.exec(value); + if (!match || !match[2]) return ''; + return normalize(match[2]); + }; + const collectChain = (start) => { + const out = []; + const seen = new Set(); + const pushParents = (node) => { + while (node && !seen.has(node)) { + seen.add(node); + out.push(node); + node = node.parentElement; } - return chain; }; - for (const el of walk(start)) { - if (!seen.has(el)) { - seen.add(el); - elChain.push(el); + pushParents(start); + if (start && start.tagName === 'PICTURE' && start.querySelector) { + const img = start.querySelector('img'); + if (img) pushParents(img); + } + return out; + }; + const candidateFromElement = (el) => { + if (!el) return ''; + const attr = (name) => normalize(el.getAttribute ? el.getAttribute(name) : ''); + if (el.tagName === 'IMG') { + const imageCandidates = [ + normalize(el.currentSrc || ''), + attr('src'), + firstSrcsetURL(attr('srcset')), + attr('data-src'), + attr('data-iurl'), + attr('data-lazy-src'), + attr('data-original'), + ]; + const foundImage = imageCandidates.find(Boolean); + if (foundImage) return foundImage; + } + const genericAttrs = [ + 'src', 'data-src', 'data-iurl', 'data-lazy-src', + 'data-original', 'data-image', 'data-image-url', + 'data-thumb', 'data-thumbnail-url', 'content' + ]; + for (const name of genericAttrs) { + const v = attr(name); + if (v) return v; + } + const inlineBg = firstBackgroundURL(el.style && el.style.backgroundImage ? el.style.backgroundImage : ''); + if (inlineBg) return inlineBg; + try { + const computed = window.getComputedStyle(el); + const computedBg = firstBackgroundURL(computed ? computed.backgroundImage : ''); + if (computedBg) return computedBg; + } catch (_) {} + if (el.querySelector) { + const nestedImg = el.querySelector('img[src],img[srcset],img[data-src],img[data-iurl],source[srcset]'); + if (nestedImg) { + const nestedCandidates = [ + normalize(nestedImg.currentSrc || ''), + normalize(nestedImg.getAttribute ? nestedImg.getAttribute('src') : ''), + firstSrcsetURL(nestedImg.getAttribute ? nestedImg.getAttribute('srcset') : ''), + normalize(nestedImg.getAttribute ? (nestedImg.getAttribute('data-src') || nestedImg.getAttribute('data-iurl') || '') : '') + ]; + const foundNested = nestedCandidates.find(Boolean); + if (foundNested) return foundNested; + } + const nestedBg = el.querySelector('[style*="background-image"]'); + if (nestedBg) { + const styleValue = nestedBg.getAttribute ? nestedBg.getAttribute('style') : ''; + const bgURL = firstBackgroundURL(styleValue || ''); + if (bgURL) return bgURL; } } - - for (const el of elChain) { - if (el.tagName === 'IMG') { - if (el.currentSrc) return el.currentSrc; - if (el.src) return el.src; + return ''; + }; + const tryNodes = (nodes) => { + for (const start of nodes) { + for (const el of collectChain(start)) { + const found = candidateFromElement(el); + if (found) return found; } - if (el.tagName === 'PICTURE') { - const img = el.querySelector('img'); - if (img) { - if (img.currentSrc) return img.currentSrc; - if (img.src) return img.src; + if (start && start.shadowRoot && start.shadowRoot.elementFromPoint) { + const inner = start.shadowRoot.elementFromPoint(x, y); + if (inner) { + for (const el of collectChain(inner)) { + const found = candidateFromElement(el); + if (found) return found; + } } } } - } - return ''; + return ''; + }; + const all = document.elementsFromPoint ? document.elementsFromPoint(x, y) : []; + const foundFromAll = tryNodes(all); + if (foundFromAll) return foundFromAll; + const single = document.elementFromPoint ? document.elementFromPoint(x, y) : null; + return candidateFromElement(single) || ''; })(); """ evaluateJavaScript(js) { result, _ in @@ -391,28 +669,69 @@ final class CmuxWebView: WKWebView { let flippedY = bounds.height - point.y let js = """ (() => { - const nodes = document.elementsFromPoint(\(point.x), \(flippedY)); - for (const start of nodes) { - let el = start; - let seen = new Set(); - let cur = (() => { - let n = start; - return n; - })(); - let walk = (node) => { - let chain = []; - while (node && !seen.has(node)) { - seen.add(node); - chain.push(node); - node = node.parentElement; - } - return chain; - }; - for (const n of walk(cur)) { - if (n.tagName === 'A' && n.href) return n.href; + const x = \(point.x); + const y = \(flippedY); + const normalize = (raw) => { + if (!raw || typeof raw !== 'string') return ''; + const trimmed = raw.trim(); + if (!trimmed) return ''; + if (trimmed.startsWith('//')) return window.location.protocol + trimmed; + return trimmed; + }; + const collectChain = (start) => { + const out = []; + const seen = new Set(); + while (start && !seen.has(start)) { + seen.add(start); + out.push(start); + start = start.parentElement; } - } - return ''; + return out; + }; + const linkFromElement = (el) => { + if (!el) return ''; + const attr = (name) => normalize(el.getAttribute ? el.getAttribute(name) : ''); + if (el.closest) { + const closestLink = el.closest('a[href],area[href]'); + if (closestLink && closestLink.href) return normalize(closestLink.href); + } + if ((el.tagName === 'A' || el.tagName === 'AREA') && el.href) { + return normalize(el.href); + } + const attrCandidates = ['href', 'data-href', 'data-url', 'data-link', 'data-link-url']; + for (const name of attrCandidates) { + const v = attr(name); + if (v) return v; + } + if (el.querySelector) { + const nestedLink = el.querySelector('a[href],area[href]'); + if (nestedLink && nestedLink.href) return normalize(nestedLink.href); + } + return ''; + }; + const tryNodes = (nodes) => { + for (const start of nodes) { + for (const node of collectChain(start)) { + const found = linkFromElement(node); + if (found) return found; + } + if (start && start.shadowRoot && start.shadowRoot.elementFromPoint) { + const inner = start.shadowRoot.elementFromPoint(x, y); + if (inner) { + for (const node of collectChain(inner)) { + const found = linkFromElement(node); + if (found) return found; + } + } + } + } + return ''; + }; + const nodes = document.elementsFromPoint ? document.elementsFromPoint(x, y) : []; + const found = tryNodes(nodes); + if (found) return found; + const single = document.elementFromPoint ? document.elementFromPoint(x, y) : null; + return linkFromElement(single) || ''; })(); """ evaluateJavaScript(js) { result, _ in @@ -425,6 +744,49 @@ final class CmuxWebView: WKWebView { } } + private func debugInspectElementsAtPoint(_ point: NSPoint, traceID: String, kind: String) { +#if DEBUG + let flippedY = bounds.height - point.y + let js = """ + (() => { + const clip = (value, max = 180) => { + if (value == null) return ''; + const s = String(value); + return s.length > max ? s.slice(0, max) + '…' : s; + }; + const x = \(point.x); + const y = \(flippedY); + const nodes = document.elementsFromPoint ? document.elementsFromPoint(x, y) : []; + const entries = []; + const limit = Math.min(nodes.length, 8); + for (let i = 0; i < limit; i++) { + const el = nodes[i]; + if (!el) continue; + entries.push({ + tag: clip((el.tagName || '').toLowerCase()), + id: clip(el.id || ''), + cls: clip(typeof el.className === 'string' ? el.className : ''), + href: clip(el.href || ''), + src: clip(el.src || ''), + currentSrc: clip(el.currentSrc || ''), + dataHref: clip(el.getAttribute ? el.getAttribute('data-href') : ''), + dataSrc: clip(el.getAttribute ? el.getAttribute('data-src') : '') + }); + } + return JSON.stringify({count: nodes.length, entries}); + })(); + """ + evaluateJavaScript(js) { [weak self] result, _ in + guard let self, + let payload = result as? String, + !payload.isEmpty else { return } + self.debugContextDownload( + "browser.ctxdl.inspect trace=\(traceID) kind=\(kind) payload=\(payload)" + ) + } +#endif + } + private func resolveContextMenuLinkURL(at point: NSPoint, completion: @escaping (URL?) -> Void) { if let contextMenuLinkURLProvider { contextMenuLinkURLProvider(self, point, completion) @@ -446,16 +808,34 @@ final class CmuxWebView: WKWebView { _ = NSWorkspace.shared.open(url) } - private func runContextMenuFallback(action: Selector?, target: AnyObject?, sender: Any?) { - guard let action else { return } + private func runContextMenuFallback( + action: Selector?, + target: AnyObject?, + sender: Any?, + traceID: String? = nil, + reason: String? = nil + ) { + let trace = traceID ?? "unknown" + guard let action else { + debugContextDownload( + "browser.ctxdl.fallback trace=\(trace) reason=\(reason ?? "none") action=nil target=\(String(describing: target))" + ) + return + } // Guard against accidental self-recursion if fallback gets overwritten. if target === self, action == #selector(contextMenuDownloadImage(_:)) || action == #selector(contextMenuDownloadLinkedFile(_:)) { NSLog("CmuxWebView context fallback skipped (recursive self action)") + debugContextDownload( + "browser.ctxdl.fallback trace=\(trace) reason=\(reason ?? "none") skipped=recursive action=\(Self.selectorName(action))" + ) return } - _ = NSApp.sendAction(action, to: target, from: sender) + let dispatched = NSApp.sendAction(action, to: target, from: sender) + debugContextDownload( + "browser.ctxdl.fallback trace=\(trace) reason=\(reason ?? "none") dispatched=\(dispatched ? 1 : 0) action=\(Self.selectorName(action)) target=\(String(describing: target))" + ) } private func notifyContextMenuDownloadState(_ downloading: Bool) { @@ -473,19 +853,98 @@ final class CmuxWebView: WKWebView { suggestedFilename: String?, sender: Any?, fallbackAction: Selector?, - fallbackTarget: AnyObject? + fallbackTarget: AnyObject?, + traceID: String ) { - guard isDownloadableScheme(url) else { - runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender) + guard isDownloadSupportedScheme(url) else { + debugContextDownload( + "browser.ctxdl.request trace=\(traceID) stage=rejectUnsupportedScheme url=\(url.absoluteString)" + ) + runContextMenuFallback( + action: fallbackAction, + target: fallbackTarget, + sender: sender, + traceID: traceID, + reason: "unsupported_scheme" + ) return } let scheme = url.scheme?.lowercased() ?? "" + debugContextDownload( + "browser.ctxdl.request trace=\(traceID) stage=start scheme=\(scheme) url=\(url.absoluteString)" + ) notifyContextMenuDownloadState(true) + debugContextDownload("browser.ctxdl.state trace=\(traceID) downloading=1") + + if scheme == "data" { + DispatchQueue.main.async { + guard let parsed = Self.parseDataURL(url) else { + self.notifyContextMenuDownloadState(false) + self.debugContextDownload( + "browser.ctxdl.data trace=\(traceID) stage=parseFailure urlLength=\(url.absoluteString.count)" + ) + self.runContextMenuFallback( + action: fallbackAction, + target: fallbackTarget, + sender: sender, + traceID: traceID, + reason: "data_url_parse_error" + ) + return + } + + let saveName = Self.suggestedFilenameForDataURL( + mimeType: parsed.mimeType, + suggestedFilename: suggestedFilename + ) + self.debugContextDownload( + "browser.ctxdl.data trace=\(traceID) stage=parseSuccess mime=\(parsed.mimeType ?? "nil") bytes=\(parsed.data.count)" + ) + + let savePanel = NSSavePanel() + savePanel.nameFieldStringValue = saveName + savePanel.canCreateDirectories = true + savePanel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first + self.notifyContextMenuDownloadState(false) + self.debugContextDownload( + "browser.ctxdl.data trace=\(traceID) stage=savePrompt shown=1 defaultName=\(saveName)" + ) + savePanel.begin { result in + guard result == .OK, let destURL = savePanel.url else { + self.debugContextDownload( + "browser.ctxdl.data trace=\(traceID) stage=savePrompt result=cancel" + ) + return + } + do { + try parsed.data.write(to: destURL, options: .atomic) + self.debugContextDownload( + "browser.ctxdl.data trace=\(traceID) stage=saveSuccess path=\(destURL.path)" + ) + } catch { + self.debugContextDownload( + "browser.ctxdl.data trace=\(traceID) stage=saveFailure error=\(error.localizedDescription)" + ) + self.runContextMenuFallback( + action: fallbackAction, + target: fallbackTarget, + sender: sender, + traceID: traceID, + reason: "data_save_write_error" + ) + } + } + } + return + } if scheme == "file" { DispatchQueue.main.async { do { let data = try Data(contentsOf: url) + self.debugContextDownload( + "browser.ctxdl.file trace=\(traceID) stage=readSuccess bytes=\(data.count) path=\(url.path)" + ) let filename = suggestedFilename?.trimmingCharacters(in: .whitespacesAndNewlines) let saveName = (filename?.isEmpty == false ? filename! : url.lastPathComponent.isEmpty ? "download" : url.lastPathComponent) let savePanel = NSSavePanel() @@ -494,13 +953,39 @@ final class CmuxWebView: WKWebView { savePanel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first // Download is already complete; we're now waiting for user save choice. self.notifyContextMenuDownloadState(false) + self.debugContextDownload( + "browser.ctxdl.file trace=\(traceID) stage=savePrompt shown=1 defaultName=\(saveName)" + ) savePanel.begin { result in - guard result == .OK, let destURL = savePanel.url else { return } - try? data.write(to: destURL, options: .atomic) + guard result == .OK, let destURL = savePanel.url else { + self.debugContextDownload( + "browser.ctxdl.file trace=\(traceID) stage=savePrompt result=cancel" + ) + return + } + do { + try data.write(to: destURL, options: .atomic) + self.debugContextDownload( + "browser.ctxdl.file trace=\(traceID) stage=saveSuccess path=\(destURL.path)" + ) + } catch { + self.debugContextDownload( + "browser.ctxdl.file trace=\(traceID) stage=saveFailure error=\(error.localizedDescription)" + ) + } } } catch { self.notifyContextMenuDownloadState(false) - self.runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender) + self.debugContextDownload( + "browser.ctxdl.file trace=\(traceID) stage=readFailure error=\(error.localizedDescription)" + ) + self.runContextMenuFallback( + action: fallbackAction, + target: fallbackTarget, + sender: sender, + traceID: traceID, + reason: "file_read_error" + ) } } return @@ -520,14 +1005,35 @@ final class CmuxWebView: WKWebView { if let ua = self.customUserAgent, !ua.isEmpty { request.setValue(ua, forHTTPHeaderField: "User-Agent") } + self.debugContextDownload( + "browser.ctxdl.request trace=\(traceID) stage=dispatch method=\(request.httpMethod ?? "GET") cookies=\(cookies.count) referer=\(request.value(forHTTPHeaderField: "Referer") ?? "nil") uaSet=\(request.value(forHTTPHeaderField: "User-Agent") == nil ? 0 : 1)" + ) URLSession.shared.dataTask(with: request) { data, response, error in DispatchQueue.main.async { guard let data, error == nil else { + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + let mime = response?.mimeType ?? "nil" + let hasResponse = response == nil ? 0 : 1 + self.debugContextDownload( + "browser.ctxdl.response trace=\(traceID) stage=failure hasResponse=\(hasResponse) status=\(statusCode) mime=\(mime) error=\(error?.localizedDescription ?? "unknown")" + ) self.notifyContextMenuDownloadState(false) - self.runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender) + self.runContextMenuFallback( + action: fallbackAction, + target: fallbackTarget, + sender: sender, + traceID: traceID, + reason: "network_error" + ) return } + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + let mime = response?.mimeType ?? "nil" + let expectedLength = response?.expectedContentLength ?? -1 + self.debugContextDownload( + "browser.ctxdl.response trace=\(traceID) stage=success hasResponse=1 status=\(statusCode) mime=\(mime) bytes=\(data.count) expected=\(expectedLength)" + ) let filenameCandidate = suggestedFilename ?? response?.suggestedFilename ?? url.lastPathComponent @@ -539,12 +1045,32 @@ final class CmuxWebView: WKWebView { savePanel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first // Download is already complete; we're now waiting for user save choice. self.notifyContextMenuDownloadState(false) + self.debugContextDownload( + "browser.ctxdl.response trace=\(traceID) stage=savePrompt shown=1 defaultName=\(saveName)" + ) savePanel.begin { result in - guard result == .OK, let destURL = savePanel.url else { return } + guard result == .OK, let destURL = savePanel.url else { + self.debugContextDownload( + "browser.ctxdl.response trace=\(traceID) stage=savePrompt result=cancel" + ) + return + } do { try data.write(to: destURL, options: .atomic) + self.debugContextDownload( + "browser.ctxdl.response trace=\(traceID) stage=saveSuccess path=\(destURL.path)" + ) } catch { - self.runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender) + self.debugContextDownload( + "browser.ctxdl.response trace=\(traceID) stage=saveFailure error=\(error.localizedDescription)" + ) + self.runContextMenuFallback( + action: fallbackAction, + target: fallbackTarget, + sender: sender, + traceID: traceID, + reason: "save_write_error" + ) } } } @@ -556,15 +1082,18 @@ final class CmuxWebView: WKWebView { _ url: URL, sender: Any?, fallbackAction: Selector?, - fallbackTarget: AnyObject? + fallbackTarget: AnyObject?, + traceID: String ) { NSLog("CmuxWebView context download start: %@", url.absoluteString) + debugContextDownload("browser.ctxdl.start trace=\(traceID) url=\(url.absoluteString)") downloadURLViaSession( url, suggestedFilename: nil, sender: sender, fallbackAction: fallbackAction, - fallbackTarget: fallbackTarget + fallbackTarget: fallbackTarget, + traceID: traceID ) } @@ -596,10 +1125,14 @@ final class CmuxWebView: WKWebView { override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) { super.willOpenMenu(menu, with: event) lastContextMenuPoint = convert(event.locationInWindow, from: nil) + debugContextDownload( + "browser.ctxdl.menu open itemCount=\(menu.items.count) point=(\(Int(lastContextMenuPoint.x)),\(Int(lastContextMenuPoint.y)))" + ) var openLinkInsertionIndex: Int? var hasDefaultBrowserOpenLinkItem = false for (index, item) in menu.items.enumerated() { + debugLogContextMenuDownloadCandidate(item, index: index) if !hasDefaultBrowserOpenLinkItem, (item.action == #selector(contextMenuOpenLinkInDefaultBrowser(_:)) || item.title == "Open Link in Default Browser") { @@ -620,9 +1153,11 @@ final class CmuxWebView: WKWebView { item.title = "Open Link in New Tab" } - if item.identifier?.rawValue == "WKMenuItemIdentifierDownloadImage" - || item.title == "Download Image" { + if isDownloadImageMenuItem(item) { NSLog("CmuxWebView context menu hook: download image") + debugContextDownload( + "browser.ctxdl.menu hook kind=image index=\(index) id=\(item.identifier?.rawValue ?? "nil") title=\(item.title) action=\(Self.selectorName(item.action))" + ) captureFallbackForMenuItemIfNeeded(item) // Keep global fallback as a secondary safety net. if let box = objc_getAssociatedObject(item, &Self.contextMenuFallbackKey) as? ContextMenuFallbackBox { @@ -636,9 +1171,11 @@ final class CmuxWebView: WKWebView { item.action = #selector(contextMenuDownloadImage(_:)) } - if item.identifier?.rawValue == "WKMenuItemIdentifierDownloadLinkedFile" - || item.title == "Download Linked File" { + if isDownloadLinkedFileMenuItem(item) { NSLog("CmuxWebView context menu hook: download linked file") + debugContextDownload( + "browser.ctxdl.menu hook kind=linked index=\(index) id=\(item.identifier?.rawValue ?? "nil") title=\(item.title) action=\(Self.selectorName(item.action))" + ) captureFallbackForMenuItemIfNeeded(item) // Keep global fallback as a secondary safety net. if let box = objc_getAssociatedObject(item, &Self.contextMenuFallbackKey) as? ContextMenuFallbackBox { @@ -674,80 +1211,152 @@ final class CmuxWebView: WKWebView { } @objc private func contextMenuDownloadImage(_ sender: Any?) { + let traceID = Self.makeContextDownloadTraceID(prefix: "img") let point = lastContextMenuPoint + debugContextDownload( + "browser.ctxdl.click trace=\(traceID) kind=image point=(\(Int(point.x)),\(Int(point.y)))" + ) let fallback = fallbackFromSender( sender, defaultAction: fallbackDownloadImageAction, defaultTarget: fallbackDownloadImageTarget ) + debugContextDownload( + "browser.ctxdl.click trace=\(traceID) fallback action=\(Self.selectorName(fallback.action)) target=\(String(describing: fallback.target))" + ) findImageURLAtPoint(point) { [weak self] url in guard let self else { return } + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image imageURL=\(url?.absoluteString ?? "nil")" + ) + var dataImageURL: URL? if let url { let scheme = url.scheme?.lowercased() ?? "" - if scheme == "http" || scheme == "https" || scheme == "file" { - NSLog("CmuxWebView context download image URL: %@", url.absoluteString) - self.startContextMenuDownload( - url, - sender: sender, - fallbackAction: fallback.action, - fallbackTarget: fallback.target + if scheme == "data" { + dataImageURL = url + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image dataURLDetected length=\(url.absoluteString.count)" + ) + } else if scheme == "http" || scheme == "https" || scheme == "file" { + let normalized = self.normalizedLinkedDownloadURL(url) + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image normalizedImageURL=\(normalized.absoluteString)" + ) + if !self.isLikelyFaviconURL(normalized) && self.isLikelyImageURL(normalized) { + NSLog("CmuxWebView context download image URL: %@ (normalized=%@)", url.absoluteString, normalized.absoluteString) + self.startContextMenuDownload( + normalized, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target, + traceID: traceID + ) + return + } + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image rejectedPrimaryImageURL=\(normalized.absoluteString)" ) - return } } // Google Images and similar sites often expose blob:/data: image URLs. // If image URL is not directly downloadable, fall back to the nearby link URL. self.findLinkURLAtPoint(point) { linkURL in - guard let linkURL else { - NSLog("CmuxWebView context download image: no downloadable image/link URL, using fallback action") - self.runContextMenuFallback( - action: fallback.action, - target: fallback.target, - sender: sender + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image fallbackLinkURL=\(linkURL?.absoluteString ?? "nil")" + ) + if let linkURL { + let normalizedLink = self.normalizedLinkedDownloadURL(linkURL) + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image normalizedFallbackLinkURL=\(normalizedLink.absoluteString)" ) - return + if self.isDownloadableScheme(normalizedLink), + self.isLikelyImageURL(normalizedLink), + !self.isLikelyFaviconURL(normalizedLink) { + NSLog("CmuxWebView context download image fallback to link URL: %@ (normalized=%@)", linkURL.absoluteString, normalizedLink.absoluteString) + self.startContextMenuDownload( + normalizedLink, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target, + traceID: traceID + ) + return + } } - let linkScheme = linkURL.scheme?.lowercased() ?? "" - guard linkScheme == "http" || linkScheme == "https" || linkScheme == "file" else { - NSLog("CmuxWebView context download image: link URL not downloadable (%@), using fallback action", linkURL.absoluteString) - self.runContextMenuFallback( - action: fallback.action, - target: fallback.target, - sender: sender + + if let dataImageURL { + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image fallbackToDataURL=1" + ) + self.startContextMenuDownload( + dataImageURL, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target, + traceID: traceID ) return } - NSLog("CmuxWebView context download image fallback to link URL: %@", linkURL.absoluteString) - self.startContextMenuDownload( - linkURL, + if let linkURL { + NSLog("CmuxWebView context download image: link URL not image-like (%@), using fallback action", linkURL.absoluteString) + self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "image") + self.runContextMenuFallback( + action: fallback.action, + target: fallback.target, + sender: sender, + traceID: traceID, + reason: "fallback_link_not_image" + ) + return + } + + NSLog("CmuxWebView context download image: no downloadable image/link URL, using fallback action") + self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "image") + self.runContextMenuFallback( + action: fallback.action, + target: fallback.target, sender: sender, - fallbackAction: fallback.action, - fallbackTarget: fallback.target + traceID: traceID, + reason: "no_image_or_link_url" ) } } } @objc private func contextMenuDownloadLinkedFile(_ sender: Any?) { + let traceID = Self.makeContextDownloadTraceID(prefix: "lnk") let point = lastContextMenuPoint + debugContextDownload( + "browser.ctxdl.click trace=\(traceID) kind=linked point=(\(Int(point.x)),\(Int(point.y)))" + ) let fallback = fallbackFromSender( sender, defaultAction: fallbackDownloadLinkedFileAction, defaultTarget: fallbackDownloadLinkedFileTarget ) + debugContextDownload( + "browser.ctxdl.click trace=\(traceID) fallback action=\(Self.selectorName(fallback.action)) target=\(String(describing: fallback.target))" + ) findLinkURLAtPoint(point) { [weak self] url in guard let self else { return } + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=linked linkURL=\(url?.absoluteString ?? "nil")" + ) if let url { let normalized = self.normalizedLinkedDownloadURL(url) - if self.isDownloadableScheme(normalized) { + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=linked normalizedLinkURL=\(normalized.absoluteString)" + ) + if self.isDownloadSupportedScheme(normalized) { NSLog("CmuxWebView context download linked file URL: %@ (normalized=%@)", url.absoluteString, normalized.absoluteString) self.startContextMenuDownload( normalized, sender: sender, fallbackAction: fallback.action, - fallbackTarget: fallback.target + fallbackTarget: fallback.target, + traceID: traceID ) return } @@ -755,35 +1364,84 @@ final class CmuxWebView: WKWebView { // Fallback 1: image URL under cursor (useful on image-heavy result pages). self.findImageURLAtPoint(point) { imageURL in + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=linked fallbackImageURL=\(imageURL?.absoluteString ?? "nil")" + ) + var dataImageURL: URL? if let imageURL, self.isDownloadableScheme(imageURL) { NSLog("CmuxWebView context download linked file fallback image URL: %@", imageURL.absoluteString) self.startContextMenuDownload( imageURL, sender: sender, fallbackAction: fallback.action, - fallbackTarget: fallback.target + fallbackTarget: fallback.target, + traceID: traceID ) return } + if let imageURL, self.isDataURLScheme(imageURL) { + dataImageURL = imageURL + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=linked fallbackDataURLDetected length=\(imageURL.absoluteString.count)" + ) + } // Fallback 2: simpler nearest-anchor lookup. self.findLinkAtPoint(point) { fallbackURL in + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=linked nearestAnchorURL=\(fallbackURL?.absoluteString ?? "nil")" + ) guard let fallbackURL else { + if let dataImageURL { + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=linked fallbackToDataURL=1" + ) + self.startContextMenuDownload( + dataImageURL, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target, + traceID: traceID + ) + return + } NSLog("CmuxWebView context download linked file: URL nil, using fallback action") + self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "linked") self.runContextMenuFallback( action: fallback.action, target: fallback.target, - sender: sender + sender: sender, + traceID: traceID, + reason: "no_link_or_image_url" ) return } let normalized = self.normalizedLinkedDownloadURL(fallbackURL) - guard self.isDownloadableScheme(normalized) else { + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=linked normalizedNearestAnchorURL=\(normalized.absoluteString)" + ) + guard self.isDownloadSupportedScheme(normalized) else { + if let dataImageURL { + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=linked fallbackToDataURL=1" + ) + self.startContextMenuDownload( + dataImageURL, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target, + traceID: traceID + ) + return + } NSLog("CmuxWebView context download linked file: unsupported URL %@, using fallback action", fallbackURL.absoluteString) + self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "linked") self.runContextMenuFallback( action: fallback.action, target: fallback.target, - sender: sender + sender: sender, + traceID: traceID, + reason: "nearest_anchor_unsupported_scheme" ) return } @@ -792,7 +1450,8 @@ final class CmuxWebView: WKWebView { normalized, sender: sender, fallbackAction: fallback.action, - fallbackTarget: fallback.target + fallbackTarget: fallback.target, + traceID: traceID ) } } From db86d3e301177840cb422778351257781ff7aa7f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:38:05 -0800 Subject: [PATCH 06/30] Broaden CLI socket telemetry and add restart listener command --- CLI/cmux.swift | 45 ++++--- Sources/AppDelegate.swift | 25 ++++ Sources/ContentView.swift | 11 ++ .../test_claude_hook_missing_socket_error.py | 1 + tests/test_cli_socket_sentry_scope.py | 115 +++++++++++++++++ ..._command_palette_socket_restart_command.py | 118 ++++++++++++++++++ 6 files changed, 296 insertions(+), 19 deletions(-) create mode 100644 tests/test_cli_socket_sentry_scope.py create mode 100644 tests/test_command_palette_socket_restart_command.py diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 44f98629..b14dad7c 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -11,8 +11,8 @@ struct CLIError: Error, CustomStringConvertible { var description: String { message } } -private final class ClaudeHookSentryTelemetry { - private let enabled: Bool +private final class CLISocketSentryTelemetry { + private let command: String private let subcommand: String private let socketPath: String private let envSocketPath: String? @@ -27,13 +27,15 @@ private final class ClaudeHookSentryTelemetry { #endif init(command: String, commandArgs: [String], socketPath: String, processEnv: [String: String]) { - self.enabled = command == "claude-hook" + self.command = command.lowercased() self.subcommand = commandArgs.first?.lowercased() ?? "help" self.socketPath = socketPath self.envSocketPath = processEnv["CMUX_SOCKET_PATH"] self.workspaceId = processEnv["CMUX_WORKSPACE_ID"] self.surfaceId = processEnv["CMUX_SURFACE_ID"] - self.disabledByEnv = processEnv["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] == "1" + self.disabledByEnv = + processEnv["CMUX_CLI_SENTRY_DISABLED"] == "1" || + processEnv["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] == "1" } func breadcrumb(_ message: String, data: [String: Any] = [:]) { @@ -44,7 +46,7 @@ private final class ClaudeHookSentryTelemetry { for (key, value) in data { payload[key] = value } - let crumb = Breadcrumb(level: .info, category: "claude-hook.cli") + let crumb = Breadcrumb(level: .info, category: "cmux.cli") crumb.message = message crumb.data = payload SentrySDK.addBreadcrumb(crumb) @@ -62,22 +64,25 @@ private final class ClaudeHookSentryTelemetry { context[key] = value } let subcommand = self.subcommand + let command = self.command _ = SentrySDK.capture(error: error) { scope in scope.setLevel(.error) scope.setTag(value: "cmux-cli", key: "component") - scope.setTag(value: subcommand, key: "claude_hook_subcommand") - scope.setContext(value: context, key: "claude_hook") + scope.setTag(value: command, key: "cli_command") + scope.setTag(value: subcommand, key: "cli_subcommand") + scope.setContext(value: context, key: "cli_socket") } SentrySDK.flush(timeout: 2.0) #endif } private var shouldEmit: Bool { - enabled && !disabledByEnv + !disabledByEnv } private func baseContext() -> [String: Any] { var context: [String: Any] = [ + "command": command, "subcommand": subcommand, "requested_socket_path": socketPath, "env_socket_path": envSocketPath ?? "" @@ -677,7 +682,7 @@ struct CMUXCLI { let command = args[index] let commandArgs = Array(args[(index + 1)...]) - let hookTelemetry = ClaudeHookSentryTelemetry( + let cliTelemetry = CLISocketSentryTelemetry( command: command, commandArgs: commandArgs, socketPath: socketPath, @@ -698,16 +703,16 @@ struct CMUXCLI { } let client = SocketClient(path: socketPath) - hookTelemetry.breadcrumb( + cliTelemetry.breadcrumb( "socket.connect.attempt", data: ["command": command] ) do { try client.connect() - hookTelemetry.breadcrumb("socket.connect.success") + cliTelemetry.breadcrumb("socket.connect.success") } catch { - hookTelemetry.breadcrumb("socket.connect.failure") - hookTelemetry.captureError(stage: "socket_connect", error: error) + cliTelemetry.breadcrumb("socket.connect.failure") + cliTelemetry.captureError(stage: "socket_connect", error: error) throw error } defer { client.close() } @@ -1294,13 +1299,13 @@ struct CMUXCLI { print(response) case "claude-hook": - hookTelemetry.breadcrumb("claude-hook.dispatch") + cliTelemetry.breadcrumb("claude-hook.dispatch") do { - try runClaudeHook(commandArgs: commandArgs, client: client, telemetry: hookTelemetry) - hookTelemetry.breadcrumb("claude-hook.completed") + try runClaudeHook(commandArgs: commandArgs, client: client, telemetry: cliTelemetry) + cliTelemetry.breadcrumb("claude-hook.completed") } catch { - hookTelemetry.breadcrumb("claude-hook.failure") - hookTelemetry.captureError(stage: "claude_hook_dispatch", error: error) + cliTelemetry.breadcrumb("claude-hook.failure") + cliTelemetry.captureError(stage: "claude_hook_dispatch", error: error) throw error } @@ -4534,7 +4539,7 @@ struct CMUXCLI { private func runClaudeHook( commandArgs: [String], client: SocketClient, - telemetry: ClaudeHookSentryTelemetry + telemetry: CLISocketSentryTelemetry ) throws { let subcommand = commandArgs.first?.lowercased() ?? "help" let hookArgs = Array(commandArgs.dropFirst()) @@ -5319,6 +5324,8 @@ struct CMUXCLI { CMUX_TAB_ID Optional alias used by `tab-action`/`rename-tab` as default --tab. CMUX_SURFACE_ID Auto-set in cmux terminals. Used as default --surface. CMUX_SOCKET_PATH Override the default Unix socket path (/tmp/cmux.sock). + CMUX_CLI_SENTRY_DISABLED + Set to 1 to disable CLI Sentry socket diagnostics. """ } } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 337ad9f3..d9bf44b8 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -3229,6 +3229,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent updateController.attemptUpdate() } + @objc func restartSocketListener(_ sender: Any?) { + guard let tabManager else { + NSSound.beep() + return + } + + let raw = UserDefaults.standard.string(forKey: SocketControlSettings.appStorageKey) + ?? SocketControlSettings.defaultMode.rawValue + let userMode = SocketControlSettings.migrateMode(raw) + let mode = SocketControlSettings.effectiveMode(userMode: userMode) + guard mode != .off else { + TerminalController.shared.stop() + NSSound.beep() + return + } + + let socketPath = SocketControlSettings.socketPath() + sentryBreadcrumb("socket.listener.restart", category: "socket", data: [ + "mode": mode.rawValue, + "path": socketPath + ]) + TerminalController.shared.stop() + TerminalController.shared.start(tabManager: tabManager, socketPath: socketPath, accessMode: mode) + } + private func setupMenuBarExtra() { let store = TerminalNotificationStore.shared menuBarExtraController = MenuBarExtraController( diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 04a6433a..29ffab15 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -3530,6 +3530,14 @@ struct ContentView: View { keywords: ["attempt", "check", "update", "upgrade", "release"] ) ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.restartSocketListener", + title: constant("Restart CLI Listener"), + subtitle: constant("Global"), + keywords: ["restart", "socket", "listener", "cli", "cmux", "control"] + ) + ) contributions.append( CommandPaletteCommandContribution( @@ -3934,6 +3942,9 @@ struct ContentView: View { registry.register(commandId: "palette.attemptUpdate") { AppDelegate.shared?.attemptUpdate(nil) } + registry.register(commandId: "palette.restartSocketListener") { + AppDelegate.shared?.restartSocketListener(nil) + } registry.register(commandId: "palette.renameWorkspace") { beginRenameWorkspaceFlow() diff --git a/tests/test_claude_hook_missing_socket_error.py b/tests/test_claude_hook_missing_socket_error.py index 96357164..d20c7c22 100644 --- a/tests/test_claude_hook_missing_socket_error.py +++ b/tests/test_claude_hook_missing_socket_error.py @@ -47,6 +47,7 @@ def main() -> int: pass env = os.environ.copy() + env["CMUX_CLI_SENTRY_DISABLED"] = "1" env["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" env.pop("CMUX_SOCKET_PATH", None) diff --git a/tests/test_cli_socket_sentry_scope.py b/tests/test_cli_socket_sentry_scope.py new file mode 100644 index 00000000..46deeee3 --- /dev/null +++ b/tests/test_cli_socket_sentry_scope.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Regression test: CLI socket Sentry telemetry must apply to all commands.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def get_repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path.cwd() + + +def require(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle not in content: + failures.append(message) + + +def reject(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle in content: + failures.append(message) + + +def main() -> int: + repo_root = get_repo_root() + cli_path = repo_root / "CLI" / "cmux.swift" + if not cli_path.exists(): + print(f"FAIL: missing expected file: {cli_path}") + return 1 + + content = cli_path.read_text(encoding="utf-8") + failures: list[str] = [] + + require( + content, + "private final class CLISocketSentryTelemetry {", + "Missing CLISocketSentryTelemetry definition", + failures, + ) + require( + content, + 'processEnv["CMUX_CLI_SENTRY_DISABLED"] == "1" ||', + "Missing CMUX_CLI_SENTRY_DISABLED kill switch", + failures, + ) + require( + content, + 'processEnv["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] == "1"', + "Missing backwards-compatible CMUX_CLAUDE_HOOK_SENTRY_DISABLED kill switch", + failures, + ) + require( + content, + "private var shouldEmit: Bool {\n !disabledByEnv\n }", + "Telemetry scope should be command-agnostic (only disabled by env kill switch)", + failures, + ) + require( + content, + 'let crumb = Breadcrumb(level: .info, category: "cmux.cli")', + "Telemetry breadcrumb category should be cmux.cli", + failures, + ) + require( + content, + '"command": command,', + "Base telemetry context must include command name", + failures, + ) + require( + content, + "let cliTelemetry = CLISocketSentryTelemetry(", + "CLI should initialize generic socket telemetry", + failures, + ) + require( + content, + 'cliTelemetry.breadcrumb(\n "socket.connect.attempt",', + "CLI should emit socket.connect.attempt breadcrumb for commands", + failures, + ) + + reject( + content, + "self.enabled = command == \"claude-hook\"", + "Telemetry regressed to claude-hook-only scope", + failures, + ) + reject( + content, + "enabled && !disabledByEnv", + "Telemetry still depends on legacy enabled flag", + failures, + ) + + if failures: + print("FAIL: CLI socket telemetry scope regression(s) detected") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: CLI socket telemetry scope is command-agnostic") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_command_palette_socket_restart_command.py b/tests/test_command_palette_socket_restart_command.py new file mode 100644 index 00000000..6904c5a4 --- /dev/null +++ b/tests/test_command_palette_socket_restart_command.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Regression test for command-palette socket-listener restart command wiring.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def get_repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path.cwd() + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def require(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle not in content: + failures.append(message) + + +def main() -> int: + repo_root = get_repo_root() + content_view_path = repo_root / "Sources" / "ContentView.swift" + app_delegate_path = repo_root / "Sources" / "AppDelegate.swift" + + missing_paths = [ + str(path) + for path in [content_view_path, app_delegate_path] + if not path.exists() + ] + if missing_paths: + print("Missing expected files:") + for path in missing_paths: + print(f" - {path}") + return 1 + + content_view = read_text(content_view_path) + app_delegate = read_text(app_delegate_path) + + failures: list[str] = [] + + require( + content_view, + 'commandId: "palette.restartSocketListener"', + "Missing `palette.restartSocketListener` command contribution", + failures, + ) + require( + content_view, + 'title: constant("Restart CLI Listener")', + "Missing `Restart CLI Listener` command title", + failures, + ) + require( + content_view, + 'registry.register(commandId: "palette.restartSocketListener") {', + "Missing command handler registration for `palette.restartSocketListener`", + failures, + ) + require( + content_view, + "AppDelegate.shared?.restartSocketListener(nil)", + "Socket restart command handler does not call `AppDelegate.restartSocketListener`", + failures, + ) + + require( + app_delegate, + "@objc func restartSocketListener(_ sender: Any?) {", + "Missing `AppDelegate.restartSocketListener` action", + failures, + ) + require( + app_delegate, + "let mode = SocketControlSettings.effectiveMode(userMode: userMode)", + "`restartSocketListener` no longer uses effective socket control mode", + failures, + ) + require( + app_delegate, + "let socketPath = SocketControlSettings.socketPath()", + "`restartSocketListener` no longer uses configured socket path", + failures, + ) + require( + app_delegate, + "TerminalController.shared.stop()", + "`restartSocketListener` no longer stops current listener before restart", + failures, + ) + require( + app_delegate, + "TerminalController.shared.start(tabManager: tabManager, socketPath: socketPath, accessMode: mode)", + "`restartSocketListener` no longer starts listener with current settings", + failures, + ) + + if failures: + print("FAIL: command-palette socket restart command regression(s) detected") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: command-palette socket restart command wiring is intact") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 513e9aa607d6261174ea13c1bf8d6b2fddbe302d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:38:07 -0800 Subject: [PATCH 07/30] feat(sidebar): add markdown blocks, provider labels, and fine-grained toggles --- Sources/ContentView.swift | 186 ++++++++++++++++++++------- Sources/TerminalController.swift | 210 +++++++++++++++++++++++++++++-- Sources/Workspace.swift | 51 ++++++-- Sources/cmuxApp.swift | 56 ++++++++- tests/cmux.py | 57 +++++++++ tests/test_sidebar_meta_block.py | 100 +++++++++++++++ tests/test_sidebar_pr.py | 10 +- 7 files changed, 599 insertions(+), 71 deletions(-) create mode 100644 tests/test_sidebar_meta_block.py diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 41893bb6..577afc83 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2480,6 +2480,7 @@ private struct TabItemView: View { @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints @AppStorage("sidebarShowGitBranch") private var sidebarShowGitBranch = true @AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout + @AppStorage("sidebarShowBranchDirectory") private var sidebarShowBranchDirectory = true @AppStorage("sidebarShowGitBranchIcon") private var sidebarShowGitBranchIcon = false @AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true @AppStorage("sidebarShowPorts") private var sidebarShowPorts = true @@ -2667,6 +2668,7 @@ private struct TabItemView: View { if sidebarShowMetadata { let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder() + let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder() if !metadataEntries.isEmpty { SidebarMetadataRows( entries: metadataEntries, @@ -2675,6 +2677,14 @@ private struct TabItemView: View { ) .transition(.opacity.combined(with: .move(edge: .top))) } + if !metadataBlocks.isEmpty { + SidebarMetadataMarkdownBlocks( + blocks: metadataBlocks, + isActive: usesInvertedActiveForeground, + onFocus: { updateSelection() } + ) + .transition(.opacity.combined(with: .move(edge: .top))) + } } // Latest log entry @@ -2717,54 +2727,56 @@ private struct TabItemView: View { } // Branch + directory row - if sidebarBranchVerticalLayout { - if !verticalBranchDirectoryLines.isEmpty { - HStack(alignment: .top, spacing: 3) { - if sidebarShowGitBranchIcon, sidebarShowGitBranch, verticalRowsContainBranch { - Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9)) - .foregroundColor(activeSecondaryColor(0.6)) - } - VStack(alignment: .leading, spacing: 1) { - ForEach(Array(verticalBranchDirectoryLines.enumerated()), id: \.offset) { _, line in - HStack(spacing: 3) { - if let branch = line.branch { - Text(branch) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.75)) - .lineLimit(1) - .truncationMode(.tail) - } - if line.branch != nil, line.directory != nil { - Image(systemName: "circle.fill") - .font(.system(size: 3)) - .foregroundColor(activeSecondaryColor(0.6)) - .padding(.horizontal, 1) - } - if let directory = line.directory { - Text(directory) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.75)) - .lineLimit(1) - .truncationMode(.tail) + if sidebarShowBranchDirectory { + if sidebarBranchVerticalLayout { + if !verticalBranchDirectoryLines.isEmpty { + HStack(alignment: .top, spacing: 3) { + if sidebarShowGitBranchIcon, sidebarShowGitBranch, verticalRowsContainBranch { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 9)) + .foregroundColor(activeSecondaryColor(0.6)) + } + VStack(alignment: .leading, spacing: 1) { + ForEach(Array(verticalBranchDirectoryLines.enumerated()), id: \.offset) { _, line in + HStack(spacing: 3) { + if let branch = line.branch { + Text(branch) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.75)) + .lineLimit(1) + .truncationMode(.tail) + } + if line.branch != nil, line.directory != nil { + Image(systemName: "circle.fill") + .font(.system(size: 3)) + .foregroundColor(activeSecondaryColor(0.6)) + .padding(.horizontal, 1) + } + if let directory = line.directory { + Text(directory) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.75)) + .lineLimit(1) + .truncationMode(.tail) + } } } } } } - } - } else if let dirRow = branchDirectoryRow { - HStack(spacing: 3) { - if sidebarShowGitBranch && gitBranchSummaryText != nil && sidebarShowGitBranchIcon { - Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9)) - .foregroundColor(activeSecondaryColor(0.6)) + } else if let dirRow = branchDirectoryRow { + HStack(spacing: 3) { + if sidebarShowGitBranch && gitBranchSummaryText != nil && sidebarShowGitBranchIcon { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 9)) + .foregroundColor(activeSecondaryColor(0.6)) + } + Text(dirRow) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.75)) + .lineLimit(1) + .truncationMode(.tail) } - Text(dirRow) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.75)) - .lineLimit(1) - .truncationMode(.tail) } } @@ -2779,7 +2791,7 @@ private struct TabItemView: View { status: pullRequest.status, color: pullRequestForegroundColor ) - Text("PR #\(pullRequest.number)") + Text("\(pullRequest.label) #\(pullRequest.number)") .underline() Text(pullRequestStatusLabel(pullRequest.status)) if pullRequest.extraCount > 0 { @@ -2792,7 +2804,7 @@ private struct TabItemView: View { .foregroundColor(pullRequestForegroundColor) } .buttonStyle(.plain) - .help("Open Pull Request #\(pullRequest.number)") + .help("Open \(pullRequest.label) #\(pullRequest.number)") } // Ports row @@ -2806,6 +2818,7 @@ private struct TabItemView: View { } .animation(.easeInOut(duration: 0.2), value: tab.logEntries.count) .animation(.easeInOut(duration: 0.2), value: tab.progress != nil) + .animation(.easeInOut(duration: 0.2), value: tab.metadataBlocks.count) .padding(.horizontal, 10) .padding(.vertical, 8) .background( @@ -3300,6 +3313,7 @@ private struct TabItemView: View { private struct PullRequestDisplay { let number: Int + let label: String let url: URL let status: SidebarPullRequestStatus let extraCount: Int @@ -3310,6 +3324,7 @@ private struct TabItemView: View { guard let first = pullRequests.first else { return nil } return PullRequestDisplay( number: first.number, + label: first.label, url: first.url, status: first.status, extraCount: max(0, pullRequests.count - 1) @@ -3675,6 +3690,89 @@ private struct SidebarMetadataEntryRow: View { } } +private struct SidebarMetadataMarkdownBlocks: View { + let blocks: [SidebarMetadataBlock] + let isActive: Bool + let onFocus: () -> Void + + @State private var isExpanded: Bool = false + private let collapsedBlockLimit = 1 + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + ForEach(visibleBlocks, id: \.key) { block in + SidebarMetadataMarkdownBlockRow( + block: block, + isActive: isActive, + onFocus: onFocus + ) + } + + if shouldShowToggle { + Button(isExpanded ? "Show less details" : "Show more details") { + onFocus() + withAnimation(.easeInOut(duration: 0.15)) { + isExpanded.toggle() + } + } + .buttonStyle(.plain) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(isActive ? .white.opacity(0.65) : .secondary.opacity(0.9)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + private var visibleBlocks: [SidebarMetadataBlock] { + guard !isExpanded, blocks.count > collapsedBlockLimit else { return blocks } + return Array(blocks.prefix(collapsedBlockLimit)) + } + + private var shouldShowToggle: Bool { + blocks.count > collapsedBlockLimit + } +} + +private struct SidebarMetadataMarkdownBlockRow: View { + let block: SidebarMetadataBlock + let isActive: Bool + let onFocus: () -> Void + + @State private var renderedMarkdown: AttributedString? + + var body: some View { + Group { + if let renderedMarkdown { + Text(renderedMarkdown) + .foregroundColor(foregroundColor) + } else { + Text(block.markdown) + .foregroundColor(foregroundColor) + } + } + .font(.system(size: 10)) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .contentShape(Rectangle()) + .onTapGesture { onFocus() } + .onAppear(perform: renderMarkdown) + .onChange(of: block.markdown) { _ in + renderMarkdown() + } + } + + private var foregroundColor: Color { + isActive ? .white.opacity(0.8) : .secondary + } + + private func renderMarkdown() { + renderedMarkdown = try? AttributedString( + markdown: block.markdown, + options: .init(interpretedSyntax: .full) + ) + } +} + enum SidebarDropEdge { case top case bottom diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 78f4e88a..b18d12ed 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -180,6 +180,16 @@ class TerminalController { current.format != format } + nonisolated static func shouldReplaceMetadataBlock( + current: SidebarMetadataBlock?, + key: String, + markdown: String, + priority: Int + ) -> Bool { + guard let current else { return true } + return current.key != key || current.markdown != markdown || current.priority != priority + } + nonisolated static func shouldReplaceProgress( current: SidebarProgressState?, value: Double, @@ -201,11 +211,12 @@ class TerminalController { nonisolated static func shouldReplacePullRequest( current: SidebarPullRequestState?, number: Int, + label: String, url: URL, status: SidebarPullRequestStatus ) -> Bool { guard let current else { return true } - return current.number != number || current.url != url || current.status != status + return current.number != number || current.label != label || current.url != url || current.status != status } nonisolated static func shouldReplacePorts(current: [Int]?, next: [Int]) -> Bool { @@ -728,18 +739,27 @@ class TerminalController { case "report_meta": return reportMeta(args) + case "report_meta_block": + return reportMetaBlock(args) + case "clear_status": return clearStatus(args) case "clear_meta": return clearMeta(args) + case "clear_meta_block": + return clearMetaBlock(args) + case "list_status": return listStatus(args) case "list_meta": return listMeta(args) + case "list_meta_blocks": + return listMetaBlocks(args) + case "log": return appendLog(args) @@ -764,6 +784,9 @@ class TerminalController { case "report_pr": return reportPullRequest(args) + case "report_review": + return reportPullRequest(args) + case "clear_pr": return clearPullRequest(args) @@ -7905,10 +7928,13 @@ class TerminalController { simulate_app_active - Trigger app active handler set_status [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] - Set a status entry report_meta [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] - Set sidebar metadata entry + report_meta_block [--priority=N] [--tab=X] -- - Set freeform sidebar markdown block clear_status [--tab=X] - Remove a status entry clear_meta [--tab=X] - Remove sidebar metadata entry + clear_meta_block [--tab=X] - Remove sidebar markdown block list_status [--tab=X] - List all status entries list_meta [--tab=X] - List sidebar metadata entries + list_meta_blocks [--tab=X] - List sidebar markdown blocks log [--level=X] [--source=X] [--tab=X] -- - Append a log entry clear_log [--tab=X] - Clear log entries list_log [--limit=N] [--tab=X] - List log entries @@ -7916,7 +7942,8 @@ class TerminalController { clear_progress [--tab=X] - Clear progress bar report_git_branch [--status=dirty] [--tab=X] [--panel=Y] - Report git branch clear_git_branch [--tab=X] [--panel=Y] - Clear git branch - report_pr [--state=open|merged|closed] [--tab=X] [--panel=Y] - Report pull request + report_pr [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y] - Report pull request / review item + report_review [--label=MR] [--state=open|merged|closed] [--tab=X] [--panel=Y] - Alias for provider-specific review item clear_pr [--tab=X] [--panel=Y] - Clear pull request report_ports [port2...] [--tab=X] [--panel=Y] - Report listening ports report_tty [--tab=X] [--panel=Y] - Register TTY for batched port scanning @@ -10847,6 +10874,33 @@ class TerminalController { return tabManager.tabs.first(where: { $0.id == selectedId }) } + private func resolveTabIdForSidebarMutation( + reportArgs: String, + options: [String: String] + ) -> (tabId: UUID?, error: String?) { + var tabId: UUID? + DispatchQueue.main.sync { + if let tab = resolveTabForReport(reportArgs) { + tabId = tab.id + } + } + if let tabId { + return (tabId, nil) + } + let error = options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" + return (nil, error) + } + + private func tabForSidebarMutation(id: UUID) -> Tab? { + if let tab = tabManager?.tabs.first(where: { $0.id == id }) { + return tab + } + if let otherManager = AppDelegate.shared?.tabManagerFor(tabId: id) { + return otherManager.tabs.first(where: { $0.id == id }) + } + return nil + } + private func parseSidebarMetadataFormat(_ raw: String) -> SidebarMetadataFormat? { switch raw.lowercased() { case "plain": @@ -10901,12 +10955,13 @@ class TerminalController { parsedURL = nil } - var result = "OK" - DispatchQueue.main.sync { - guard let tab = resolveTabForReport(args) else { - result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" - return - } + let tabResolution = resolveTabIdForSidebarMutation(reportArgs: args, options: parsed.options) + guard let targetTabId = tabResolution.tabId else { + return tabResolution.error ?? "ERROR: No tab selected" + } + + DispatchQueue.main.async { [weak self] in + guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return } guard Self.shouldReplaceStatusEntry( current: tab.statusEntries[key], key: key, @@ -10930,7 +10985,7 @@ class TerminalController { timestamp: Date() ) } - return result + return "OK" } private func clearSidebarMetadata(_ args: String, usage: String) -> String { @@ -11009,6 +11064,115 @@ class TerminalController { listSidebarMetadata(args, emptyMessage: "No metadata entries") } + private func splitMetadataBlockArgs(_ args: String) -> (optionsPart: String, markdownPart: String?) { + guard let separatorRange = args.range(of: " -- ") else { + return (args, nil) + } + let optionsPart = String(args[.. String { + var line = "\(block.key)=\(block.markdown.replacingOccurrences(of: "\n", with: "\\n"))" + if block.priority != 0 { line += " priority=\(block.priority)" } + return line + } + + private func reportMetaBlock(_ args: String) -> String { + guard tabManager != nil else { return "ERROR: TabManager not available" } + + let parts = splitMetadataBlockArgs(args) + let parsed = parseOptionsNoStop(parts.optionsPart) + guard let key = parsed.positional.first, !key.isEmpty else { + return "ERROR: Missing metadata block key — usage: report_meta_block [--priority=N] [--tab=X] -- " + } + + let markdown: String + if let raw = parts.markdownPart { + markdown = raw + } else if parsed.positional.count >= 2 { + markdown = parsed.positional.dropFirst().joined(separator: " ") + } else { + return "ERROR: Missing metadata markdown — usage: report_meta_block [--priority=N] [--tab=X] -- " + } + + let trimmedMarkdown = markdown.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedMarkdown.isEmpty else { + return "ERROR: Missing metadata markdown — usage: report_meta_block [--priority=N] [--tab=X] -- " + } + + let priority: Int + if let rawPriority = normalizedOptionValue(parsed.options["priority"]) { + guard let parsedPriority = Int(rawPriority) else { + return "ERROR: Invalid metadata block priority '\(rawPriority)' — must be an integer" + } + priority = max(-9999, min(9999, parsedPriority)) + } else { + priority = 0 + } + + let tabResolution = resolveTabIdForSidebarMutation(reportArgs: parts.optionsPart, options: parsed.options) + guard let targetTabId = tabResolution.tabId else { + return tabResolution.error ?? "ERROR: No tab selected" + } + + DispatchQueue.main.async { [weak self] in + guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return } + guard Self.shouldReplaceMetadataBlock( + current: tab.metadataBlocks[key], + key: key, + markdown: markdown, + priority: priority + ) else { + return + } + tab.metadataBlocks[key] = SidebarMetadataBlock( + key: key, + markdown: markdown, + priority: priority, + timestamp: Date() + ) + } + return "OK" + } + + private func clearMetaBlock(_ args: String) -> String { + let parsed = parseOptions(args) + guard let key = parsed.positional.first, parsed.positional.count == 1 else { + return "ERROR: Missing metadata block key — usage: clear_meta_block [--tab=X]" + } + + var result = "OK" + DispatchQueue.main.sync { + guard let tab = resolveTabForReport(args) else { + result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" + return + } + if tab.metadataBlocks.removeValue(forKey: key) == nil { + result = "OK (key not found)" + } + } + return result + } + + private func listMetaBlocks(_ args: String) -> String { + var result = "" + DispatchQueue.main.sync { + guard let tab = resolveTabForReport(args) else { + result = "ERROR: Tab not found" + return + } + let blocks = tab.sidebarMetadataBlocksInDisplayOrder() + if blocks.isEmpty { + result = "No metadata blocks" + return + } + result = blocks.map(sidebarMetadataBlockLine).joined(separator: "\n") + } + return result + } + private func appendLog(_ args: String) -> String { let parsed = parseOptions(args) guard !parsed.positional.isEmpty else { @@ -11218,7 +11382,7 @@ class TerminalController { private func reportPullRequest(_ args: String) -> String { let parsed = parseOptions(args) guard parsed.positional.count >= 2 else { - return "ERROR: Missing pull request number or URL — usage: report_pr [--state=open|merged|closed] [--tab=X] [--panel=Y]" + return "ERROR: Missing pull request number or URL — usage: report_pr [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]" } let rawNumber = parsed.positional[0].trimmingCharacters(in: .whitespacesAndNewlines) @@ -11239,6 +11403,12 @@ class TerminalController { return "ERROR: Invalid pull request state '\(statusRaw)' — use: open, merged, closed" } + let labelRaw = normalizedOptionValue(parsed.options["label"]) ?? "PR" + guard !labelRaw.isEmpty else { + return "ERROR: Invalid review label — usage: report_pr [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]" + } + let label = String(labelRaw.prefix(16)) + var result = "OK" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { @@ -11252,7 +11422,7 @@ class TerminalController { let surfaceId: UUID if let panelArg { if panelArg.isEmpty { - result = "ERROR: Missing panel id — usage: report_pr [--state=open|merged|closed] [--tab=X] [--panel=Y]" + result = "ERROR: Missing panel id — usage: report_pr [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]" return } guard let parsedId = UUID(uuidString: panelArg) else { @@ -11276,13 +11446,20 @@ class TerminalController { guard Self.shouldReplacePullRequest( current: tab.panelPullRequests[surfaceId], number: number, + label: label, url: url, status: status ) else { return } - tab.updatePanelPullRequest(panelId: surfaceId, number: number, url: url, status: status) + tab.updatePanelPullRequest( + panelId: surfaceId, + number: number, + label: label, + url: url, + status: status + ) } return result } @@ -11621,8 +11798,10 @@ class TerminalController { if let pr = tab.pullRequest { lines.append("pr=#\(pr.number) \(pr.status.rawValue) \(pr.url.absoluteString)") + lines.append("pr_label=\(pr.label)") } else { lines.append("pr=none") + lines.append("pr_label=none") } if tab.listeningPorts.isEmpty { @@ -11644,6 +11823,12 @@ class TerminalController { lines.append(" \(sidebarMetadataLine(entry))") } + let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder() + lines.append("meta_block_count=\(metadataBlocks.count)") + for block in metadataBlocks { + lines.append(" \(sidebarMetadataBlockLine(block))") + } + lines.append("log_count=\(tab.logEntries.count)") for entry in tab.logEntries.suffix(5) { lines.append(" [\(entry.level.rawValue)] \(entry.message)") @@ -11670,6 +11855,7 @@ class TerminalController { tab.panelPullRequests.removeAll() tab.surfaceListeningPorts.removeAll() tab.listeningPorts.removeAll() + tab.metadataBlocks.removeAll() } return result } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index fe49ccb5..7b8c38d3 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -15,6 +15,13 @@ struct SidebarStatusEntry { let timestamp: Date } +struct SidebarMetadataBlock { + let key: String + let markdown: String + let priority: Int + let timestamp: Date +} + enum SidebarMetadataFormat: String { case plain case markdown @@ -53,6 +60,7 @@ enum SidebarPullRequestStatus: String { struct SidebarPullRequestState: Equatable { let number: Int + let label: String let url: URL let status: SidebarPullRequestStatus } @@ -150,28 +158,32 @@ enum SidebarBranchOrdering { } } - var orderedNumbers: [Int] = [] - var pullRequestsByNumber: [Int: SidebarPullRequestState] = [:] + func reviewKey(for state: SidebarPullRequestState) -> String { + "\(state.label.lowercased())#\(state.number)" + } + + var orderedKeys: [String] = [] + var pullRequestsByKey: [String: SidebarPullRequestState] = [:] for panelId in orderedPanelIds { guard let state = panelPullRequests[panelId] else { continue } - let number = state.number - if pullRequestsByNumber[number] == nil { - orderedNumbers.append(number) - pullRequestsByNumber[number] = state + let key = reviewKey(for: state) + if pullRequestsByKey[key] == nil { + orderedKeys.append(key) + pullRequestsByKey[key] = state continue } - guard let existing = pullRequestsByNumber[number] else { continue } + guard let existing = pullRequestsByKey[key] else { continue } if statusPriority(state.status) > statusPriority(existing.status) { - pullRequestsByNumber[number] = state + pullRequestsByKey[key] = state } } - if orderedNumbers.isEmpty, let fallbackPullRequest { + if orderedKeys.isEmpty, let fallbackPullRequest { return [fallbackPullRequest] } - return orderedNumbers.compactMap { pullRequestsByNumber[$0] } + return orderedKeys.compactMap { pullRequestsByKey[$0] } } static func orderedUniqueBranchDirectoryEntries( @@ -353,6 +365,7 @@ final class Workspace: Identifiable, ObservableObject { nonisolated private static let manualUnreadFocusGraceInterval: TimeInterval = 0.2 nonisolated private static let manualUnreadClearDelayAfterFocusFlash: TimeInterval = 0.2 @Published var statusEntries: [String: SidebarStatusEntry] = [:] + @Published var metadataBlocks: [String: SidebarMetadataBlock] = [:] @Published var logEntries: [SidebarLogEntry] = [] @Published var progress: SidebarProgressState? @Published var gitBranch: SidebarGitBranchState? @@ -862,8 +875,14 @@ final class Workspace: Identifiable, ObservableObject { } } - func updatePanelPullRequest(panelId: UUID, number: Int, url: URL, status: SidebarPullRequestStatus) { - let state = SidebarPullRequestState(number: number, url: url, status: status) + func updatePanelPullRequest( + panelId: UUID, + number: Int, + label: String, + url: URL, + status: SidebarPullRequestStatus + ) { + let state = SidebarPullRequestState(number: number, label: label, url: url, status: status) let existing = panelPullRequests[panelId] if existing != state { panelPullRequests[panelId] = state @@ -995,6 +1014,14 @@ final class Workspace: Identifiable, ObservableObject { } } + func sidebarMetadataBlocksInDisplayOrder() -> [SidebarMetadataBlock] { + metadataBlocks.values.sorted { lhs, rhs in + if lhs.priority != rhs.priority { return lhs.priority > rhs.priority } + if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } + return lhs.key < rhs.key + } + } + // MARK: - Panel Operations /// Create a new split with a terminal panel diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 20d85821..d71ae4de 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2568,7 +2568,11 @@ struct SettingsView: View { @AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue + @AppStorage("sidebarShowBranchDirectory") private var sidebarShowBranchDirectory = true @AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true + @AppStorage("sidebarShowPorts") private var sidebarShowPorts = true + @AppStorage("sidebarShowLog") private var sidebarShowLog = true + @AppStorage("sidebarShowProgress") private var sidebarShowProgress = true @AppStorage("sidebarShowStatusPills") private var sidebarShowMetadata = true @State private var shortcutResetToken = UUID() @State private var topBlurOpacity: Double = 0 @@ -2777,9 +2781,20 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + "Show Branch + Directory in Sidebar", + subtitle: "Display the built-in git branch and working-directory row." + ) { + Toggle("", isOn: $sidebarShowBranchDirectory) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + SettingsCardRow( "Show Pull Requests in Sidebar", - subtitle: "Display PR status, number, and a clickable link when available." + subtitle: "Display review items (PR/MR/etc.) with status, number, and clickable link." ) { Toggle("", isOn: $sidebarShowPullRequest) .labelsHidden() @@ -2788,9 +2803,42 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + "Show Listening Ports in Sidebar", + subtitle: "Display detected listening ports for the active workspace." + ) { + Toggle("", isOn: $sidebarShowPorts) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsCardRow( + "Show Latest Log in Sidebar", + subtitle: "Display the latest imperative log/status message." + ) { + Toggle("", isOn: $sidebarShowLog) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsCardRow( + "Show Progress in Sidebar", + subtitle: "Display the built-in progress bar from set_progress." + ) { + Toggle("", isOn: $sidebarShowProgress) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + SettingsCardRow( "Show Custom Metadata in Sidebar", - subtitle: "Display metadata rows from report_meta/set_status, including icons and optional links." + subtitle: "Display custom metadata from report_meta/set_status and report_meta_block." ) { Toggle("", isOn: $sidebarShowMetadata) .labelsHidden() @@ -3323,7 +3371,11 @@ struct SettingsView: View { workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue + sidebarShowBranchDirectory = true sidebarShowPullRequest = true + sidebarShowPorts = true + sidebarShowLog = true + sidebarShowProgress = true sidebarShowMetadata = true showOpenAccessConfirmation = false pendingOpenAccessMode = nil diff --git a/tests/cmux.py b/tests/cmux.py index 2c022319..c4f95904 100755 --- a/tests/cmux.py +++ b/tests/cmux.py @@ -589,6 +589,37 @@ class cmux: raise cmuxError(response) return response + def report_meta_block(self, key: str, markdown: str, priority: int = None, tab: str = None) -> None: + """Report a freeform sidebar markdown metadata block.""" + cmd = f"report_meta_block {key}" + if priority is not None: + cmd += f" --priority={priority}" + if tab: + cmd += f" --tab={tab}" + cmd += f" -- {_quote_option_value(markdown)}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + + def clear_meta_block(self, key: str, tab: str = None) -> None: + """Remove a sidebar markdown metadata block.""" + cmd = f"clear_meta_block {key}" + if tab: + cmd += f" --tab={tab}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + + def list_meta_blocks(self, tab: str = None) -> str: + """List sidebar markdown metadata blocks.""" + cmd = "list_meta_blocks" + if tab: + cmd += f" --tab={tab}" + response = self._send_command(cmd) + if response.startswith("ERROR"): + raise cmuxError(response) + return response + def log(self, message: str, level: str = None, source: str = None, tab: str = None) -> None: """Append a sidebar log entry.""" # TerminalController.parseOptions treats any --* token as an option until @@ -641,12 +672,38 @@ class cmux: self, number: int, url: str, + label: str = None, state: str = None, tab: str = None, panel: str = None, ) -> None: """Report pull-request metadata for sidebar display.""" cmd = f"report_pr {number} {url}" + if label: + cmd += f" --label={_quote_option_value(label)}" + if state: + cmd += f" --state={state}" + if tab: + cmd += f" --tab={tab}" + if panel: + cmd += f" --panel={panel}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + + def report_review( + self, + number: int, + url: str, + label: str = None, + state: str = None, + tab: str = None, + panel: str = None, + ) -> None: + """Report provider-specific review metadata (GitLab MR, Bitbucket PR, etc.).""" + cmd = f"report_review {number} {url}" + if label: + cmd += f" --label={_quote_option_value(label)}" if state: cmd += f" --state={state}" if tab: diff --git a/tests/test_sidebar_meta_block.py b/tests/test_sidebar_meta_block.py new file mode 100644 index 00000000..1ca6ade1 --- /dev/null +++ b/tests/test_sidebar_meta_block.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +""" +End-to-end test for sidebar markdown metadata block commands. + +Validates: +1) report_meta_block stores markdown payload and priority +2) metadata block list ordering follows priority +3) clear_meta_block removes block metadata +""" + +from __future__ import annotations + +import os +import sys +import time + +# Add the directory containing cmux.py to the path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from cmux import cmux, cmuxError # noqa: E402 + + +def _parse_sidebar_state(text: str) -> dict[str, str]: + data: dict[str, str] = {} + for raw in (text or "").splitlines(): + line = raw.rstrip("\n") + if not line or line.startswith(" "): + continue + if "=" not in line: + continue + k, v = line.split("=", 1) + data[k.strip()] = v.strip() + return data + + +def _wait_for_state_field( + client: cmux, + key: str, + expected: str, + timeout: float = 8.0, + interval: float = 0.1, +) -> dict[str, str]: + start = time.time() + while time.time() - start < timeout: + state = _parse_sidebar_state(client.sidebar_state()) + if state.get(key) == expected: + return state + time.sleep(interval) + raise AssertionError(f"Timed out waiting for {key}={expected!r}") + + +def main() -> int: + tag = os.environ.get("CMUX_TAG") or "" + if not tag: + print("Tip: set CMUX_TAG= when running this test to avoid socket conflicts.") + + try: + with cmux() as client: + new_tab_id = client.new_tab() + client.select_tab(new_tab_id) + time.sleep(0.6) + + tab_id = client.current_workspace() + + summary_md = "### Agent\\n- status: in progress\\n- pr: #337" + footer_md = "_last update: now_" + + client.report_meta_block("summary", summary_md, priority=50, tab=tab_id) + client.report_meta_block("footer", footer_md, priority=10, tab=tab_id) + _wait_for_state_field(client, "meta_block_count", "2") + + listed = client.list_meta_blocks(tab=tab_id).splitlines() + if len(listed) != 2: + raise AssertionError(f"Expected 2 metadata blocks, got {len(listed)}: {listed}") + if not listed[0].startswith("summary="): + raise AssertionError(f"Expected highest-priority block first. Got: {listed[0]}") + if "priority=50" not in listed[0]: + raise AssertionError(f"Expected summary block priority in listing. Got: {listed[0]}") + + client.clear_meta_block("summary", tab=tab_id) + _wait_for_state_field(client, "meta_block_count", "1") + + listed = client.list_meta_blocks(tab=tab_id).splitlines() + if any(line.startswith("summary=") for line in listed): + raise AssertionError(f"Summary block should be cleared. Got: {listed}") + + try: + client.close_tab(new_tab_id) + except Exception: + pass + + print("Sidebar markdown metadata block test passed.") + return 0 + except (cmuxError, AssertionError) as e: + print(f"Sidebar markdown metadata block test failed: {e}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_sidebar_pr.py b/tests/test_sidebar_pr.py index e6c9cbfd..39645aaa 100644 --- a/tests/test_sidebar_pr.py +++ b/tests/test_sidebar_pr.py @@ -5,7 +5,8 @@ End-to-end test for sidebar pull-request metadata. Validates: 1) report_pr writes sidebar PR state 2) state transition open -> merged is reflected -3) clear_pr removes PR metadata +3) provider labels can be set via report_review/report_pr --label +4) clear_pr removes PR metadata """ from __future__ import annotations @@ -71,12 +72,19 @@ def main() -> int: client.report_pr(pr_number, pr_url, state="open", tab=tab_id, panel=panel_id) _wait_for_state_field(client, "pr", f"#{pr_number} open {pr_url}") + _wait_for_state_field(client, "pr_label", "PR") + + client.report_review(pr_number, pr_url, label="MR", state="open", tab=tab_id, panel=panel_id) + _wait_for_state_field(client, "pr", f"#{pr_number} open {pr_url}") + _wait_for_state_field(client, "pr_label", "MR") client.report_pr(pr_number, pr_url, state="merged", tab=tab_id, panel=panel_id) _wait_for_state_field(client, "pr", f"#{pr_number} merged {pr_url}") + _wait_for_state_field(client, "pr_label", "PR") client.clear_pr(tab=tab_id, panel=panel_id) _wait_for_state_field(client, "pr", "none") + _wait_for_state_field(client, "pr_label", "none") try: client.close_tab(new_tab_id) From e4f351027c1f688df25c8cba3e8eab9726b61a35 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:39:52 -0800 Subject: [PATCH 08/30] fix(sidebar): normalize escaped newlines in markdown blocks --- Sources/TerminalController.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index b18d12ed..89348db2 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -11097,7 +11097,12 @@ class TerminalController { return "ERROR: Missing metadata markdown — usage: report_meta_block [--priority=N] [--tab=X] -- " } - let trimmedMarkdown = markdown.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedMarkdown = markdown + .replacingOccurrences(of: "\\r\\n", with: "\n") + .replacingOccurrences(of: "\\n", with: "\n") + .replacingOccurrences(of: "\\t", with: "\t") + + let trimmedMarkdown = normalizedMarkdown.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedMarkdown.isEmpty else { return "ERROR: Missing metadata markdown — usage: report_meta_block [--priority=N] [--tab=X] -- " } @@ -11122,14 +11127,14 @@ class TerminalController { guard Self.shouldReplaceMetadataBlock( current: tab.metadataBlocks[key], key: key, - markdown: markdown, + markdown: normalizedMarkdown, priority: priority ) else { return } tab.metadataBlocks[key] = SidebarMetadataBlock( key: key, - markdown: markdown, + markdown: normalizedMarkdown, priority: priority, timestamp: Date() ) From f617032ad58692141d7855d12ac54f9a192bc6a3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:39:55 -0800 Subject: [PATCH 09/30] Require launch tag for cmux DEV --- Sources/SocketControlSettings.swift | 49 ++++++++++++++++++++++++++++ Sources/cmuxApp.swift | 12 +++++++ cmuxTests/GhosttyConfigTests.swift | 50 +++++++++++++++++++++++++++++ scripts/run-tests-v1.sh | 5 +-- scripts/run-tests-v2.sh | 5 +-- 5 files changed, 117 insertions(+), 4 deletions(-) diff --git a/Sources/SocketControlSettings.swift b/Sources/SocketControlSettings.swift index a2586136..da220b15 100644 --- a/Sources/SocketControlSettings.swift +++ b/Sources/SocketControlSettings.swift @@ -163,6 +163,8 @@ struct SocketControlSettings { static let legacyEnabledKey = "socketControlEnabled" static let allowSocketPathOverrideKey = "CMUX_ALLOW_SOCKET_OVERRIDE" static let socketPasswordEnvKey = "CMUX_SOCKET_PASSWORD" + static let launchTagEnvKey = "CMUX_TAG" + static let baseDebugBundleIdentifier = "com.cmuxterm.app.debug" private static func normalizeMode(_ raw: String) -> String { raw @@ -211,6 +213,53 @@ struct SocketControlSettings { #endif } + static func launchTag( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> String? { + guard let raw = environment[launchTagEnvKey] else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + static func shouldBlockUntaggedDebugLaunch( + environment: [String: String] = ProcessInfo.processInfo.environment, + bundleIdentifier: String? = Bundle.main.bundleIdentifier, + isDebugBuild: Bool = SocketControlSettings.isDebugBuild + ) -> Bool { + guard isDebugBuild else { return false } + if isRunningUnderXCTest(environment: environment) { + return false + } + + guard let bundleIdentifier = bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), + !bundleIdentifier.isEmpty else { + return false + } + + if bundleIdentifier.hasPrefix("\(baseDebugBundleIdentifier).") { + return false + } + + guard bundleIdentifier == baseDebugBundleIdentifier else { + return false + } + + return launchTag(environment: environment) == nil + } + + static func isRunningUnderXCTest(environment: [String: String]) -> Bool { + let indicators = [ + "XCTestConfigurationFilePath", + "XCTestBundlePath", + "XCTestSessionIdentifier", + "XCInjectBundleInto", + ] + return indicators.contains { key in + guard let value = environment[key] else { return false } + return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } + static func socketPath( environment: [String: String] = ProcessInfo.processInfo.environment, bundleIdentifier: String? = Bundle.main.bundleIdentifier, diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index f857ffa0..004406e7 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -35,6 +35,10 @@ struct cmuxApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate init() { + if SocketControlSettings.shouldBlockUntaggedDebugLaunch() { + Self.terminateForMissingLaunchTag() + } + Self.configureGhosttyEnvironment() let startupAppearance = AppearanceSettings.resolvedMode() @@ -58,6 +62,14 @@ struct cmuxApp: App { appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState) } + private static func terminateForMissingLaunchTag() -> Never { + let message = "error: refusing to launch untagged cmux DEV; start with ./scripts/reload.sh --tag (or set CMUX_TAG for test harnesses)" + fputs("\(message)\n", stderr) + fflush(stderr) + NSLog("%@", message) + Darwin.exit(64) + } + private static func configureGhosttyEnvironment() { let fileManager = FileManager.default let ghosttyAppResources = "/Applications/Ghostty.app/Contents/Resources/ghostty" diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 220767ba..a788043b 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -691,6 +691,56 @@ final class SocketControlSettingsTests: XCTestCase { "/tmp/cmux-staging.sock" ) } + + func testUntaggedDebugBundleBlockedWithoutLaunchTag() { + XCTAssertTrue( + SocketControlSettings.shouldBlockUntaggedDebugLaunch( + environment: [:], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: true + ) + ) + } + + func testUntaggedDebugBundleAllowedWithLaunchTag() { + XCTAssertFalse( + SocketControlSettings.shouldBlockUntaggedDebugLaunch( + environment: ["CMUX_TAG": "tests-v1"], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: true + ) + ) + } + + func testTaggedDebugBundleAllowedWithoutLaunchTag() { + XCTAssertFalse( + SocketControlSettings.shouldBlockUntaggedDebugLaunch( + environment: [:], + bundleIdentifier: "com.cmuxterm.app.debug.tests-v1", + isDebugBuild: true + ) + ) + } + + func testReleaseBuildIgnoresLaunchTagGate() { + XCTAssertFalse( + SocketControlSettings.shouldBlockUntaggedDebugLaunch( + environment: [:], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: false + ) + ) + } + + func testXCTestLaunchIgnoresLaunchTagGate() { + XCTAssertFalse( + SocketControlSettings.shouldBlockUntaggedDebugLaunch( + environment: ["XCTestConfigurationFilePath": "/tmp/fake.xctestconfiguration"], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: true + ) + ) + } } final class PostHogAnalyticsPropertiesTests: XCTestCase { diff --git a/scripts/run-tests-v1.sh b/scripts/run-tests-v1.sh index 317d19cf..12592e70 100755 --- a/scripts/run-tests-v1.sh +++ b/scripts/run-tests-v1.sh @@ -13,6 +13,7 @@ cd "$(dirname "$0")/.." DERIVED_DATA_PATH="$HOME/Library/Developer/Xcode/DerivedData/cmux-tests-v1" APP="$DERIVED_DATA_PATH/Build/Products/Debug/cmux DEV.app" +RUN_TAG="tests-v1" echo "== build ==" # Work around stale explicit-module cache artifacts (notably Sentry headers) that can @@ -51,7 +52,7 @@ launch_and_wait() { defaults write com.cmuxterm.app.debug socketControlMode -string full >/dev/null 2>&1 || true # Launch directly with UI test mode enabled so startup follows deterministic test codepaths. - CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 & + CMUX_TAG="$RUN_TAG" CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 & SOCK="" for _ in {1..120}; do @@ -70,7 +71,7 @@ launch_and_wait() { export CMUX_SOCKET="$SOCK" # Ensure LaunchServices has a visible/main window attached for rendering checks. - open "$APP" >/dev/null 2>&1 || true + CMUX_TAG="$RUN_TAG" open "$APP" >/dev/null 2>&1 || true sleep 0.5 echo "== wait ready ==" diff --git a/scripts/run-tests-v2.sh b/scripts/run-tests-v2.sh index 4be4e854..e17cc6c2 100755 --- a/scripts/run-tests-v2.sh +++ b/scripts/run-tests-v2.sh @@ -13,6 +13,7 @@ cd "$(dirname "$0")/.." DERIVED_DATA_PATH="$HOME/Library/Developer/Xcode/DerivedData/cmux-tests-v2" APP="$DERIVED_DATA_PATH/Build/Products/Debug/cmux DEV.app" +RUN_TAG="tests-v2" echo "== build ==" # Work around stale explicit-module cache artifacts (notably Sentry headers) that can @@ -51,7 +52,7 @@ launch_and_wait() { defaults write com.cmuxterm.app.debug socketControlMode -string full >/dev/null 2>&1 || true # Launch directly with UI test mode enabled so startup follows deterministic test codepaths. - CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 & + CMUX_TAG="$RUN_TAG" CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 & SOCK="" for _ in {1..120}; do @@ -70,7 +71,7 @@ launch_and_wait() { export CMUX_SOCKET="$SOCK" # Ensure LaunchServices has a visible/main window attached for rendering checks. - open "$APP" >/dev/null 2>&1 || true + CMUX_TAG="$RUN_TAG" open "$APP" >/dev/null 2>&1 || true sleep 0.5 echo "== wait ready ==" From 7d0e259b368f5ca8c4b1ae27a756892c6b0300e6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:44:15 -0800 Subject: [PATCH 10/30] style(sidebar): reduce metadata icon sizing --- Sources/ContentView.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 577afc83..c694e228 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -3387,7 +3387,7 @@ private struct TabItemView: View { private struct PullRequestStatusIcon: View { let status: SidebarPullRequestStatus let color: Color - private static let frameSize: CGFloat = 14 + private static let frameSize: CGFloat = 13 var body: some View { switch status { @@ -3397,7 +3397,7 @@ private struct TabItemView: View { PullRequestMergedIcon(color: color) case .closed: Image(systemName: "xmark.circle") - .font(.system(size: 9, weight: .regular)) + .font(.system(size: 8, weight: .regular)) .foregroundColor(color) .frame(width: Self.frameSize, height: Self.frameSize) } @@ -3653,12 +3653,12 @@ private struct SidebarMetadataEntryRow: View { if iconRaw.hasPrefix("emoji:") { let value = String(iconRaw.dropFirst("emoji:".count)) guard !value.isEmpty else { return nil } - return AnyView(Text(value).font(.system(size: 10))) + return AnyView(Text(value).font(.system(size: 9))) } if iconRaw.hasPrefix("text:") { let value = String(iconRaw.dropFirst("text:".count)) guard !value.isEmpty else { return nil } - return AnyView(Text(value).font(.system(size: 9, weight: .semibold))) + return AnyView(Text(value).font(.system(size: 8, weight: .semibold))) } let symbolName: String if iconRaw.hasPrefix("sf:") { @@ -3667,7 +3667,7 @@ private struct SidebarMetadataEntryRow: View { symbolName = iconRaw } guard !symbolName.isEmpty else { return nil } - return AnyView(Image(systemName: symbolName).font(.system(size: 9, weight: .medium))) + return AnyView(Image(systemName: symbolName).font(.system(size: 8, weight: .medium))) } @ViewBuilder From 915c01f9d0233b37e2760a7679806aaad5751b5a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:47:01 -0800 Subject: [PATCH 11/30] Add ../../Frameworks rpath for bundled cmux cli --- GhosttyTabs.xcodeproj/project.pbxproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index d8bc7236..c3f2b4d9 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -810,6 +810,7 @@ "$(inherited)", "@executable_path", "@executable_path/../Frameworks", + "@executable_path/../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; PRODUCT_NAME = cmux; @@ -828,6 +829,7 @@ "$(inherited)", "@executable_path", "@executable_path/../Frameworks", + "@executable_path/../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; PRODUCT_NAME = cmux; From f7ef0707c289aabd48366e091ef849490f80be8d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:51:43 -0800 Subject: [PATCH 12/30] Include commit metadata in cmux --version output --- CLI/cmux.swift | 110 ++++++++++++++++++---- tests/test_cli_version_commit_metadata.py | 85 +++++++++++++++++ 2 files changed, 175 insertions(+), 20 deletions(-) create mode 100644 tests/test_cli_version_commit_metadata.py diff --git a/CLI/cmux.swift b/CLI/cmux.swift index b14dad7c..c1c98599 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -4988,39 +4988,63 @@ struct CMUXCLI { private func versionSummary() -> String { let info = resolvedVersionInfo() + let commit = info["CMUXCommit"].flatMap { normalizedCommitHash($0) } + let baseSummary: String if let version = info["CFBundleShortVersionString"], let build = info["CFBundleVersion"] { - return "cmux \(version) (\(build))" + baseSummary = "cmux \(version) (\(build))" + } else if let version = info["CFBundleShortVersionString"] { + baseSummary = "cmux \(version)" + } else if let build = info["CFBundleVersion"] { + baseSummary = "cmux build \(build)" + } else { + baseSummary = "cmux version unknown" } - if let version = info["CFBundleShortVersionString"] { - return "cmux \(version)" - } - if let build = info["CFBundleVersion"] { - return "cmux build \(build)" - } - return "cmux version unknown" + guard let commit else { return baseSummary } + return "\(baseSummary) [\(commit)]" } private func resolvedVersionInfo() -> [String: String] { + var info: [String: String] = [:] if let main = versionInfo(from: Bundle.main.infoDictionary) { - return main + info.merge(main, uniquingKeysWith: { current, _ in current }) } - for plistURL in candidateInfoPlistURLs() { - guard let data = try? Data(contentsOf: plistURL), - let raw = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil), - let dictionary = raw as? [String: Any], - let parsed = versionInfo(from: dictionary) - else { - continue + let needsPlistFallback = + info["CFBundleShortVersionString"] == nil || + info["CFBundleVersion"] == nil || + info["CMUXCommit"] == nil + if needsPlistFallback { + for plistURL in candidateInfoPlistURLs() { + guard let data = try? Data(contentsOf: plistURL), + let raw = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil), + let dictionary = raw as? [String: Any], + let parsed = versionInfo(from: dictionary) + else { + continue + } + info.merge(parsed, uniquingKeysWith: { current, _ in current }) + if info["CFBundleShortVersionString"] != nil, + info["CFBundleVersion"] != nil, + info["CMUXCommit"] != nil { + break + } } - return parsed } - if let fromProject = versionInfoFromProjectFile() { - return fromProject + let needsProjectFallback = + info["CFBundleShortVersionString"] == nil || + info["CFBundleVersion"] == nil || + info["CMUXCommit"] == nil + if needsProjectFallback, let fromProject = versionInfoFromProjectFile() { + info.merge(fromProject, uniquingKeysWith: { current, _ in current }) } - return [:] + if info["CMUXCommit"] == nil, + let commit = normalizedCommitHash(ProcessInfo.processInfo.environment["CMUX_COMMIT"]) { + info["CMUXCommit"] = commit + } + + return info } private func versionInfo(from dictionary: [String: Any]?) -> [String: String]? { @@ -5039,6 +5063,10 @@ struct CMUXCLI { info["CFBundleVersion"] = trimmed } } + if let commit = dictionary["CMUXCommit"] as? String, + let normalizedCommit = normalizedCommitHash(commit) { + info["CMUXCommit"] = normalizedCommit + } return info.isEmpty ? nil : info } @@ -5064,6 +5092,9 @@ struct CMUXCLI { if let build = firstProjectSetting("CURRENT_PROJECT_VERSION", in: contents) { info["CFBundleVersion"] = build } + if let commit = gitCommitHash(at: current) { + info["CMUXCommit"] = commit + } if !info.isEmpty { return info } @@ -5100,6 +5131,45 @@ struct CMUXCLI { return value } + private func gitCommitHash(at directory: URL) -> String? { + let process = Process() + let stdout = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["git", "-C", directory.path, "rev-parse", "--short=9", "HEAD"] + process.standardOutput = stdout + process.standardError = Pipe() + + do { + try process.run() + } catch { + return nil + } + process.waitUntilExit() + guard process.terminationStatus == 0 else { + return nil + } + + let data = stdout.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8) else { + return nil + } + return normalizedCommitHash(output) + } + + private func normalizedCommitHash(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !trimmed.contains("$(") else { + return nil + } + let normalized = trimmed.lowercased() + let allowed = CharacterSet(charactersIn: "0123456789abcdef") + guard normalized.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { + return nil + } + return String(normalized.prefix(12)) + } + private func candidateInfoPlistURLs() -> [URL] { guard let executable = currentExecutablePath(), !executable.isEmpty else { return [] diff --git a/tests/test_cli_version_commit_metadata.py b/tests/test_cli_version_commit_metadata.py new file mode 100644 index 00000000..3029fe0d --- /dev/null +++ b/tests/test_cli_version_commit_metadata.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Regression test: CLI version output wiring keeps commit metadata support.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def get_repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path.cwd() + + +def require(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle not in content: + failures.append(message) + + +def main() -> int: + repo_root = get_repo_root() + cli_path = repo_root / "CLI" / "cmux.swift" + if not cli_path.exists(): + print(f"FAIL: missing expected file: {cli_path}") + return 1 + + content = cli_path.read_text(encoding="utf-8") + failures: list[str] = [] + + require( + content, + 'let commit = info["CMUXCommit"].flatMap { normalizedCommitHash($0) }', + "versionSummary no longer reads CMUXCommit metadata", + failures, + ) + require( + content, + 'return "\\(baseSummary) [\\(commit)]"', + "versionSummary no longer appends commit metadata", + failures, + ) + require( + content, + 'if let commit = dictionary["CMUXCommit"] as? String,', + "Info.plist parsing no longer reads CMUXCommit", + failures, + ) + require( + content, + "if let commit = gitCommitHash(at: current) {", + "Project fallback no longer probes git commit hash", + failures, + ) + require( + content, + '["git", "-C", directory.path, "rev-parse", "--short=9", "HEAD"]', + "Git commit probe command changed unexpectedly", + failures, + ) + require( + content, + 'normalizedCommitHash(ProcessInfo.processInfo.environment["CMUX_COMMIT"])', + "Environment commit fallback (CMUX_COMMIT) is missing", + failures, + ) + + if failures: + print("FAIL: CLI version commit metadata regression(s) detected") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: CLI version commit metadata wiring is intact") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 7610e586b77a58b5c0230cf1b10a2c792b4cb52e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 24 Feb 2026 20:53:12 -0800 Subject: [PATCH 13/30] Browser download: keep logs debug-only and harden menu fallback --- Sources/Panels/CmuxWebView.swift | 56 ++++++++++++------- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 34 +++++++++++ 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index c8244dd2..c330f9ea 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -826,7 +826,6 @@ final class CmuxWebView: WKWebView { if target === self, action == #selector(contextMenuDownloadImage(_:)) || action == #selector(contextMenuDownloadLinkedFile(_:)) { - NSLog("CmuxWebView context fallback skipped (recursive self action)") debugContextDownload( "browser.ctxdl.fallback trace=\(trace) reason=\(reason ?? "none") skipped=recursive action=\(Self.selectorName(action))" ) @@ -1085,7 +1084,6 @@ final class CmuxWebView: WKWebView { fallbackTarget: AnyObject?, traceID: String ) { - NSLog("CmuxWebView context download start: %@", url.absoluteString) debugContextDownload("browser.ctxdl.start trace=\(traceID) url=\(url.absoluteString)") downloadURLViaSession( url, @@ -1154,7 +1152,6 @@ final class CmuxWebView: WKWebView { } if isDownloadImageMenuItem(item) { - NSLog("CmuxWebView context menu hook: download image") debugContextDownload( "browser.ctxdl.menu hook kind=image index=\(index) id=\(item.identifier?.rawValue ?? "nil") title=\(item.title) action=\(Self.selectorName(item.action))" ) @@ -1172,7 +1169,6 @@ final class CmuxWebView: WKWebView { } if isDownloadLinkedFileMenuItem(item) { - NSLog("CmuxWebView context menu hook: download linked file") debugContextDownload( "browser.ctxdl.menu hook kind=linked index=\(index) id=\(item.identifier?.rawValue ?? "nil") title=\(item.title) action=\(Self.selectorName(item.action))" ) @@ -1230,6 +1226,7 @@ final class CmuxWebView: WKWebView { "browser.ctxdl.resolve trace=\(traceID) kind=image imageURL=\(url?.absoluteString ?? "nil")" ) var dataImageURL: URL? + var weakImageURL: URL? if let url { let scheme = url.scheme?.lowercased() ?? "" if scheme == "data" { @@ -1242,16 +1239,27 @@ final class CmuxWebView: WKWebView { self.debugContextDownload( "browser.ctxdl.resolve trace=\(traceID) kind=image normalizedImageURL=\(normalized.absoluteString)" ) - if !self.isLikelyFaviconURL(normalized) && self.isLikelyImageURL(normalized) { - NSLog("CmuxWebView context download image URL: %@ (normalized=%@)", url.absoluteString, normalized.absoluteString) - self.startContextMenuDownload( - normalized, - sender: sender, - fallbackAction: fallback.action, - fallbackTarget: fallback.target, - traceID: traceID + if self.isLikelyImageURL(normalized) { + if !self.isLikelyFaviconURL(normalized) { + self.startContextMenuDownload( + normalized, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target, + traceID: traceID + ) + return + } + weakImageURL = normalized + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image weakCandidateURL=\(normalized.absoluteString) reason=favicon_or_low_confidence" + ) + } else if self.isDownloadableScheme(normalized), !self.isLikelyFaviconURL(normalized) { + // Some image CDNs use extensionless URLs; keep as last-resort candidate. + weakImageURL = normalized + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image weakCandidateURL=\(normalized.absoluteString) reason=unclassified_direct_image_src" ) - return } self.debugContextDownload( "browser.ctxdl.resolve trace=\(traceID) kind=image rejectedPrimaryImageURL=\(normalized.absoluteString)" @@ -1273,7 +1281,6 @@ final class CmuxWebView: WKWebView { if self.isDownloadableScheme(normalizedLink), self.isLikelyImageURL(normalizedLink), !self.isLikelyFaviconURL(normalizedLink) { - NSLog("CmuxWebView context download image fallback to link URL: %@ (normalized=%@)", linkURL.absoluteString, normalizedLink.absoluteString) self.startContextMenuDownload( normalizedLink, sender: sender, @@ -1299,8 +1306,21 @@ final class CmuxWebView: WKWebView { return } + if let weakImageURL { + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image fallbackToWeakCandidate=1" + ) + self.startContextMenuDownload( + weakImageURL, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target, + traceID: traceID + ) + return + } + if let linkURL { - NSLog("CmuxWebView context download image: link URL not image-like (%@), using fallback action", linkURL.absoluteString) self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "image") self.runContextMenuFallback( action: fallback.action, @@ -1312,7 +1332,6 @@ final class CmuxWebView: WKWebView { return } - NSLog("CmuxWebView context download image: no downloadable image/link URL, using fallback action") self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "image") self.runContextMenuFallback( action: fallback.action, @@ -1350,7 +1369,6 @@ final class CmuxWebView: WKWebView { "browser.ctxdl.resolve trace=\(traceID) kind=linked normalizedLinkURL=\(normalized.absoluteString)" ) if self.isDownloadSupportedScheme(normalized) { - NSLog("CmuxWebView context download linked file URL: %@ (normalized=%@)", url.absoluteString, normalized.absoluteString) self.startContextMenuDownload( normalized, sender: sender, @@ -1369,7 +1387,6 @@ final class CmuxWebView: WKWebView { ) var dataImageURL: URL? if let imageURL, self.isDownloadableScheme(imageURL) { - NSLog("CmuxWebView context download linked file fallback image URL: %@", imageURL.absoluteString) self.startContextMenuDownload( imageURL, sender: sender, @@ -1405,7 +1422,6 @@ final class CmuxWebView: WKWebView { ) return } - NSLog("CmuxWebView context download linked file: URL nil, using fallback action") self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "linked") self.runContextMenuFallback( action: fallback.action, @@ -1434,7 +1450,6 @@ final class CmuxWebView: WKWebView { ) return } - NSLog("CmuxWebView context download linked file: unsupported URL %@, using fallback action", fallbackURL.absoluteString) self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "linked") self.runContextMenuFallback( action: fallback.action, @@ -1445,7 +1460,6 @@ final class CmuxWebView: WKWebView { ) return } - NSLog("CmuxWebView context download linked file fallback URL: %@ (normalized=%@)", fallbackURL.absoluteString, normalized.absoluteString) self.startContextMenuDownload( normalized, sender: sender, diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 9a0e10b6..b610b14f 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -641,6 +641,40 @@ final class CmuxWebViewContextMenuTests: XCTestCase { XCTAssertFalse(menu.items.contains { $0.title == "Open Link in Default Browser" }) } + + func testWillOpenMenuHooksDownloadImageToDiskMenuVariant() { + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let menu = NSMenu() + let originalTarget = NSObject() + let originalAction = NSSelectorFromString("downloadImageToDisk:") + let downloadItem = NSMenuItem(title: "Download Image As...", action: originalAction, keyEquivalent: "") + downloadItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierDownloadImageToDisk") + downloadItem.target = originalTarget + menu.addItem(downloadItem) + + webView.willOpenMenu(menu, with: makeRightMouseDownEvent()) + + XCTAssertTrue(downloadItem.target === webView) + XCTAssertNotNil(downloadItem.action) + XCTAssertNotEqual(downloadItem.action, originalAction) + } + + func testWillOpenMenuHooksDownloadLinkedFileToDiskMenuVariant() { + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let menu = NSMenu() + let originalTarget = NSObject() + let originalAction = NSSelectorFromString("downloadLinkToDisk:") + let downloadItem = NSMenuItem(title: "Download Linked File As...", action: originalAction, keyEquivalent: "") + downloadItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierDownloadLinkToDisk") + downloadItem.target = originalTarget + menu.addItem(downloadItem) + + webView.willOpenMenu(menu, with: makeRightMouseDownEvent()) + + XCTAssertTrue(downloadItem.target === webView) + XCTAssertNotNil(downloadItem.action) + XCTAssertNotEqual(downloadItem.action, originalAction) + } } final class BrowserDevToolsButtonDebugSettingsTests: XCTestCase { From 023d7759ace245e4dd427c9de126bf3d8dae547a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:55:44 -0800 Subject: [PATCH 14/30] sidebar: render pull requests as per-line rows --- Sources/ContentView.swift | 71 +++++----- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 121 +++++++++++++++++- 2 files changed, 156 insertions(+), 36 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 381cbc1d..e9a7e04d 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -6317,31 +6317,34 @@ private struct TabItemView: View { } } - // Pull request row - if sidebarShowPullRequest, let pullRequest = primaryPullRequestDisplay { - Button(action: { - updateSelection() - NSWorkspace.shared.open(pullRequest.url) - }) { - HStack(spacing: 4) { - PullRequestStatusIcon( - status: pullRequest.status, - color: pullRequestForegroundColor - ) - Text("\(pullRequest.label) #\(pullRequest.number)") - .underline() - Text(pullRequestStatusLabel(pullRequest.status)) - if pullRequest.extraCount > 0 { - Text("+\(pullRequest.extraCount)") - .opacity(0.75) + // Pull request rows + if sidebarShowPullRequest, !pullRequestDisplays.isEmpty { + VStack(alignment: .leading, spacing: 1) { + ForEach(pullRequestDisplays) { pullRequest in + Button(action: { + updateSelection() + NSWorkspace.shared.open(pullRequest.url) + }) { + HStack(spacing: 4) { + PullRequestStatusIcon( + status: pullRequest.status, + color: pullRequestForegroundColor + ) + Text("\(pullRequest.label) #\(pullRequest.number)") + .underline() + .lineLimit(1) + .truncationMode(.tail) + Text(pullRequestStatusLabel(pullRequest.status)) + .lineLimit(1) + Spacer(minLength: 0) + } + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(pullRequestForegroundColor) } - Spacer(minLength: 0) + .buttonStyle(.plain) + .help("Open \(pullRequest.label) #\(pullRequest.number)") } - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(pullRequestForegroundColor) } - .buttonStyle(.plain) - .help("Open \(pullRequest.label) #\(pullRequest.number)") } // Ports row @@ -6912,24 +6915,24 @@ private struct TabItemView: View { return entries.isEmpty ? nil : entries.joined(separator: " | ") } - private struct PullRequestDisplay { + private struct PullRequestDisplay: Identifiable { + let id: String let number: Int let label: String let url: URL let status: SidebarPullRequestStatus - let extraCount: Int } - private var primaryPullRequestDisplay: PullRequestDisplay? { - let pullRequests = tab.sidebarPullRequestsInDisplayOrder() - guard let first = pullRequests.first else { return nil } - return PullRequestDisplay( - number: first.number, - label: first.label, - url: first.url, - status: first.status, - extraCount: max(0, pullRequests.count - 1) - ) + private var pullRequestDisplays: [PullRequestDisplay] { + tab.sidebarPullRequestsInDisplayOrder().map { pullRequest in + PullRequestDisplay( + id: "\(pullRequest.label.lowercased())#\(pullRequest.number)|\(pullRequest.url.absoluteString)", + number: pullRequest.number, + label: pullRequest.label, + url: pullRequest.url, + status: pullRequest.status + ) + } } private var pullRequestForegroundColor: Color { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 9a0e10b6..d84c20a6 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -3646,6 +3646,117 @@ final class SidebarBranchOrderingTests: XCTestCase { [SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: false, directory: "/repo/default")] ) } + + func testOrderedUniquePullRequestsFollowsPanelOrderAcrossSplitsAndTabs() { + let first = UUID() + let second = UUID() + let third = UUID() + let fourth = UUID() + + let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests( + orderedPanelIds: [first, second, third, fourth], + panelPullRequests: [ + first: pullRequestState( + number: 337, + label: "PR", + url: "https://github.com/manaflow-ai/cmux/pull/337", + status: .open + ), + second: pullRequestState( + number: 18, + label: "MR", + url: "https://gitlab.com/manaflow/cmux/-/merge_requests/18", + status: .open + ), + third: pullRequestState( + number: 337, + label: "PR", + url: "https://github.com/manaflow-ai/cmux/pull/337", + status: .merged + ), + fourth: pullRequestState( + number: 92, + label: "PR", + url: "https://bitbucket.org/manaflow/cmux/pull-requests/92", + status: .closed + ) + ], + fallbackPullRequest: pullRequestState( + number: 1, + label: "PR", + url: "https://example.invalid/fallback/1", + status: .open + ) + ) + + XCTAssertEqual( + pullRequests.map { "\($0.label)#\($0.number)" }, + ["PR#337", "MR#18", "PR#92"] + ) + XCTAssertEqual( + pullRequests.map(\.status), + [.merged, .open, .closed] + ) + } + + func testOrderedUniquePullRequestsTreatsSameNumberDifferentLabelsAsDistinct() { + let first = UUID() + let second = UUID() + + let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests( + orderedPanelIds: [first, second], + panelPullRequests: [ + first: pullRequestState( + number: 42, + label: "PR", + url: "https://github.com/manaflow-ai/cmux/pull/42", + status: .open + ), + second: pullRequestState( + number: 42, + label: "MR", + url: "https://gitlab.com/manaflow/cmux/-/merge_requests/42", + status: .open + ) + ], + fallbackPullRequest: nil + ) + + XCTAssertEqual( + pullRequests.map { "\($0.label)#\($0.number)" }, + ["PR#42", "MR#42"] + ) + } + + func testOrderedUniquePullRequestsUsesFallbackWhenNoPanelPullRequestsExist() { + let fallback = pullRequestState( + number: 11, + label: "PR", + url: "https://github.com/manaflow-ai/cmux/pull/11", + status: .open + ) + let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests( + orderedPanelIds: [], + panelPullRequests: [:], + fallbackPullRequest: fallback + ) + + XCTAssertEqual(pullRequests, [fallback]) + } + + private func pullRequestState( + number: Int, + label: String, + url: String, + status: SidebarPullRequestStatus + ) -> SidebarPullRequestState { + SidebarPullRequestState( + number: number, + label: label, + url: URL(string: url)!, + status: status + ) + } } @MainActor @@ -6479,7 +6590,10 @@ final class TerminalControllerSidebarDedupeTests: XCTestCase { key: "agent", value: "idle", icon: "bolt", - color: "#ffffff" + color: "#ffffff", + url: nil, + priority: 0, + format: .plain ) ) } @@ -6498,7 +6612,10 @@ final class TerminalControllerSidebarDedupeTests: XCTestCase { key: "agent", value: "running", icon: "bolt", - color: "#ffffff" + color: "#ffffff", + url: nil, + priority: 0, + format: .plain ) ) } From 109ce43c709de33006afacf748828dd6db0bf3c9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:59:06 -0800 Subject: [PATCH 15/30] settings: control sidebar PR link open target --- Sources/ContentView.swift | 16 ++++++++++++++-- Sources/Panels/BrowserPanel.swift | 10 ++++++++++ Sources/cmuxApp.swift | 16 ++++++++++++++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 12 ++++++++++++ 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index e9a7e04d..b5418cff 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -6016,6 +6016,8 @@ private struct TabItemView: View { @AppStorage("sidebarShowBranchDirectory") private var sidebarShowBranchDirectory = true @AppStorage("sidebarShowGitBranchIcon") private var sidebarShowGitBranchIcon = false @AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true + @AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) + private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser @AppStorage("sidebarShowPorts") private var sidebarShowPorts = true @AppStorage("sidebarShowLog") private var sidebarShowLog = true @AppStorage("sidebarShowProgress") private var sidebarShowProgress = true @@ -6322,8 +6324,7 @@ private struct TabItemView: View { VStack(alignment: .leading, spacing: 1) { ForEach(pullRequestDisplays) { pullRequest in Button(action: { - updateSelection() - NSWorkspace.shared.open(pullRequest.url) + openPullRequestLink(pullRequest.url) }) { HStack(spacing: 4) { PullRequestStatusIcon( @@ -6939,6 +6940,17 @@ private struct TabItemView: View { isActive ? .white.opacity(0.75) : .secondary } + private func openPullRequestLink(_ url: URL) { + updateSelection() + if openSidebarPullRequestLinksInCmuxBrowser { + if tabManager.openBrowser(url: url, insertAtEnd: true) == nil { + NSWorkspace.shared.open(url) + } + return + } + NSWorkspace.shared.open(url) + } + private func pullRequestStatusLabel(_ status: SidebarPullRequestStatus) -> String { switch status { case .open: return "open" diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 71f297bb..07f1fd45 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -127,6 +127,9 @@ enum BrowserLinkOpenSettings { static let openTerminalLinksInCmuxBrowserKey = "browserOpenTerminalLinksInCmuxBrowser" static let defaultOpenTerminalLinksInCmuxBrowser: Bool = true + static let openSidebarPullRequestLinksInCmuxBrowserKey = "browserOpenSidebarPullRequestLinksInCmuxBrowser" + static let defaultOpenSidebarPullRequestLinksInCmuxBrowser: Bool = true + static let interceptTerminalOpenCommandInCmuxBrowserKey = "browserInterceptTerminalOpenCommandInCmuxBrowser" static let defaultInterceptTerminalOpenCommandInCmuxBrowser: Bool = true @@ -140,6 +143,13 @@ enum BrowserLinkOpenSettings { return defaults.bool(forKey: openTerminalLinksInCmuxBrowserKey) } + static func openSidebarPullRequestLinksInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: openSidebarPullRequestLinksInCmuxBrowserKey) == nil { + return defaultOpenSidebarPullRequestLinksInCmuxBrowser + } + return defaults.bool(forKey: openSidebarPullRequestLinksInCmuxBrowserKey) + } + static func interceptTerminalOpenCommandInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool { if defaults.object(forKey: interceptTerminalOpenCommandInCmuxBrowserKey) != nil { return defaults.bool(forKey: interceptTerminalOpenCommandInCmuxBrowserKey) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 0f193ba1..63dbc3b0 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2631,6 +2631,8 @@ struct SettingsView: View { private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue @AppStorage("sidebarShowBranchDirectory") private var sidebarShowBranchDirectory = true @AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true + @AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) + private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser @AppStorage("sidebarShowPorts") private var sidebarShowPorts = true @AppStorage("sidebarShowLog") private var sidebarShowLog = true @AppStorage("sidebarShowProgress") private var sidebarShowProgress = true @@ -2877,6 +2879,19 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + "Open Sidebar PR Links in cmux Browser", + subtitle: openSidebarPullRequestLinksInCmuxBrowser + ? "Clicks open inside cmux browser." + : "Clicks open in your default browser." + ) { + Toggle("", isOn: $openSidebarPullRequestLinksInCmuxBrowser) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + SettingsCardRow( "Show Listening Ports in Sidebar", subtitle: "Display detected listening ports for the active workspace." @@ -3460,6 +3475,7 @@ struct SettingsView: View { sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue sidebarShowBranchDirectory = true sidebarShowPullRequest = true + openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser sidebarShowPorts = true sidebarShowLog = true sidebarShowProgress = true diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index d84c20a6..59bcab74 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -6352,6 +6352,18 @@ final class BrowserLinkOpenSettingsTests: XCTestCase { XCTAssertTrue(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser(defaults: defaults)) } + func testSidebarPullRequestLinksDefaultToCmuxBrowser() { + XCTAssertTrue(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults)) + } + + func testSidebarPullRequestLinksPreferenceUsesStoredValue() { + defaults.set(false, forKey: BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) + XCTAssertFalse(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults)) + + defaults.set(true, forKey: BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) + XCTAssertTrue(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults)) + } + func testOpenCommandInterceptionDefaultsToCmuxBrowser() { XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) } From a5711425df9366a481e600d698eb4062fd85364b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:03:38 -0800 Subject: [PATCH 16/30] sidebar: add command palette action for workspace PR links --- Sources/ContentView.swift | 67 ++++++++++++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b5418cff..9393bdf3 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1226,6 +1226,8 @@ struct ContentView: View { @State private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] = [:] @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus + @AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) + private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser @FocusState private var isCommandPaletteSearchFocused: Bool @FocusState private var isCommandPaletteRenameFocused: Bool @@ -1368,6 +1370,7 @@ struct ContentView: View { static let workspaceName = "workspace.name" static let workspaceHasCustomName = "workspace.hasCustomName" static let workspaceShouldPin = "workspace.shouldPin" + static let workspaceHasPullRequests = "workspace.hasPullRequests" static let hasFocusedPanel = "panel.hasFocus" static let panelName = "panel.name" @@ -3339,6 +3342,10 @@ struct ContentView: View { snapshot.setString(CommandPaletteContextKeys.workspaceName, workspaceDisplayName(workspace)) snapshot.setBool(CommandPaletteContextKeys.workspaceHasCustomName, workspace.customTitle != nil) snapshot.setBool(CommandPaletteContextKeys.workspaceShouldPin, !workspace.isPinned) + snapshot.setBool( + CommandPaletteContextKeys.workspaceHasPullRequests, + !workspace.sidebarPullRequestsInDisplayOrder().isEmpty + ) } if let panelContext = focusedPanelContext { @@ -3646,6 +3653,18 @@ struct ContentView: View { ) ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.openWorkspacePullRequests", + title: constant("Open All Workspace PR Links"), + subtitle: workspaceSubtitle, + keywords: ["pull", "request", "review", "merge", "pr", "mr", "open", "links", "workspace"], + when: { + $0.bool(CommandPaletteContextKeys.hasWorkspace) && + $0.bool(CommandPaletteContextKeys.workspaceHasPullRequests) + } + ) + ) contributions.append( CommandPaletteCommandContribution( commandId: "palette.browserBack", @@ -3998,6 +4017,13 @@ struct ContentView: View { registry.register(commandId: "palette.previousTabInPane") { tabManager.selectPreviousSurface() } + registry.register(commandId: "palette.openWorkspacePullRequests") { + DispatchQueue.main.async { + if !openWorkspacePullRequestsInConfiguredBrowser() { + NSSound.beep() + } + } + } registry.register(commandId: "palette.browserBack") { tabManager.focusedBrowserPanel?.goBack() @@ -4640,6 +4666,31 @@ struct ContentView: View { return NSWorkspace.shared.open(url) } + private func openWorkspacePullRequestsInConfiguredBrowser() -> Bool { + guard let workspace = tabManager.selectedWorkspace else { return false } + let pullRequests = workspace.sidebarPullRequestsInDisplayOrder() + guard !pullRequests.isEmpty else { return false } + + var openedCount = 0 + if openSidebarPullRequestLinksInCmuxBrowser { + for pullRequest in pullRequests { + if tabManager.openBrowser(url: pullRequest.url, insertAtEnd: true) != nil { + openedCount += 1 + } else if NSWorkspace.shared.open(pullRequest.url) { + openedCount += 1 + } + } + return openedCount > 0 + } + + for pullRequest in pullRequests { + if NSWorkspace.shared.open(pullRequest.url) { + openedCount += 1 + } + } + return openedCount > 0 + } + private func openFocusedDirectory(in target: TerminalDirectoryOpenTarget) -> Bool { guard let directoryURL = focusedTerminalDirectoryURL() else { return false } return openFocusedDirectory(directoryURL, in: target) @@ -7008,7 +7059,7 @@ private struct TabItemView: View { private struct PullRequestStatusIcon: View { let status: SidebarPullRequestStatus let color: Color - private static let frameSize: CGFloat = 13 + private static let frameSize: CGFloat = 12 var body: some View { switch status { @@ -7018,7 +7069,7 @@ private struct TabItemView: View { PullRequestMergedIcon(color: color) case .closed: Image(systemName: "xmark.circle") - .font(.system(size: 8, weight: .regular)) + .font(.system(size: 7, weight: .regular)) .foregroundColor(color) .frame(width: Self.frameSize, height: Self.frameSize) } @@ -7027,9 +7078,9 @@ private struct TabItemView: View { private struct PullRequestOpenIcon: View { let color: Color - private static let stroke = StrokeStyle(lineWidth: 1.35, lineCap: .round, lineJoin: .round) - private static let nodeDiameter: CGFloat = 3.4 - private static let frameSize: CGFloat = 14 + private static let stroke = StrokeStyle(lineWidth: 1.2, lineCap: .round, lineJoin: .round) + private static let nodeDiameter: CGFloat = 3.0 + private static let frameSize: CGFloat = 13 var body: some View { ZStack { @@ -7065,9 +7116,9 @@ private struct TabItemView: View { private struct PullRequestMergedIcon: View { let color: Color - private static let stroke = StrokeStyle(lineWidth: 1.35, lineCap: .round, lineJoin: .round) - private static let nodeDiameter: CGFloat = 3.4 - private static let frameSize: CGFloat = 14 + private static let stroke = StrokeStyle(lineWidth: 1.2, lineCap: .round, lineJoin: .round) + private static let nodeDiameter: CGFloat = 3.0 + private static let frameSize: CGFloat = 13 var body: some View { ZStack { From cc06fb84e35f2fa8750ee41c6ae4abf18a43df0e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:07:37 -0800 Subject: [PATCH 17/30] Fix command palette Open Settings action routing --- Sources/AppDelegate.swift | 22 ++++++++- Sources/ContentView.swift | 2 +- Sources/cmuxApp.swift | 2 +- .../AppDelegateShortcutRoutingTests.swift | 46 +++++++++++++++++++ 4 files changed, 68 insertions(+), 4 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 337ad9f3..57166431 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -3258,9 +3258,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } + @MainActor + static func presentPreferencesWindow( + sendShowSettingsAction: @MainActor () -> Bool = { + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + }, + showFallbackSettingsWindow: @MainActor () -> Void = { + SettingsWindowController.shared.show() + }, + activateApplication: @MainActor () -> Void = { + NSApp.activate(ignoringOtherApps: true) + } + ) { + let handledByResponderChain = sendShowSettingsAction() + if !handledByResponderChain { + showFallbackSettingsWindow() + } + activateApplication() + } + @objc func openPreferencesWindow() { - NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) - NSApp.activate(ignoringOtherApps: true) + Self.presentPreferencesWindow() } func refreshMenuBarExtraForDebug() { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 04a6433a..60b38650 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -3923,7 +3923,7 @@ struct ContentView: View { AppDelegate.shared?.jumpToLatestUnread() } registry.register(commandId: "palette.openSettings") { - NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + AppDelegate.presentPreferencesWindow() } registry.register(commandId: "palette.checkForUpdates") { AppDelegate.shared?.checkForUpdates(nil) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 004406e7..e8a0c634 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -1701,7 +1701,7 @@ private struct AcknowledgmentsView: View { } } -private final class SettingsWindowController: NSWindowController, NSWindowDelegate { +final class SettingsWindowController: NSWindowController, NSWindowDelegate { static let shared = SettingsWindowController() private init() { diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 59796d6d..3ef18f94 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -417,6 +417,52 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertTrue(appDelegate.tabManager === firstManager, "Unresolved event window should not retarget active manager") } + func testPresentPreferencesWindowUsesFallbackWhenResponderChainDoesNotHandleSettingsAction() { + var sendShowSettingsActionCallCount = 0 + var showFallbackSettingsWindowCallCount = 0 + var activateApplicationCallCount = 0 + + AppDelegate.presentPreferencesWindow( + sendShowSettingsAction: { + sendShowSettingsActionCallCount += 1 + return false + }, + showFallbackSettingsWindow: { + showFallbackSettingsWindowCallCount += 1 + }, + activateApplication: { + activateApplicationCallCount += 1 + } + ) + + XCTAssertEqual(sendShowSettingsActionCallCount, 1) + XCTAssertEqual(showFallbackSettingsWindowCallCount, 1) + XCTAssertEqual(activateApplicationCallCount, 1) + } + + func testPresentPreferencesWindowSkipsFallbackWhenResponderChainHandlesSettingsAction() { + var sendShowSettingsActionCallCount = 0 + var showFallbackSettingsWindowCallCount = 0 + var activateApplicationCallCount = 0 + + AppDelegate.presentPreferencesWindow( + sendShowSettingsAction: { + sendShowSettingsActionCallCount += 1 + return true + }, + showFallbackSettingsWindow: { + showFallbackSettingsWindowCallCount += 1 + }, + activateApplication: { + activateApplicationCallCount += 1 + } + ) + + XCTAssertEqual(sendShowSettingsActionCallCount, 1) + XCTAssertEqual(showFallbackSettingsWindowCallCount, 0) + XCTAssertEqual(activateApplicationCallCount, 1) + } + private func makeKeyDownEvent( key: String, modifiers: NSEvent.ModifierFlags, From afeec2d324d67cf557d1388ed42566b79ae4820e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:14:57 -0800 Subject: [PATCH 18/30] sidebar: open PR links in right split for target workspace --- Sources/ContentView.swift | 7 +- Sources/TabManager.swift | 65 +++++++++++++++++-- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 48 ++++++++++++++ 3 files changed, 112 insertions(+), 8 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 9393bdf3..e3b8c37c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -6994,7 +6994,12 @@ private struct TabItemView: View { private func openPullRequestLink(_ url: URL) { updateSelection() if openSidebarPullRequestLinksInCmuxBrowser { - if tabManager.openBrowser(url: url, insertAtEnd: true) == nil { + if tabManager.openBrowser( + inWorkspace: tab.id, + url: url, + preferSplitRight: true, + insertAtEnd: true + ) == nil { NSWorkspace.shared.open(url) } return diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 345c08d1..8efe9677 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1940,19 +1940,70 @@ class TabManager: ObservableObject { return tab.browserPanel(for: panelId) } + /// Open a browser in a specific workspace, optionally preferring a split-right layout. + @discardableResult + func openBrowser( + inWorkspace tabId: UUID, + url: URL? = nil, + preferSplitRight: Bool = false, + insertAtEnd: Bool = false + ) -> UUID? { + guard let workspace = tabs.first(where: { $0.id == tabId }) else { return nil } + if selectedTabId != tabId { + selectedTabId = tabId + } + + if preferSplitRight { + let splitSourcePanelId: UUID? = { + if let focusedPanelId = workspace.focusedPanelId, + workspace.panels[focusedPanelId] != nil { + return focusedPanelId + } + if let rememberedPanelId = lastFocusedPanelByTab[tabId], + workspace.panels[rememberedPanelId] != nil { + return rememberedPanelId + } + if let orderedPanelId = workspace.sidebarOrderedPanelIds().first(where: { workspace.panels[$0] != nil }) { + return orderedPanelId + } + return workspace.panels.keys.sorted { $0.uuidString < $1.uuidString }.first + }() + + if let splitSourcePanelId, + let browserPanel = workspace.newBrowserSplit( + from: splitSourcePanelId, + orientation: .horizontal, + url: url, + focus: true + ) { + rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id) + return browserPanel.id + } + } + + guard let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first, + let browserPanel = workspace.newBrowserSurface( + inPane: paneId, + url: url, + focus: true, + insertAtEnd: insertAtEnd + ) else { + return nil + } + rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id) + return browserPanel.id + } + /// Open a browser in the currently focused pane (as a new surface) @discardableResult func openBrowser(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? { - guard let tabId = selectedTabId, - let tab = tabs.first(where: { $0.id == tabId }), - let focusedPaneId = tab.bonsplitController.focusedPaneId else { return nil } - let panel = tab.newBrowserSurface( - inPane: focusedPaneId, + guard let tabId = selectedTabId else { return nil } + return openBrowser( + inWorkspace: tabId, url: url, - focus: true, + preferSplitRight: false, insertAtEnd: insertAtEnd ) - return panel?.id } /// Reopen the most recently closed browser panel (Cmd+Shift+T). diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 59bcab74..70a06646 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2868,6 +2868,54 @@ final class TabManagerSurfaceCreationTests: XCTestCase { ) XCTAssertEqual(workspace.focusedPanelId, browserPanelId, "Expected opened browser surface to be focused") } + + func testOpenBrowserInWorkspaceSplitRightSelectsTargetWorkspaceAndCreatesSplit() { + let manager = TabManager() + guard let initialWorkspace = manager.selectedWorkspace else { + XCTFail("Expected initial selected workspace") + return + } + guard let url = URL(string: "https://example.com/pull/123") else { + XCTFail("Expected test URL to be valid") + return + } + + let targetWorkspace = manager.addWorkspace(select: false) + manager.selectWorkspace(initialWorkspace) + let initialPaneCount = targetWorkspace.bonsplitController.allPaneIds.count + let initialPanelCount = targetWorkspace.panels.count + + guard let browserPanelId = manager.openBrowser( + inWorkspace: targetWorkspace.id, + url: url, + preferSplitRight: true, + insertAtEnd: true + ) else { + XCTFail("Expected browser panel to be created in target workspace") + return + } + + XCTAssertEqual(manager.selectedTabId, targetWorkspace.id, "Expected target workspace to become selected") + XCTAssertEqual( + targetWorkspace.bonsplitController.allPaneIds.count, + initialPaneCount + 1, + "Expected split-right browser open to create a new pane" + ) + XCTAssertEqual( + targetWorkspace.panels.count, + initialPanelCount + 1, + "Expected browser panel count to increase by one" + ) + XCTAssertEqual( + targetWorkspace.focusedPanelId, + browserPanelId, + "Expected created browser panel to be focused in target workspace" + ) + XCTAssertTrue( + targetWorkspace.panels[browserPanelId] is BrowserPanel, + "Expected created panel to be a browser panel" + ) + } } @MainActor From d0ce351856d6fad489ff47473115ccd5310e3005 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:15:01 -0800 Subject: [PATCH 19/30] Route palette settings through Cmd+, add debug logs --- Sources/AppDelegate.swift | 21 +++++++++++++++++++-- Sources/ContentView.swift | 15 ++++++++++++++- Sources/cmuxApp.swift | 23 ++++++++++++++++------- 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 57166431..865a9988 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -3250,7 +3250,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self?.checkForUpdates(nil) }, onOpenPreferences: { [weak self] in - self?.openPreferencesWindow() + self?.openPreferencesWindow(debugSource: "menuBarExtra") }, onQuitApp: { NSApp.terminate(nil) @@ -3271,14 +3271,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } ) { let handledByResponderChain = sendShowSettingsAction() +#if DEBUG + dlog("settings.open.present handledByResponderChain=\(handledByResponderChain ? 1 : 0)") +#endif if !handledByResponderChain { +#if DEBUG + dlog("settings.open.present fallback=1") +#endif showFallbackSettingsWindow() } activateApplication() +#if DEBUG + dlog("settings.open.present activate=1") +#endif + } + + @MainActor + func openPreferencesWindow(debugSource: String) { +#if DEBUG + dlog("settings.open.request source=\(debugSource)") +#endif + Self.presentPreferencesWindow() } @objc func openPreferencesWindow() { - Self.presentPreferencesWindow() + openPreferencesWindow(debugSource: "appDelegate") } func refreshMenuBarExtraForDebug() { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 60b38650..db98110f 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -3923,7 +3923,17 @@ struct ContentView: View { AppDelegate.shared?.jumpToLatestUnread() } registry.register(commandId: "palette.openSettings") { - AppDelegate.presentPreferencesWindow() +#if DEBUG + dlog("palette.openSettings.invoke") +#endif + if let appDelegate = AppDelegate.shared { + appDelegate.openPreferencesWindow(debugSource: "palette.openSettings") + } else { +#if DEBUG + dlog("palette.openSettings.missingAppDelegate fallback=1") +#endif + AppDelegate.presentPreferencesWindow() + } } registry.register(commandId: "palette.checkForUpdates") { AppDelegate.shared?.checkForUpdates(nil) @@ -4239,6 +4249,9 @@ struct ContentView: View { } private func runCommandPaletteCommand(_ command: CommandPaletteCommand) { +#if DEBUG + dlog("palette.run commandId=\(command.id) dismissOnRun=\(command.dismissOnRun ? 1 : 0)") +#endif recordCommandPaletteUsage(command.id) command.action() if command.dismissOnRun { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index e8a0c634..283e11a5 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -195,7 +195,7 @@ struct cmuxApp: App { applyAppearance() if ProcessInfo.processInfo.environment["CMUX_UI_TEST_SHOW_SETTINGS"] == "1" { DispatchQueue.main.async { - showSettingsPanel() + appDelegate.openPreferencesWindow(debugSource: "uiTestShowSettings") } } } @@ -210,7 +210,7 @@ struct cmuxApp: App { .commands { CommandGroup(replacing: .appSettings) { Button("Settings…") { - showSettingsPanel() + appDelegate.openPreferencesWindow(debugSource: "menu.cmdComma") } .keyboardShortcut(",", modifiers: .command) } @@ -583,11 +583,6 @@ struct cmuxApp: App { NSApp.activate(ignoringOtherApps: true) } - private func showSettingsPanel() { - SettingsWindowController.shared.show() - NSApp.activate(ignoringOtherApps: true) - } - private func applyAppearance() { let mode = AppearanceSettings.mode(for: appearanceMode) if appearanceMode != mode.rawValue { @@ -1728,11 +1723,25 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { func show() { guard let window else { return } +#if DEBUG + NSLog( + "settings.window.show requested isVisible=%d isKey=%d", + window.isVisible ? 1 : 0, + window.isKeyWindow ? 1 : 0 + ) +#endif SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .settings) if !window.isVisible { window.center() } window.makeKeyAndOrderFront(nil) +#if DEBUG + NSLog( + "settings.window.show completed isVisible=%d isKey=%d", + window.isVisible ? 1 : 0, + window.isKeyWindow ? 1 : 0 + ) +#endif } } From b891a86047b74c4184e37060f28b63c43bcc11e4 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:16:06 -0800 Subject: [PATCH 20/30] Log settings window show events to debug log --- Sources/cmuxApp.swift | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 283e11a5..6aecdf64 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -1,6 +1,7 @@ import AppKit import SwiftUI import Darwin +import Bonsplit @main struct cmuxApp: App { @@ -1724,11 +1725,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { func show() { guard let window else { return } #if DEBUG - NSLog( - "settings.window.show requested isVisible=%d isKey=%d", - window.isVisible ? 1 : 0, - window.isKeyWindow ? 1 : 0 - ) + dlog("settings.window.show requested isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)") #endif SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .settings) if !window.isVisible { @@ -1736,11 +1733,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { } window.makeKeyAndOrderFront(nil) #if DEBUG - NSLog( - "settings.window.show completed isVisible=%d isKey=%d", - window.isVisible ? 1 : 0, - window.isKeyWindow ? 1 : 0 - ) + dlog("settings.window.show completed isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)") #endif } } From 84d07931a5f38b2f6d250bbde1af7014858e022d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:24:49 -0800 Subject: [PATCH 21/30] sidebar: dedupe PR rows by normalized URL identity --- Sources/Workspace.swift | 20 +++++++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 32 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index ff5de794..bb7d91b1 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -722,8 +722,26 @@ enum SidebarBranchOrdering { } } + func normalizedReviewURLKey(for url: URL) -> String { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url.absoluteString + } + + // Treat URL variants that differ only by query/fragment as the same review item. + components.query = nil + components.fragment = nil + let scheme = components.scheme?.lowercased() ?? "" + let host = components.host?.lowercased() ?? "" + let port = components.port.map { ":\($0)" } ?? "" + var path = components.path + if path.hasSuffix("/"), path.count > 1 { + path.removeLast() + } + return "\(scheme)://\(host)\(port)\(path)" + } + func reviewKey(for state: SidebarPullRequestState) -> String { - "\(state.label.lowercased())#\(state.number)" + "\(state.label.lowercased())#\(state.number)|\(normalizedReviewURLKey(for: state.url))" } var orderedKeys: [String] = [] diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 70a06646..a2f727e0 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -3776,6 +3776,38 @@ final class SidebarBranchOrderingTests: XCTestCase { ) } + func testOrderedUniquePullRequestsTreatsSameNumberAndLabelDifferentUrlsAsDistinct() { + let first = UUID() + let second = UUID() + + let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests( + orderedPanelIds: [first, second], + panelPullRequests: [ + first: pullRequestState( + number: 42, + label: "PR", + url: "https://github.com/manaflow-ai/cmux/pull/42", + status: .open + ), + second: pullRequestState( + number: 42, + label: "PR", + url: "https://github.com/manaflow-ai/other-repo/pull/42", + status: .open + ) + ], + fallbackPullRequest: nil + ) + + XCTAssertEqual( + pullRequests.map(\.url.absoluteString), + [ + "https://github.com/manaflow-ai/cmux/pull/42", + "https://github.com/manaflow-ai/other-repo/pull/42" + ] + ) + } + func testOrderedUniquePullRequestsUsesFallbackWhenNoPanelPullRequestsExist() { let fallback = pullRequestState( number: 11, From 7517454ea2912836926e46ae5bd48951e60cd242 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:28:03 -0800 Subject: [PATCH 22/30] Always open custom settings window from palette path --- Sources/AppDelegate.swift | 13 ++------- .../AppDelegateShortcutRoutingTests.swift | 29 +++++++++---------- 2 files changed, 15 insertions(+), 27 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 865a9988..ef8a73b3 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -3260,9 +3260,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @MainActor static func presentPreferencesWindow( - sendShowSettingsAction: @MainActor () -> Bool = { - NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) - }, showFallbackSettingsWindow: @MainActor () -> Void = { SettingsWindowController.shared.show() }, @@ -3270,16 +3267,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent NSApp.activate(ignoringOtherApps: true) } ) { - let handledByResponderChain = sendShowSettingsAction() #if DEBUG - dlog("settings.open.present handledByResponderChain=\(handledByResponderChain ? 1 : 0)") + dlog("settings.open.present path=customWindowDirect") #endif - if !handledByResponderChain { -#if DEBUG - dlog("settings.open.present fallback=1") -#endif - showFallbackSettingsWindow() - } + showFallbackSettingsWindow() activateApplication() #if DEBUG dlog("settings.open.present activate=1") diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 3ef18f94..c5a9435a 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -417,16 +417,11 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertTrue(appDelegate.tabManager === firstManager, "Unresolved event window should not retarget active manager") } - func testPresentPreferencesWindowUsesFallbackWhenResponderChainDoesNotHandleSettingsAction() { - var sendShowSettingsActionCallCount = 0 + func testPresentPreferencesWindowShowsCustomSettingsWindowAndActivates() { var showFallbackSettingsWindowCallCount = 0 var activateApplicationCallCount = 0 AppDelegate.presentPreferencesWindow( - sendShowSettingsAction: { - sendShowSettingsActionCallCount += 1 - return false - }, showFallbackSettingsWindow: { showFallbackSettingsWindowCallCount += 1 }, @@ -435,21 +430,15 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { } ) - XCTAssertEqual(sendShowSettingsActionCallCount, 1) XCTAssertEqual(showFallbackSettingsWindowCallCount, 1) XCTAssertEqual(activateApplicationCallCount, 1) } - func testPresentPreferencesWindowSkipsFallbackWhenResponderChainHandlesSettingsAction() { - var sendShowSettingsActionCallCount = 0 + func testPresentPreferencesWindowSupportsRepeatedCalls() { var showFallbackSettingsWindowCallCount = 0 var activateApplicationCallCount = 0 AppDelegate.presentPreferencesWindow( - sendShowSettingsAction: { - sendShowSettingsActionCallCount += 1 - return true - }, showFallbackSettingsWindow: { showFallbackSettingsWindowCallCount += 1 }, @@ -458,9 +447,17 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { } ) - XCTAssertEqual(sendShowSettingsActionCallCount, 1) - XCTAssertEqual(showFallbackSettingsWindowCallCount, 0) - XCTAssertEqual(activateApplicationCallCount, 1) + AppDelegate.presentPreferencesWindow( + showFallbackSettingsWindow: { + showFallbackSettingsWindowCallCount += 1 + }, + activateApplication: { + activateApplicationCallCount += 1 + } + ) + + XCTAssertEqual(showFallbackSettingsWindowCallCount, 2) + XCTAssertEqual(activateApplicationCallCount, 2) } private func makeKeyDownEvent( From b84c0539c92e65f04f87b541703498617d4133ce Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:36:10 -0800 Subject: [PATCH 23/30] browser: reuse top-right pane for sidebar PR opens --- Sources/TabManager.swift | 11 +++ Sources/Workspace.swift | 91 +++++++++++++++++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 47 ++++++++++ 3 files changed, 149 insertions(+) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 8efe9677..f707686c 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1954,6 +1954,17 @@ class TabManager: ObservableObject { } if preferSplitRight { + if let targetPaneId = workspace.topRightBrowserReusePane(), + let browserPanel = workspace.newBrowserSurface( + inPane: targetPaneId, + url: url, + focus: true, + insertAtEnd: insertAtEnd + ) { + rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id) + return browserPanel.id + } + let splitSourcePanelId: UUID? = { if let focusedPanelId = workspace.focusedPanelId, workspace.panels[focusedPanelId] != nil { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index bb7d91b1..43ca9cd5 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2184,6 +2184,49 @@ final class Workspace: Identifiable, ObservableObject { return nil } + /// Returns the top-right pane in the current split tree. + /// When a workspace is already split, sidebar PR opens should reuse an existing pane + /// instead of creating additional right splits. + func topRightBrowserReusePane() -> PaneID? { + let paneIds = bonsplitController.allPaneIds + guard paneIds.count > 1 else { return nil } + + let paneById = Dictionary(uniqueKeysWithValues: paneIds.map { ($0.id.uuidString, $0) }) + var paneBounds: [String: CGRect] = [:] + browserCollectNormalizedPaneBounds( + node: bonsplitController.treeSnapshot(), + availableRect: CGRect(x: 0, y: 0, width: 1, height: 1), + into: &paneBounds + ) + + guard !paneBounds.isEmpty else { + return paneIds.sorted { $0.id.uuidString < $1.id.uuidString }.first + } + + let epsilon = 0.000_1 + let rightMostX = paneBounds.values.map(\.maxX).max() ?? 0 + + let sortedCandidates = paneBounds + .filter { _, rect in abs(rect.maxX - rightMostX) <= epsilon } + .sorted { lhs, rhs in + if abs(lhs.value.minY - rhs.value.minY) > epsilon { + return lhs.value.minY < rhs.value.minY + } + if abs(lhs.value.minX - rhs.value.minX) > epsilon { + return lhs.value.minX > rhs.value.minX + } + return lhs.key < rhs.key + } + + for candidate in sortedCandidates { + if let pane = paneById[candidate.key] { + return pane + } + } + + return paneIds.sorted { $0.id.uuidString < $1.id.uuidString }.first + } + private enum BrowserPaneBranch { case first case second @@ -2221,6 +2264,54 @@ final class Workspace: Identifiable, ObservableObject { } } + private func browserCollectNormalizedPaneBounds( + node: ExternalTreeNode, + availableRect: CGRect, + into output: inout [String: CGRect] + ) { + switch node { + case .pane(let paneNode): + output[paneNode.id] = availableRect + case .split(let splitNode): + let divider = min(max(splitNode.dividerPosition, 0), 1) + let firstRect: CGRect + let secondRect: CGRect + + if splitNode.orientation.lowercased() == "vertical" { + // Stacked split: first = top, second = bottom + firstRect = CGRect( + x: availableRect.minX, + y: availableRect.minY, + width: availableRect.width, + height: availableRect.height * divider + ) + secondRect = CGRect( + x: availableRect.minX, + y: availableRect.minY + (availableRect.height * divider), + width: availableRect.width, + height: availableRect.height * (1 - divider) + ) + } else { + // Side-by-side split: first = left, second = right + firstRect = CGRect( + x: availableRect.minX, + y: availableRect.minY, + width: availableRect.width * divider, + height: availableRect.height + ) + secondRect = CGRect( + x: availableRect.minX + (availableRect.width * divider), + y: availableRect.minY, + width: availableRect.width * (1 - divider), + height: availableRect.height + ) + } + + browserCollectNormalizedPaneBounds(node: splitNode.first, availableRect: firstRect, into: &output) + browserCollectNormalizedPaneBounds(node: splitNode.second, availableRect: secondRect, into: &output) + } + } + private struct BrowserCloseFallbackPlan { let orientation: SplitOrientation let insertFirst: Bool diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index a2f727e0..378f142a 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2916,6 +2916,53 @@ final class TabManagerSurfaceCreationTests: XCTestCase { "Expected created panel to be a browser panel" ) } + + func testOpenBrowserInWorkspaceSplitRightReusesTopRightPaneWhenAlreadySplit() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let topRightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal), + workspace.newTerminalSplit(from: topRightPanel.id, orientation: .vertical) != nil, + let topRightPaneId = workspace.paneId(forPanelId: topRightPanel.id), + let url = URL(string: "https://example.com/pull/456") else { + XCTFail("Expected split setup to succeed") + return + } + + let initialPaneCount = workspace.bonsplitController.allPaneIds.count + + guard let browserPanelId = manager.openBrowser( + inWorkspace: workspace.id, + url: url, + preferSplitRight: true, + insertAtEnd: true + ) else { + XCTFail("Expected browser panel to be created") + return + } + + XCTAssertEqual( + workspace.bonsplitController.allPaneIds.count, + initialPaneCount, + "Expected split-right browser open to reuse existing panes" + ) + XCTAssertEqual( + workspace.paneId(forPanelId: browserPanelId), + topRightPaneId, + "Expected browser to open in the top-right pane when multiple splits already exist" + ) + + let targetPaneTabs = workspace.bonsplitController.tabs(inPane: topRightPaneId) + guard let lastSurfaceId = targetPaneTabs.last?.id else { + XCTFail("Expected top-right pane to contain tabs") + return + } + XCTAssertEqual( + workspace.panelIdFromSurfaceId(lastSurfaceId), + browserPanelId, + "Expected browser surface to be appended at end in the reused top-right pane" + ) + } } @MainActor From 17d8956789a12c3a78b152b6965113e49afd1ee9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 24 Feb 2026 21:54:25 -0800 Subject: [PATCH 24/30] Fix Cmd+W terminal close in terminal+browser split --- Sources/AppDelegate.swift | 31 +++ Sources/Panels/TerminalPanel.swift | 5 +- Sources/TabManager.swift | 61 ++++- Sources/TerminalWindowPortal.swift | 8 + Sources/Workspace.swift | 27 ++- ...e_464_cmdw_close_terminal_browser_split.py | 214 ++++++++++++++++++ 6 files changed, 338 insertions(+), 8 deletions(-) create mode 100644 tests/test_issue_464_cmdw_close_terminal_browser_split.py diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index d9bf44b8..b1ce5200 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -4537,6 +4537,37 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + // Cmd+W must close the focused panel even if first-responder momentarily lags on a + // browser NSTextView during split focus transitions. + if normalizedFlags == [.command], (chars == "w" || event.keyCode == 13) { + if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, + targetWindow.identifier?.rawValue == "cmux.settings" { + targetWindow.performClose(nil) + } else { + let responder = event.window?.firstResponder + ?? NSApp.keyWindow?.firstResponder + ?? NSApp.mainWindow?.firstResponder + if let ghosttyView = cmuxOwningGhosttyView(for: responder), + let workspaceId = ghosttyView.tabId, + let panelId = ghosttyView.terminalSurface?.id, + let manager = tabManagerFor(tabId: workspaceId) ?? tabManager { +#if DEBUG + dlog( + "shortcut.cmdW route=ghostty workspace=\(workspaceId.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) selected=\(manager.selectedTabId?.uuidString.prefix(5) ?? "nil")" + ) +#endif + manager.closePanelWithConfirmation(tabId: workspaceId, surfaceId: panelId) + } else { +#if DEBUG + dlog("shortcut.cmdW route=focusedPanelFallback") +#endif + tabManager?.closeCurrentPanelWithConfirmation() + } + } + return true + } + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .closeWorkspace)) { tabManager?.closeCurrentWorkspaceWithConfirmation() return true diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index ede60c40..c8ba8507 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -137,8 +137,11 @@ final class TerminalPanel: Panel, ObservableObject { func close() { // The surface will be cleaned up by its deinit - // Just unfocus before closing + // Detach from the window portal on real close so stale hosted views + // cannot remain above browser panes after split close. unfocus() + hostedView.setVisibleInUI(false) + TerminalWindowPortalRegistry.detach(hostedView: hostedView) } func requestViewReattach() { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 345c08d1..f2125f6f 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1116,10 +1116,28 @@ class TabManager: ObservableObject { } private func closePanelWithConfirmation(tab: Workspace, panelId: UUID) { + let bonsplitTabCount = tab.bonsplitController.allPaneIds.reduce(0) { partial, paneId in + partial + tab.bonsplitController.tabs(inPane: paneId).count + } + let panelKind: String = { + guard let panel = tab.panels[panelId] else { return "missing" } + if panel is TerminalPanel { return "terminal" } + if panel is BrowserPanel { return "browser" } + return String(describing: type(of: panel)) + }() +#if DEBUG + dlog( + "surface.close.shortcut.begin tab=\(tab.id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) kind=\(panelKind) " + + "panelCount=\(tab.panels.count) bonsplitTabs=\(bonsplitTabCount)" + ) +#endif + // Cmd+W closes the focused Bonsplit tab (a "tab" in the UI). When the workspace only has // a single tab left, closing it should close the workspace (and possibly the window), // rather than creating a replacement terminal. - let isLastTabInWorkspace = tab.panels.count <= 1 + let effectiveSurfaceCount = max(tab.panels.count, bonsplitTabCount) + let isLastTabInWorkspace = effectiveSurfaceCount <= 1 if isLastTabInWorkspace { let willCloseWindow = tabs.count <= 1 let needsConfirm = workspaceNeedsConfirmClose(tab) @@ -1127,11 +1145,25 @@ class TabManager: ObservableObject { let message = willCloseWindow ? "This will close the last tab and close the window." : "This will close the last tab and close its workspace." +#if DEBUG + dlog( + "surface.close.shortcut.confirm tab=\(tab.id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) reason=lastTab" + ) +#endif guard confirmClose( title: "Close tab?", message: message, acceptCmdD: willCloseWindow - ) else { return } + ) else { +#if DEBUG + dlog( + "surface.close.shortcut.cancel tab=\(tab.id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) reason=lastTabConfirmDismissed" + ) +#endif + return + } } AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id) @@ -1145,15 +1177,36 @@ class TabManager: ObservableObject { if let terminalPanel = tab.terminalPanel(for: panelId), terminalPanel.needsConfirmClose() { +#if DEBUG + dlog( + "surface.close.shortcut.confirm tab=\(tab.id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) reason=terminalNeedsConfirm" + ) +#endif guard confirmClose( title: "Close tab?", message: "This will close the current tab.", acceptCmdD: false - ) else { return } + ) else { +#if DEBUG + dlog( + "surface.close.shortcut.cancel tab=\(tab.id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) reason=terminalConfirmDismissed" + ) +#endif + return + } } // We already confirmed (if needed); bypass Bonsplit's delegate gating. - tab.closePanel(panelId, force: true) + let closed = tab.closePanel(panelId, force: true) +#if DEBUG + dlog( + "surface.close.shortcut tab=\(tab.id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) closed=\(closed ? 1 : 0) " + + "panelsAfterCall=\(tab.panels.count)" + ) +#endif } func closePanelWithConfirmation(tabId: UUID, surfaceId: UUID) { diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 7dda1b50..605b04c7 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -1463,6 +1463,14 @@ enum TerminalWindowPortalRegistry { portal.hideEntry(forHostedId: hostedId) } + /// Permanently detach a hosted terminal view from the window-level portal. + /// Use this when a terminal panel is actually closing (not transient SwiftUI dismantle). + static func detach(hostedView: GhosttySurfaceScrollView) { + let hostedId = ObjectIdentifier(hostedView) + guard let windowId = hostedToWindowId.removeValue(forKey: hostedId) else { return } + portalsByWindowId[windowId]?.detachHostedView(withId: hostedId) + } + /// Update the visibleInUI flag on an existing portal entry without rebinding. /// Called when a bind is deferred (host not yet in window) to prevent stale /// portal syncs from hiding a view that is about to become visible. diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 52a67ccb..ba6ca46e 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1952,17 +1952,38 @@ final class Workspace: Identifiable, ObservableObject { } // Mapping can transiently drift during split-tree mutations. If the target panel is - // currently focused, close whichever tab bonsplit marks selected in that focused pane. - guard focusedPanelId == panelId, + // currently focused (or is the active terminal first responder), close whichever tab + // bonsplit marks selected in that focused pane. + let firstResponderPanelId = cmuxOwningGhosttyView( + for: NSApp.keyWindow?.firstResponder ?? NSApp.mainWindow?.firstResponder + )?.terminalSurface?.id + let targetIsActive = focusedPanelId == panelId || firstResponderPanelId == panelId + guard targetIsActive, let focusedPane = bonsplitController.focusedPaneId, let selected = bonsplitController.selectedTab(inPane: focusedPane) else { +#if DEBUG + dlog( + "surface.close.fallback.skip panel=\(panelId.uuidString.prefix(5)) " + + "focusedPanel=\(focusedPanelId?.uuidString.prefix(5) ?? "nil") " + + "firstResponderPanel=\(firstResponderPanelId?.uuidString.prefix(5) ?? "nil") " + + "focusedPane=\(bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil")" + ) +#endif return false } if force { forceCloseTabIds.insert(selected.id) } - return bonsplitController.closeTab(selected.id) + let closed = bonsplitController.closeTab(selected.id) +#if DEBUG + dlog( + "surface.close.fallback panel=\(panelId.uuidString.prefix(5)) " + + "selectedTab=\(String(describing: selected.id).prefix(5)) " + + "closed=\(closed ? 1 : 0)" + ) +#endif + return closed } func paneId(forPanelId panelId: UUID) -> PaneID? { diff --git a/tests/test_issue_464_cmdw_close_terminal_browser_split.py b/tests/test_issue_464_cmdw_close_terminal_browser_split.py new file mode 100644 index 00000000..90a15843 --- /dev/null +++ b/tests/test_issue_464_cmdw_close_terminal_browser_split.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +""" +Regression test for issue #464: + +Scenario: + - One workspace with exactly two panes: + left: terminal + right: browser (cnn.com) + - Focus the terminal and press Cmd+W. + +Expected: + - Terminal closes. + - Browser remains and fills the workspace (no stale terminal content/pane). + +This test uses debug socket commands (`simulate_shortcut`, `layout_debug`, +`surface_health`, `drag_hit_chain`). +Run against a Debug app socket (typically with CMUX_SOCKET_MODE=allowAll). +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s: float = 5.0, interval_s: float = 0.05) -> bool: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return True + time.sleep(interval_s) + return False + + +def _wait_url_contains(client: cmux, panel_id: str, needle: str, timeout_s: float = 20.0) -> None: + def _matches() -> bool: + response = client._send_command(f"get_url {panel_id}").strip().lower() + return not response.startswith("error") and needle.lower() in response + + if not _wait_until(_matches, timeout_s=timeout_s, interval_s=0.1): + current = client._send_command(f"get_url {panel_id}") + raise cmuxError(f"Timed out waiting for browser URL containing '{needle}', got: {current}") + + +def _capture_screenshot(client: cmux, label: str) -> str: + response = client._send_command(f"screenshot {label}").strip() + if not response.startswith("OK "): + return f"" + parts = response.split(" ", 2) + if len(parts) < 3: + return f"" + return parts[2] + + +def _focused_terminal_ready(client: cmux, panel_id: str) -> bool: + try: + return client.is_terminal_focused(panel_id) + except Exception: + return False + + +def _drag_hit_chain(client: cmux, nx: float, ny: float) -> str: + return client._send_command(f"drag_hit_chain {nx:.3f} {ny:.3f}").strip() + + +def _top_hit_view_class(hit_chain: str) -> str: + if not hit_chain or hit_chain == "none" or hit_chain.startswith("ERROR"): + return hit_chain + first = hit_chain.split("->", 1)[0] + return first.split("@", 1)[0] + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + # Quick sanity check: fail early with actionable info if socket is not in allow mode. + ping_ok = client.ping() + if not ping_ok: + raise cmuxError( + f"Socket ping failed on {SOCKET_PATH}. " + "Launch Debug app with CMUX_SOCKET_MODE=allowAll for this test." + ) + + workspace_id = client.new_workspace() + try: + client.select_workspace(workspace_id) + time.sleep(0.25) + client.activate_app() + time.sleep(0.15) + + browser_id = client.new_pane( + direction="right", + panel_type="browser", + url="https://cnn.com", + ) + _wait_url_contains(client, browser_id, "cnn", timeout_s=20.0) + + health_before = client.surface_health() + terminal_rows = [row for row in health_before if row.get("type") == "terminal"] + browser_rows = [row for row in health_before if row.get("type") == "browser"] + if len(terminal_rows) != 1 or len(browser_rows) != 1: + raise cmuxError( + f"Expected exactly one terminal and one browser before close; " + f"health={health_before}" + ) + + terminal_id = terminal_rows[0]["id"] + client.focus_surface(terminal_id) + if not _wait_until(lambda: _focused_terminal_ready(client, terminal_id), timeout_s=4.0): + raise cmuxError(f"Terminal did not become first responder before Cmd+W: {terminal_id}") + + before_surfaces = client.list_surfaces() + before_panes = client.list_panes() + before_layout = client.layout_debug() + before_shot = _capture_screenshot(client, "issue464_cmdw_before") + + client.simulate_shortcut("cmd+w") + + # Give close animations/routing time to settle. + _wait_until(lambda: len(client.list_surfaces()) == 1, timeout_s=4.0, interval_s=0.05) + time.sleep(0.25) + + after_surfaces = client.list_surfaces() + after_panes = client.list_panes() + after_health = client.surface_health() + after_layout = client.layout_debug() + after_shot = _capture_screenshot(client, "issue464_cmdw_after") + after_hit_chain = _drag_hit_chain(client, 0.42, 0.50) + after_top_hit_class = _top_hit_view_class(after_hit_chain) + + failures: list[str] = [] + + if len(after_surfaces) != 1: + failures.append(f"Expected 1 surface after Cmd+W, got {len(after_surfaces)}: {after_surfaces}") + + if len(after_panes) != 1: + failures.append(f"Expected 1 pane after Cmd+W, got {len(after_panes)}: {after_panes}") + + visible_terminals = [ + row for row in after_health + if row.get("type") == "terminal" and row.get("in_window") is True + ] + if visible_terminals: + failures.append(f"Terminal still visible in_window after Cmd+W: {visible_terminals}") + + remaining_browsers = [row for row in after_health if row.get("type") == "browser"] + if len(remaining_browsers) != 1: + failures.append(f"Expected one remaining browser in health, got: {remaining_browsers}") + else: + rb = remaining_browsers[0] + if str(rb.get("id", "")).lower() != browser_id.lower(): + failures.append( + f"Remaining browser id mismatch: expected {browser_id}, got {rb.get('id')}" + ) + if rb.get("in_window") is not True: + failures.append(f"Remaining browser not in window: {rb}") + + selected_panels = after_layout.get("selectedPanels") or [] + if len(selected_panels) != 1: + failures.append(f"Expected one selected panel after close, got {selected_panels}") + else: + selected_id = str(selected_panels[0].get("panelId", "")).lower() + if selected_id != browser_id.lower(): + failures.append( + f"Selected panel mismatch after close: expected browser {browser_id}, got {selected_id}" + ) + + if after_top_hit_class == "GhosttyNSView": + failures.append( + "Stale terminal overlay still hit-testable after close " + f"(top_hit={after_top_hit_class}, chain={after_hit_chain})" + ) + + if failures: + details = [ + "Cmd+W close regression reproduced (issue #464).", + f"workspace={workspace_id}", + f"browser={browser_id}", + f"terminal={terminal_id}", + f"before_screenshot={before_shot}", + f"after_screenshot={after_shot}", + f"before_surfaces={before_surfaces}", + f"before_panes={before_panes}", + f"before_layout={before_layout}", + f"after_surfaces={after_surfaces}", + f"after_panes={after_panes}", + f"after_health={after_health}", + f"after_layout={after_layout}", + f"after_hit_chain={after_hit_chain}", + f"after_top_hit_class={after_top_hit_class}", + ] + details.extend(f"failure={msg}" for msg in failures) + raise cmuxError("\n".join(details)) + + print( + "PASS: Cmd+W closed terminal in terminal+browser split and left browser as sole visible pane." + ) + print(f"before_screenshot={before_shot}") + print(f"after_screenshot={after_shot}") + return 0 + finally: + try: + client.close_workspace(workspace_id) + except Exception: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) From 93a7b9bc45983b99cef53a9fe8181417e89d91c9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 24 Feb 2026 22:20:09 -0800 Subject: [PATCH 25/30] Add split items and shortcuts to terminal context menu --- Sources/GhosttyTerminalView.swift | 56 +++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 266bc3b1..cde12a35 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2606,6 +2606,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return ghostty_surface_has_selection(surface) case #selector(paste(_:)), #selector(pasteAsPlainText(_:)): return GhosttyPasteboardHelper.hasString(for: GHOSTTY_CLIPBOARD_STANDARD) + case #selector(splitHorizontally(_:)), #selector(splitVertically(_:)): + return canSplitCurrentSurface() default: return true } @@ -3250,9 +3252,63 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } let pasteItem = menu.addItem(withTitle: "Paste", action: #selector(paste(_:)), keyEquivalent: "") pasteItem.target = self + menu.addItem(.separator()) + let splitHorizontallyItem = menu.addItem( + withTitle: "Split Horizontally", + action: #selector(splitHorizontally(_:)), + keyEquivalent: "d" + ) + splitHorizontallyItem.target = self + splitHorizontallyItem.keyEquivalentModifierMask = [.command, .shift] + splitHorizontallyItem.image = NSImage( + systemSymbolName: "rectangle.bottomhalf.inset.filled", + accessibilityDescription: nil + ) + + let splitVerticallyItem = menu.addItem( + withTitle: "Split Vertically", + action: #selector(splitVertically(_:)), + keyEquivalent: "d" + ) + splitVerticallyItem.target = self + splitVerticallyItem.keyEquivalentModifierMask = [.command] + splitVerticallyItem.image = NSImage( + systemSymbolName: "rectangle.righthalf.inset.filled", + accessibilityDescription: nil + ) return menu } + private func canSplitCurrentSurface() -> Bool { + guard let tabId, + let surfaceId = terminalSurface?.id, + let app = AppDelegate.shared, + let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager, + let workspace = manager.tabs.first(where: { $0.id == tabId }) else { + return false + } + return workspace.panels[surfaceId] != nil + } + + @objc private func splitHorizontally(_ sender: Any?) { + _ = splitCurrentSurface(direction: .down) + } + + @objc private func splitVertically(_ sender: Any?) { + _ = splitCurrentSurface(direction: .right) + } + + @discardableResult + private func splitCurrentSurface(direction: SplitDirection) -> Bool { + guard let tabId, + let surfaceId = terminalSurface?.id, + let app = AppDelegate.shared, + let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager else { + return false + } + return manager.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil + } + @objc private func triggerFlash(_ sender: Any?) { onTriggerFlash?() } From fc3e3a4d7d751c2dd6a46e66eed341c4f397d126 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 22:21:40 -0800 Subject: [PATCH 26/30] Pin all GitHub Actions to full commit SHAs Org policy now requires actions pinned to immutable SHAs instead of mutable version tags. Pin actions/checkout, actions/github-script, softprops/action-gh-release, and oven-sh/setup-bun across all workflows. --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/nightly.yml | 6 +++--- .github/workflows/release.yml | 6 +++--- .github/workflows/update-homebrew.yml | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd3dc3a5..a3654ce1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Validate self-hosted runner guards run: ./tests/test_ci_self_hosted_guard.sh @@ -23,10 +23,10 @@ jobs: working-directory: web steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2 - name: Install dependencies run: bun install --frozen-lockfile @@ -43,7 +43,7 @@ jobs: cancel-in-progress: false steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: submodules: recursive diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index a8ebeea4..246da9c4 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Decide whether a nightly build is needed id: decide - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 env: FORCE_BUILD: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force == 'true' && 'true' || 'false' }} with: @@ -84,7 +84,7 @@ jobs: cancel-in-progress: false steps: - name: Checkout main - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ needs.decide.outputs.head_sha }} submodules: recursive @@ -326,7 +326,7 @@ jobs: git push origin refs/tags/nightly --force - name: Publish nightly release assets - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 with: tag_name: nightly name: Nightly diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9063de75..92a60dc8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,13 +17,13 @@ jobs: cancel-in-progress: false steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: submodules: recursive - name: Guard immutable release assets id: guard_release_assets - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: script: | const { evaluateReleaseAssetGuard } = require('./scripts/release_asset_guard'); @@ -277,7 +277,7 @@ jobs: - name: Upload release asset if: steps.guard_release_assets.outputs.skip_upload != 'true' - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 with: files: | cmux-macos.dmg diff --git a/.github/workflows/update-homebrew.yml b/.github/workflows/update-homebrew.yml index 17c07fb5..d92de590 100644 --- a/.github/workflows/update-homebrew.yml +++ b/.github/workflows/update-homebrew.yml @@ -65,7 +65,7 @@ jobs: echo "DMG SHA256: $SHA256" - name: Checkout homebrew-cmux - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: repository: manaflow-ai/homebrew-cmux token: ${{ secrets.HOMEBREW_TAP_TOKEN }} From a0e7e0b284faa39fac00b5d21e176145e54366dd Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:04:22 -0800 Subject: [PATCH 27/30] Trigger CI: debug UI test hang on self-hosted runner From 79266ee03b5569dcddf2dc0cd9701e815043fade Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:15:11 -0800 Subject: [PATCH 28/30] Fix UI test hang: remove ad-hoc signature before test launch The Mac Mini runner has no dev certificates, so xcodebuild produces an ad-hoc signed app. Gatekeeper rejects it, causing XCUITest to hang forever at app.launch(). Split build and test phases, strip the ad-hoc signature between them so the app can launch. --- .github/workflows/ci.yml | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3654ce1..edf9b6f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,8 +90,25 @@ jobs: # Remove stale build cache to avoid incremental build errors rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* + - name: Build for testing + run: | + set -euo pipefail + xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" build-for-testing + + - name: Allow ad-hoc signed app to launch + run: | + # The runner has no development certificates, so the app is ad-hoc signed. + # Gatekeeper blocks ad-hoc apps, causing XCUITest to hang on launch. + # Remove the signature so macOS treats it as unsigned (launchable) instead. + APP_PATH="$(find ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*/Build/Products/Debug -name 'cmux DEV.app' -print -quit)" + if [ -z "$APP_PATH" ]; then + echo "Built app not found in DerivedData" >&2 + exit 1 + fi + codesign --remove-signature "$APP_PATH" + echo "Removed ad-hoc signature from: $APP_PATH" + - name: Run UI tests run: | set -euo pipefail - # Run directly on the self-hosted macOS runner. - xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:cmuxUITests/UpdatePillUITests test + xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:cmuxUITests/UpdatePillUITests test-without-building From 77a59b57439e18d0ab894a7c358e703ed33d5047 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:19:04 -0800 Subject: [PATCH 29/30] Fix UI test hang: set CMUX_TAG for debug launch guard Debug builds refuse to launch without CMUX_TAG to prevent accidental untagged launches. XCUITest launches the app as a separate process without XCTest env vars, so the app's isRunningUnderXCTest() check fails and the app calls exit(64). The test runner then waits forever for the app to report back. Set CMUX_TAG=ci in the CI env. --- .github/workflows/ci.yml | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index edf9b6f9..de3289bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,25 +90,12 @@ jobs: # Remove stale build cache to avoid incremental build errors rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* - - name: Build for testing - run: | - set -euo pipefail - xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" build-for-testing - - - name: Allow ad-hoc signed app to launch - run: | - # The runner has no development certificates, so the app is ad-hoc signed. - # Gatekeeper blocks ad-hoc apps, causing XCUITest to hang on launch. - # Remove the signature so macOS treats it as unsigned (launchable) instead. - APP_PATH="$(find ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*/Build/Products/Debug -name 'cmux DEV.app' -print -quit)" - if [ -z "$APP_PATH" ]; then - echo "Built app not found in DerivedData" >&2 - exit 1 - fi - codesign --remove-signature "$APP_PATH" - echo "Removed ad-hoc signature from: $APP_PATH" - - name: Run UI tests + env: + # Debug builds require CMUX_TAG to prevent accidental untagged launches. + # XCUITest launches the app as a separate process without XCTest env vars, + # so the app's isRunningUnderXCTest() check fails. Provide a tag explicitly. + CMUX_TAG: ci run: | set -euo pipefail - xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:cmuxUITests/UpdatePillUITests test-without-building + xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:cmuxUITests/UpdatePillUITests test From 4d2fd30143f4d961b128beaf7bad62fb96265e57 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:28:34 -0800 Subject: [PATCH 30/30] Fix UI test hang: bypass launch guard for XCUITest apps XCUITest launches the app as a separate process that doesn't inherit XCTest env vars (XCTestConfigurationFilePath, etc.), so isRunningUnderXCTest() returns false. The app then hits shouldBlockUntaggedDebugLaunch() and exits with code 64, causing the test runner to hang waiting for the app to launch. Fix: detect CMUX_UI_TEST_* env vars set via XCUIApplication.launchEnvironment and skip the launch guard. Also revert the failed CMUX_TAG ci.yml workaround. --- .github/workflows/ci.yml | 6 +----- Sources/SocketControlSettings.swift | 5 +++++ cmuxTests/GhosttyConfigTests.swift | 12 ++++++++++++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de3289bf..a3654ce1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,11 +91,7 @@ jobs: rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* - name: Run UI tests - env: - # Debug builds require CMUX_TAG to prevent accidental untagged launches. - # XCUITest launches the app as a separate process without XCTest env vars, - # so the app's isRunningUnderXCTest() check fails. Provide a tag explicitly. - CMUX_TAG: ci run: | set -euo pipefail + # Run directly on the self-hosted macOS runner. xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:cmuxUITests/UpdatePillUITests test diff --git a/Sources/SocketControlSettings.swift b/Sources/SocketControlSettings.swift index da220b15..b9705095 100644 --- a/Sources/SocketControlSettings.swift +++ b/Sources/SocketControlSettings.swift @@ -230,6 +230,11 @@ struct SocketControlSettings { if isRunningUnderXCTest(environment: environment) { return false } + // XCUITest launches the app as a separate process without XCTest env vars, + // so isRunningUnderXCTest() misses it. Check for any CMUX_UI_TEST_ env var. + if environment.keys.contains(where: { $0.hasPrefix("CMUX_UI_TEST_") }) { + return false + } guard let bundleIdentifier = bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), !bundleIdentifier.isEmpty else { diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index a788043b..0d912bb7 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -741,6 +741,18 @@ final class SocketControlSettingsTests: XCTestCase { ) ) } + + func testXCUITestLaunchEnvironmentIgnoresLaunchTagGate() { + // XCUITest launches the app as a separate process without XCTest env vars. + // The app receives CMUX_UI_TEST_* vars via XCUIApplication.launchEnvironment. + XCTAssertFalse( + SocketControlSettings.shouldBlockUntaggedDebugLaunch( + environment: ["CMUX_UI_TEST_MODE": "1"], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: true + ) + ) + } } final class PostHogAnalyticsPropertiesTests: XCTestCase {