Polish welcome, feedback, and shortcuts flows (#1169)

* Add cmux welcome command with ASCII logo and shortcuts

Adds `cmux welcome` CLI command that prints a blue-to-purple gradient
chevron logo, version info, and key shortcuts. Auto-runs once on first
workspace creation via UserDefaults. Adds "Welcome" to the ? help menu
in the sidebar footer, which opens a new workspace running the command.

* Polish welcome, feedback, and shortcuts flows
This commit is contained in:
Lawrence Chen 2026-03-10 20:59:34 -07:00 committed by GitHub
parent d25067f38f
commit 52783bddf0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 629 additions and 27 deletions

View file

@ -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 <email>, --body <text>, --image <path>")
}
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 <email> when sending feedback")
}
guard let body = bodyOpt, body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else {
throw CLIError(message: "feedback requires --body <text> 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 <email> --body <text> [--image <path> ...]
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 <email> Contact email for follow-up
--body <text> Feedback body
--image <path> 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 <id|ref|index>] [--surface <id|ref|index>] [--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] <command> [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:<n>` in addition to `surface:<n>`.
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 <email> --body <text> [--image <path> ...]]
ping
capabilities
identify [--workspace <id|ref|index>] [--surface <id|ref|index>] [--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.
"""
}
}

View file

@ -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": {

View file

@ -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<UUID> = []
@ -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)
}
}

View file

@ -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()
}
}
}
}

View file

@ -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,

View file

@ -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 {

View file

@ -2815,6 +2815,10 @@ enum ClaudeCodeIntegrationSettings {
}
}
enum WelcomeSettings {
static let shownKey = "cmuxWelcomeShown"
}
enum TelemetrySettings {
static let sendAnonymousTelemetryKey = "sendAnonymousTelemetry"
static let defaultSendAnonymousTelemetry = true