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:
parent
d25067f38f
commit
52783bddf0
7 changed files with 629 additions and 27 deletions
299
CLI/cmux.swift
299
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 <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.
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -2815,6 +2815,10 @@ enum ClaudeCodeIntegrationSettings {
|
|||
}
|
||||
}
|
||||
|
||||
enum WelcomeSettings {
|
||||
static let shownKey = "cmuxWelcomeShown"
|
||||
}
|
||||
|
||||
enum TelemetrySettings {
|
||||
static let sendAnonymousTelemetryKey = "sendAnonymousTelemetry"
|
||||
static let defaultSendAnonymousTelemetry = true
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue