diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 02896822..94c90c5e 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -29,7 +29,7 @@ private final class CLISocketSentryTelemetry { self.command = command.lowercased() self.subcommand = commandArgs.first?.lowercased() ?? "help" self.socketPath = socketPath - self.envSocketPath = processEnv["CMUX_SOCKET_PATH"] + self.envSocketPath = processEnv["CMUX_SOCKET_PATH"] ?? processEnv["CMUX_SOCKET"] self.workspaceId = processEnv["CMUX_WORKSPACE_ID"] self.surfaceId = processEnv["CMUX_SURFACE_ID"] self.disabledByEnv = @@ -124,7 +124,7 @@ private final class CLISocketSentryTelemetry { 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" + context["possible_root_cause"] = "CMUX_SOCKET_PATH/CMUX_SOCKET missing while tagged sockets exist" } return context @@ -794,9 +794,14 @@ struct CMUXCLI { func run() throws { let processEnv = ProcessInfo.processInfo.environment let envSocketPath: String? = { - guard let raw = processEnv["CMUX_SOCKET_PATH"] else { return nil } - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed + for key in ["CMUX_SOCKET_PATH", "CMUX_SOCKET"] { + guard let raw = processEnv[key] else { continue } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + return nil }() var socketPath = envSocketPath ?? CLISocketPathResolver.defaultSocketPath var socketPathSource: CLISocketPathSource @@ -902,6 +907,31 @@ struct CMUXCLI { return } + if command == "welcome" { + printWelcome() + return + } + + if command == "shortcuts" { + try runShortcuts( + commandArgs: commandArgs, + socketPath: resolvedSocketPath, + explicitPassword: socketPasswordArg, + jsonOutput: jsonOutput + ) + return + } + + if command == "feedback" { + try runFeedback( + commandArgs: commandArgs, + socketPath: resolvedSocketPath, + explicitPassword: socketPasswordArg, + jsonOutput: jsonOutput + ) + return + } + let client = SocketClient(path: resolvedSocketPath) if resolvedSocketPath != socketPath { cliTelemetry.breadcrumb( @@ -929,13 +959,7 @@ struct CMUXCLI { } defer { client.close() } - if let socketPassword = SocketPasswordResolver.resolve(explicit: socketPasswordArg) { - let authResponse = try client.send(command: "auth \(socketPassword)") - if authResponse.hasPrefix("ERROR:"), - !authResponse.contains("Unknown command 'auth'") { - throw CLIError(message: authResponse) - } - } + try authenticateClientIfNeeded(client, explicitPassword: socketPasswordArg) let idFormat = try resolvedIDFormat(jsonOutput: jsonOutput, raw: idFormatArg) @@ -1933,6 +1957,139 @@ struct CMUXCLI { try activateApp() } + private func runFeedback( + commandArgs: [String], + socketPath: String, + explicitPassword: String?, + jsonOutput: Bool + ) throws { + let (emailOpt, rem0) = parseOption(commandArgs, name: "--email") + let (bodyOpt, rem1) = parseOption(rem0, name: "--body") + let (imagePaths, rem2) = parseRepeatedOption(rem1, name: "--image") + let remaining = rem2.filter { $0 != "--" } + + if let unknown = remaining.first { + throw CLIError(message: "feedback: unknown flag '\(unknown)'. Known flags: --email , --body , --image ") + } + + let client = try connectClient( + socketPath: socketPath, + explicitPassword: explicitPassword, + launchIfNeeded: true + ) + defer { client.close() } + + if emailOpt == nil && bodyOpt == nil && imagePaths.isEmpty { + var params: [String: Any] = [:] + let env = ProcessInfo.processInfo.environment + if let workspaceId = env["CMUX_WORKSPACE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !workspaceId.isEmpty { + params["workspace_id"] = workspaceId + params["activate"] = false + } else { + params["activate"] = true + } + let response = try client.sendV2(method: "feedback.open", params: params) + if jsonOutput { + print(jsonString(response)) + } else { + print("OK") + } + return + } + + guard let email = emailOpt?.trimmingCharacters(in: .whitespacesAndNewlines), + email.isEmpty == false else { + throw CLIError(message: "feedback requires --email when sending feedback") + } + guard let body = bodyOpt, body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + throw CLIError(message: "feedback requires --body when sending feedback") + } + + let resolvedImages = imagePaths.map(resolvePath) + let response = try client.sendV2(method: "feedback.submit", params: [ + "email": email, + "body": body, + "image_paths": resolvedImages, + ]) + if jsonOutput { + print(jsonString(response)) + } else { + print("OK") + } + } + + private func runShortcuts( + commandArgs: [String], + socketPath: String, + explicitPassword: String?, + jsonOutput: Bool + ) throws { + let remaining = commandArgs.filter { $0 != "--" } + if let unknown = remaining.first { + throw CLIError(message: "shortcuts: unknown flag '\(unknown)'") + } + + let client = try connectClient( + socketPath: socketPath, + explicitPassword: explicitPassword, + launchIfNeeded: true + ) + defer { client.close() } + + let response = try client.sendV2(method: "settings.open", params: [ + "target": "keyboardShortcuts", + "activate": true, + ]) + if jsonOutput { + print(jsonString(response)) + } else { + print("OK") + } + } + + private func connectClient( + socketPath: String, + explicitPassword: String?, + launchIfNeeded: Bool + ) throws -> SocketClient { + let client = SocketClient(path: socketPath) + if launchIfNeeded && (try? client.connect()) == nil { + client.close() + try launchApp() + + let pollClient = SocketClient(path: socketPath) + var connected = false + for _ in 0..<100 { + if (try? pollClient.connect()) != nil { + connected = true + break + } + pollClient.close() + Thread.sleep(forTimeInterval: 0.1) + } + guard connected else { + throw CLIError(message: "cmux app did not start in time (socket not found at \(socketPath))") + } + try authenticateClientIfNeeded(pollClient, explicitPassword: explicitPassword) + return pollClient + } + + try client.connect() + try authenticateClientIfNeeded(client, explicitPassword: explicitPassword) + return client + } + + private func authenticateClientIfNeeded(_ client: SocketClient, explicitPassword: String?) throws { + if let socketPassword = SocketPasswordResolver.resolve(explicit: explicitPassword) { + let authResponse = try client.send(command: "auth \(socketPassword)") + if authResponse.hasPrefix("ERROR:"), + !authResponse.contains("Unknown command 'auth'") { + throw CLIError(message: authResponse) + } + } + } + private func launchApp() throws { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/open") @@ -4056,6 +4213,37 @@ struct CMUXCLI { Show top-level CLI usage and command list. """ + case "welcome": + return """ + Usage: cmux welcome + + Show a welcome screen with the cmux logo and useful shortcuts. + Auto-runs once on first launch. + """ + case "shortcuts": + return """ + Usage: cmux shortcuts + + Open the Settings window to Keyboard Shortcuts. + """ + case "feedback": + return """ + Usage: cmux feedback + cmux feedback --email --body [--image ...] + + Without args, open the Send Feedback modal in the running app. + + With args, submit feedback through the app using the same feedback pipeline as the modal. + + Flags: + --email Contact email for follow-up + --body Feedback body + --image Attach an image file, repeat for multiple images + + Coding agents: + Double check with the end user before sending anything. Review the message and attachments for secrets, + private code, credentials, tokens, and other sensitive information first. + """ case "identify": return """ Usage: cmux identify [--workspace ] [--surface ] [--no-caller] @@ -5140,6 +5328,31 @@ struct CMUXCLI { return (value, remaining) } + private func parseRepeatedOption(_ args: [String], name: String) -> ([String], [String]) { + var remaining: [String] = [] + var values: [String] = [] + var skipNext = false + var pastTerminator = false + for (idx, arg) in args.enumerated() { + if skipNext { + skipNext = false + continue + } + if arg == "--" { + pastTerminator = true + remaining.append(arg) + continue + } + if !pastTerminator, arg == name, idx + 1 < args.count { + values.append(args[idx + 1]) + skipNext = true + continue + } + remaining.append(arg) + } + return (values, remaining) + } + private func optionValue(_ args: [String], name: String) -> String? { guard let index = args.firstIndex(of: name), index + 1 < args.count else { return nil } return args[index + 1] @@ -6652,6 +6865,61 @@ struct CMUXCLI { return "\(baseSummary) [\(commit)]" } + private func printWelcome() { + let reset = "\u{001B}[0m" + let bold = "\u{001B}[1m" + let dim = "\u{001B}[2m" + func trueColor(_ red: Int, _ green: Int, _ blue: Int) -> String { + "\u{001B}[38;2;\(red);\(green);\(blue)m" + } + let c1 = trueColor(0, 212, 255) + let c2 = trueColor(24, 181, 250) + let c3 = trueColor(48, 150, 245) + let c4 = trueColor(72, 119, 241) + let c5 = trueColor(96, 88, 239) + let c6 = trueColor(110, 73, 238) + let c7 = trueColor(124, 58, 237) + let tagline = trueColor(130, 130, 140) + + let logo = """ + \(c1) ::\(reset) + \(c2) ::::\(reset) \(c1)c\(c2)m\(c3)u\(c7)x\(reset) + \(c3) ::::::\(reset) + \(c4) ::::::\(reset) \(tagline)the open source terminal\(reset) + \(c5) ::::::\(reset) \(tagline)built for coding agents\(reset) + \(c6) ::::\(reset) + \(c7) ::\(reset) + """ + + let shortcuts = """ + \(bold)Shortcuts\(reset) + + \(bold)\u{2318}N\(reset)\(dim) New workspace\(reset) + \(bold)\u{2318}P\(reset)\(dim) Go to workspace\(reset) + \(bold)\u{2318}D\(reset)\(dim) Split right\(reset) + \(bold)\u{2318}\u{21E7}D\(reset)\(dim) Split down\(reset) + \(bold)\u{2318}\u{21E7}P\(reset)\(dim) Command palette\(reset) + \(bold)\u{2318}\u{21E7}R\(reset)\(dim) Rename workspace\(reset) + \(bold)\u{2318}\u{21E7}L\(reset)\(dim) New browser\(reset) + \(bold)\u{2318}\u{21E7}U\(reset)\(dim) Jump to latest unread\(reset) + """ + + print() + print(logo) + print() + print(shortcuts) + print() + print(" \(bold)Docs\(reset)\(dim) https://cmux.dev/docs\(reset)") + print(" \(bold)Discord\(reset)\(dim) https://discord.gg/xsgFEVrWCZ\(reset)") + print(" \(bold)GitHub\(reset)\(dim) https://github.com/manaflow-ai/cmux (please leave a star ⭐)\(reset)") + print(" \(bold)Email\(reset)\(dim) founders@manaflow.com\(reset)") + print() + print(" \(dim)Run \(reset)\(bold)cmux --help\(reset)\(dim) for all commands.\(reset)") + print(" \(dim)Run \(reset)\(bold)cmux shortcuts\(reset)\(dim) to edit shortcuts.\(reset)") + print(" \(dim)Run \(reset)\(bold)cmux feedback\(reset)\(dim) to report a bug.\(reset)") + print() + } + private func resolvedVersionInfo() -> [String: String] { var info: [String: String] = [:] if let main = versionInfo(from: Bundle.main.infoDictionary) { @@ -6924,7 +7192,7 @@ struct CMUXCLI { cmux [global-options] [options] Handle Inputs: - For most v2-backed commands you can use UUIDs, short refs (window:1/workspace:2/pane:3/surface:4), or indexes. + Use UUIDs, short refs (window:1/workspace:2/pane:3/surface:4), or indexes where commands accept window, workspace, pane, or surface inputs. `tab-action` also accepts `tab:` in addition to `surface:`. Output defaults to refs; pass --id-format uuids or --id-format both to include UUIDs. @@ -6933,6 +7201,9 @@ struct CMUXCLI { Commands: version + welcome + shortcuts + feedback [--email --body [--image ...]] ping capabilities identify [--workspace ] [--surface ] [--no-caller] @@ -7058,8 +7329,6 @@ struct CMUXCLI { CMUX_SURFACE_ID Auto-set in cmux terminals. Used as default --surface. CMUX_SOCKET_PATH Override the Unix socket path. Without this, the CLI defaults to /tmp/cmux.sock and auto-discovers tagged/debug sockets. - CMUX_CLI_SENTRY_DISABLED - Set to 1 to disable CLI Sentry socket diagnostics. """ } } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 2d15e651..337a9e16 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -805,6 +805,23 @@ } } }, + "sidebar.help.welcome": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Welcome" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ようこそ" + } + } + } + }, "sidebar.help.changelog": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 1447c796..10240c7d 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -3446,6 +3446,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent windowForMainWindowId(windowId) } + func mainWindowContainingWorkspace(_ workspaceId: UUID) -> NSWindow? { + for context in mainWindowContexts.values where context.tabManager.tabs.contains(where: { $0.id == workspaceId }) { + if let window = context.window ?? windowForMainWindowId(context.windowId) { + return window + } + } + return nil + } + func scriptableMainWindows() -> [ScriptableMainWindowState] { var results: [ScriptableMainWindowState] = [] var seen: Set = [] @@ -4897,6 +4906,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent updateController.checkForUpdates() } + func openWelcomeWorkspace() { + guard let context = preferredMainWindowContextForWorkspaceCreation(event: nil, debugSource: "welcome") else { + return + } + if let window = context.window ?? windowForMainWindowId(context.windowId) { + setActiveMainWindow(window) + bringToFront(window) + } + let workspace = context.tabManager.addWorkspace(select: true, autoWelcomeIfNeeded: false) + sendWelcomeCommandWhenReady(to: workspace) + } + + func sendWelcomeCommandWhenReady(to workspace: Workspace, markShownOnSend: Bool = false) { + sendTextWhenReady("cmux welcome\n", to: workspace) { + if markShownOnSend { + UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey) + } + } + } + @objc func applyUpdateIfAvailable(_ sender: Any?) { updateViewModel.overrideState = nil updateController.installUpdate() @@ -5026,7 +5055,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent SettingsWindowController.shared.show(navigationTarget: target) }, activateApplication: @MainActor () -> Void = { - NSApp.activate(ignoringOtherApps: true) + NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) } ) { #if DEBUG @@ -5034,6 +5063,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif showFallbackSettingsWindow(navigationTarget) activateApplication() + if let window = SettingsWindowController.shared.window { + window.orderFrontRegardless() + window.makeKeyAndOrderFront(nil) + DispatchQueue.main.async { + window.orderFrontRegardless() + window.makeKeyAndOrderFront(nil) + } + } #if DEBUG dlog("settings.open.present activate=1") #endif @@ -5464,9 +5501,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } - private func sendTextWhenReady(_ text: String, to tab: Tab, attempt: Int = 0) { + private func sendTextWhenReady(_ text: String, to tab: Tab, attempt: Int = 0, beforeSend: (() -> Void)? = nil) { let maxAttempts = 60 if let terminalPanel = tab.focusedTerminalPanel, terminalPanel.surface.surface != nil { + beforeSend?() terminalPanel.sendText(text) return } @@ -5475,7 +5513,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.sendTextWhenReady(text, to: tab, attempt: attempt + 1) + self?.sendTextWhenReady(text, to: tab, attempt: attempt + 1, beforeSend: beforeSend) } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 3473c398..6c03b213 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -8090,6 +8090,7 @@ private enum SidebarHelpMenuAction { case githubIssues case checkForUpdates case sendFeedback + case welcome } private struct SidebarFeedbackComposerSheet: View { @@ -8466,6 +8467,122 @@ private struct SidebarFeedbackComposerSheet: View { } } +enum FeedbackComposerBridgeError: LocalizedError { + case invalidEmail + case emptyMessage + case messageTooLong + case tooManyImages + case invalidImagePath(String) + case submissionFailed(String) + + var errorDescription: String? { + switch self { + case .invalidEmail: + return "Enter a valid email address." + case .emptyMessage: + return "Enter a message before sending." + case .messageTooLong: + return "Your message is too long." + case .tooManyImages: + return "You can attach up to 10 images." + case .invalidImagePath(let path): + return "Could not attach image: \(path)" + case .submissionFailed(let message): + return message + } + } +} + +enum FeedbackComposerBridge { + static func openComposer(in window: NSWindow? = NSApp.keyWindow ?? NSApp.mainWindow) { + NotificationCenter.default.post(name: .feedbackComposerRequested, object: window) + } + + static func submit( + email: String, + message: String, + imagePaths: [String] + ) async throws -> Int { + let trimmedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines) + + guard isValidEmail(trimmedEmail) else { + throw FeedbackComposerBridgeError.invalidEmail + } + guard normalizedMessage.isEmpty == false else { + throw FeedbackComposerBridgeError.emptyMessage + } + guard message.count <= FeedbackComposerSettings.maxMessageLength else { + throw FeedbackComposerBridgeError.messageTooLong + } + guard imagePaths.count <= FeedbackComposerSettings.maxAttachmentCount else { + throw FeedbackComposerBridgeError.tooManyImages + } + + let attachments = try imagePaths.map { rawPath in + let resolvedURL = URL(fileURLWithPath: rawPath).standardizedFileURL + do { + return try FeedbackComposerAttachment(url: resolvedURL) + } catch { + throw FeedbackComposerBridgeError.invalidImagePath(resolvedURL.path) + } + } + + do { + try await FeedbackComposerClient.submit( + email: trimmedEmail, + message: normalizedMessage, + attachments: attachments + ) + } catch { + throw FeedbackComposerBridgeError.submissionFailed(userFacingMessage(for: error)) + } + + UserDefaults.standard.set(trimmedEmail, forKey: FeedbackComposerSettings.storedEmailKey) + return attachments.count + } + + private static func isValidEmail(_ rawValue: String) -> Bool { + let email = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard email.isEmpty == false else { return false } + let pattern = #"^[A-Z0-9a-z._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$"# + return NSPredicate(format: "SELF MATCHES %@", pattern).evaluate(with: email) + } + + private static func userFacingMessage(for error: Error) -> String { + guard let submissionError = error as? FeedbackComposerSubmissionError else { + return "Couldn't send feedback. Please try again." + } + + switch submissionError { + case .invalidEndpoint: + return "Feedback is unavailable right now. Email founders@manaflow.com instead." + case .invalidResponse: + return "Couldn't send feedback. Please try again." + case .attachmentReadFailed: + return "One of the selected files could not be attached." + case .attachmentPreparationFailed: + return "These images are too large to send together. Remove a few and try again." + case .transport(let transportError): + if transportError.code == .notConnectedToInternet || transportError.code == .networkConnectionLost { + return "Couldn't send feedback. Check your connection and try again." + } + return "Couldn't send feedback. Please try again." + case .rejected(let statusCode): + switch statusCode { + case 400, 413, 415: + return "Check your message and attachments, then try again." + case 429: + return "Too many feedback attempts. Please try again later." + case 500...599: + return "Feedback is unavailable right now. Email founders@manaflow.com instead." + default: + return "Couldn't send feedback. Please try again." + } + } + } +} + private struct SidebarHelpMenuButton: View { private let docsURL = URL(string: "https://cmux.dev/docs") private let changelogURL = URL(string: "https://cmux.dev/docs/changelog") @@ -8514,6 +8631,12 @@ private struct SidebarHelpMenuButton: View { private var helpPopover: some View { VStack(alignment: .leading, spacing: 2) { + helpOptionButton( + title: String(localized: "sidebar.help.welcome", defaultValue: "Welcome"), + action: .welcome, + accessibilityIdentifier: "SidebarHelpMenuOptionWelcome", + isExternalLink: false + ) helpOptionButton( title: String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback"), action: .sendFeedback, @@ -8625,14 +8748,17 @@ private struct SidebarHelpMenuButton: View { private func perform(_ action: SidebarHelpMenuAction) { switch action { case .keyboardShortcuts: - Task { @MainActor in - if let appDelegate = AppDelegate.shared { - appDelegate.openPreferencesWindow( - debugSource: "sidebarHelpMenu.keyboardShortcuts", - navigationTarget: .keyboardShortcuts - ) - } else { - AppDelegate.presentPreferencesWindow(navigationTarget: .keyboardShortcuts) + isPopoverPresented = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { + Task { @MainActor in + if let appDelegate = AppDelegate.shared { + appDelegate.openPreferencesWindow( + debugSource: "sidebarHelpMenu.keyboardShortcuts", + navigationTarget: .keyboardShortcuts + ) + } else { + AppDelegate.presentPreferencesWindow(navigationTarget: .keyboardShortcuts) + } } } case .docs: @@ -8654,6 +8780,13 @@ private struct SidebarHelpMenuButton: View { case .sendFeedback: isPopoverPresented = false onSendFeedback() + case .welcome: + isPopoverPresented = false + Task { @MainActor in + if let appDelegate = AppDelegate.shared { + appDelegate.openWelcomeWorkspace() + } + } } } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 07b13a75..31c1198a 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -813,7 +813,8 @@ class TabManager: ObservableObject { workingDirectory overrideWorkingDirectory: String? = nil, select: Bool = true, eagerLoadTerminal: Bool = false, - placementOverride: NewWorkspacePlacement? = nil + placementOverride: NewWorkspacePlacement? = nil, + autoWelcomeIfNeeded: Bool = true ) -> Workspace { sentryBreadcrumb("workspace.create", data: ["tabCount": tabs.count + 1]) let explicitWorkingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) @@ -861,9 +862,33 @@ class TabManager: ObservableObject { "selectedTabId": select ? newWorkspace.id.uuidString : (selectedTabId?.uuidString ?? "") ]) #endif + if autoWelcomeIfNeeded && select && !UserDefaults.standard.bool(forKey: WelcomeSettings.shownKey) { + if let appDelegate = AppDelegate.shared { + appDelegate.sendWelcomeCommandWhenReady(to: newWorkspace, markShownOnSend: true) + } else { + sendWelcomeWhenReady(to: newWorkspace) + } + } return newWorkspace } + private func sendWelcomeWhenReady(to workspace: Workspace, attempt: Int = 0) { + let maxAttempts = 60 + if let terminalPanel = workspace.focusedTerminalPanel, + terminalPanel.surface.surface != nil { + // Wait a bit more for the shell prompt to be ready + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey) + terminalPanel.sendText("cmux welcome\n") + } + return + } + guard attempt < maxAttempts else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.sendWelcomeWhenReady(to: workspace, attempt: attempt + 1) + } + } + private func scheduleInitialWorkspaceGitMetadataRefresh( workspaceId: UUID, panelId: UUID, diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 8f5a2ed3..44fd70cf 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1737,6 +1737,16 @@ class TerminalController { case "workspace.last": return v2Result(id: id, self.v2WorkspaceLast(params: params)) + // Settings + case "settings.open": + return v2Result(id: id, self.v2SettingsOpen(params: params)) + + // Feedback + case "feedback.open": + return v2Result(id: id, self.v2FeedbackOpen(params: params)) + case "feedback.submit": + return v2Result(id: id, self.v2FeedbackSubmit(params: params)) + // Surfaces / input case "surface.list": @@ -2096,6 +2106,9 @@ class TerminalController { "workspace.next", "workspace.previous", "workspace.last", + "settings.open", + "feedback.open", + "feedback.submit", "surface.list", "surface.current", "surface.focus", @@ -5382,6 +5395,109 @@ class TerminalController { return .ok([:]) } + private func v2FeedbackOpen(params: [String: Any]) -> V2CallResult { + let workspaceId = v2UUID(params, "workspace_id") + let windowId = v2UUID(params, "window_id") + let shouldActivate = v2Bool(params, "activate") ?? false + DispatchQueue.main.async { + let targetWindow: NSWindow? + if let windowId, let app = AppDelegate.shared { + targetWindow = app.mainWindow(for: windowId) + } else if let workspaceId, let app = AppDelegate.shared { + targetWindow = app.mainWindowContainingWorkspace(workspaceId) + } else { + targetWindow = nil + } + + if shouldActivate { + if let targetWindow { + targetWindow.makeKeyAndOrderFront(nil) + NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + } else { + NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + } + } + + FeedbackComposerBridge.openComposer(in: targetWindow) + } + return .ok(["opened": true]) + } + + private func v2SettingsOpen(params: [String: Any]) -> V2CallResult { + let targetRaw = v2String(params, "target") + let shouldActivate = v2Bool(params, "activate") ?? true + + let navigationTarget: SettingsNavigationTarget? + switch targetRaw { + case nil: + navigationTarget = nil + case SettingsNavigationTarget.keyboardShortcuts.rawValue: + navigationTarget = .keyboardShortcuts + default: + return .err(code: "invalid_params", message: "Unknown settings target", data: ["target": targetRaw ?? ""]) + } + + DispatchQueue.main.async { + if shouldActivate { + AppDelegate.presentPreferencesWindow(navigationTarget: navigationTarget) + } else { + SettingsWindowController.shared.show(navigationTarget: navigationTarget) + } + } + return .ok([ + "opened": true, + "target": navigationTarget?.rawValue ?? "general", + ]) + } + + private func v2FeedbackSubmit(params: [String: Any]) -> V2CallResult { + guard let email = params["email"] as? String else { + return .err(code: "invalid_params", message: "Missing email", data: ["field": "email"]) + } + guard let body = params["body"] as? String else { + return .err(code: "invalid_params", message: "Missing body", data: ["field": "body"]) + } + let imagePaths = params["image_paths"] as? [String] ?? [] + + let semaphore = DispatchSemaphore(value: 0) + var result: V2CallResult = .err(code: "internal_error", message: "Feedback submission failed", data: nil) + + Task { + let resolved: V2CallResult + do { + let attachmentCount = try await FeedbackComposerBridge.submit( + email: email, + message: body, + imagePaths: imagePaths + ) + resolved = .ok([ + "submitted": true, + "attachment_count": attachmentCount, + ]) + } catch let error as FeedbackComposerBridgeError { + let code: String + switch error { + case .invalidEmail, .emptyMessage, .messageTooLong, .tooManyImages, .invalidImagePath: + code = "invalid_params" + case .submissionFailed: + code = "request_failed" + } + resolved = .err(code: code, message: error.localizedDescription, data: nil) + } catch { + resolved = .err(code: "internal_error", message: error.localizedDescription, data: nil) + } + + result = resolved + semaphore.signal() + } + + if semaphore.wait(timeout: .now() + 35) == .timedOut { + return .err(code: "timeout", message: "Feedback submission timed out", data: nil) + } + + return result + } + // MARK: - V2 App Focus Methods private func v2AppFocusOverride(params: [String: Any]) -> V2CallResult { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index e4c7d1fa..a85a5e94 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2815,6 +2815,10 @@ enum ClaudeCodeIntegrationSettings { } } +enum WelcomeSettings { + static let shownKey = "cmuxWelcomeShown" +} + enum TelemetrySettings { static let sendAnonymousTelemetryKey = "sendAnonymousTelemetry" static let defaultSendAnonymousTelemetry = true