diff --git a/CLI/cmux.swift b/CLI/cmux.swift index fee11771fb25..b2a0bc768631 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -3406,6 +3406,13 @@ struct CMUXCLI { ) print(response) + case "right-sidebar": + try forwardRightSidebarCommand( + commandArgs: commandArgs, + client: client, + windowOverride: windowId + ) + case "claude-hook": cliTelemetry.breadcrumb("claude-hook.dispatch") do { @@ -10028,6 +10035,34 @@ struct CMUXCLI { cmux sidebar-state cmux sidebar-state --workspace workspace:2 """ + case "right-sidebar": + return String(localized: "cli.rightSidebar.usage", defaultValue: """ + Usage: cmux right-sidebar [flags] + + Control the right sidebar from the CLI. + + Commands: + toggle Toggle right sidebar visibility + show Show the right sidebar + hide Hide the right sidebar + focus Focus the current right sidebar mode + set + Show, switch mode, and focus + mode Print {"visible":bool,"mode":string} + files|find|vault|sessions|feed|dock + Alias for show + set + focus + + Flags: + --workspace Target the window containing a workspace + --window Target a window + --no-focus With set, switch mode without moving focus + + Examples: + cmux right-sidebar toggle + cmux right-sidebar set find + cmux right-sidebar set vault --no-focus + cmux right-sidebar mode + """) case "set-app-focus": return """ Usage: cmux set-app-focus @@ -10342,6 +10377,212 @@ struct CMUXCLI { return try sendV1Command(command, client: client) } + private struct RightSidebarCLIArguments { + let positional: [String] + let workspace: String? + let window: String? + let noFocus: Bool + } + + private func forwardRightSidebarCommand( + commandArgs: [String], + client: SocketClient, + windowOverride: String? + ) throws { + let parsed = try parseRightSidebarCLIArguments(commandArgs) + let socketArgs = try rightSidebarSocketArguments(from: parsed) + let windowId = try resolveRightSidebarWindowId(parsed.window ?? windowOverride, client: client) + let workspaceId = try resolveRightSidebarWorkspaceId(parsed.workspace, windowId: windowId, client: client) + + var forwardedArgs = socketArgs + if let workspaceId { + forwardedArgs.append("--tab=\(workspaceId)") + } + if let windowId { + forwardedArgs.append("--window=\(windowId)") + } + + let command = (["right_sidebar"] + forwardedArgs) + .map(shellQuote) + .joined(separator: " ") + let response = try sendV1Command(command, client: client) + if parsed.positional.first?.lowercased() == "mode" { + print(response) + } + } + + private func parseRightSidebarCLIArguments(_ args: [String]) throws -> RightSidebarCLIArguments { + var positional: [String] = [] + var workspace: String? + var window: String? + var noFocus = false + var index = 0 + + while index < args.count { + let arg = args[index] + switch arg { + case "--workspace": + guard index + 1 < args.count else { + throw CLIError(message: String(localized: "cli.rightSidebar.error.workspaceRequiresValue", defaultValue: "right-sidebar: --workspace requires an id")) + } + workspace = args[index + 1] + index += 2 + case "--window": + guard index + 1 < args.count else { + throw CLIError(message: String(localized: "cli.rightSidebar.error.windowRequiresValue", defaultValue: "right-sidebar: --window requires an id")) + } + window = args[index + 1] + index += 2 + case "--no-focus": + noFocus = true + index += 1 + default: + if arg.hasPrefix("--workspace=") { + workspace = String(arg.dropFirst("--workspace=".count)) + index += 1 + } else if arg.hasPrefix("--window=") { + window = String(arg.dropFirst("--window=".count)) + index += 1 + } else if arg.hasPrefix("--") { + throw CLIError(message: String(localized: "cli.rightSidebar.error.unknownFlag", defaultValue: "right-sidebar: unknown flag '\(arg)'")) + } else { + positional.append(arg) + index += 1 + } + } + } + + return RightSidebarCLIArguments( + positional: positional, + workspace: workspace, + window: window, + noFocus: noFocus + ) + } + + private func rightSidebarSocketArguments(from parsed: RightSidebarCLIArguments) throws -> [String] { + guard let action = parsed.positional.first?.lowercased() else { + throw CLIError(message: String(localized: "cli.rightSidebar.error.missingCommand", defaultValue: "right-sidebar requires a subcommand")) + } + + switch action { + case "toggle", "show", "hide", "focus", "mode": + guard parsed.positional.count == 1 else { + throw CLIError(message: String(localized: "cli.rightSidebar.error.unexpectedArguments", defaultValue: "right-sidebar \(action) received unexpected arguments")) + } + guard !parsed.noFocus else { + throw CLIError(message: String(localized: "cli.rightSidebar.error.noFocusOnlySet", defaultValue: "right-sidebar: --no-focus is only valid with set")) + } + return [action] + + case "set": + guard parsed.positional.count == 2 else { + throw CLIError(message: String(localized: "cli.rightSidebar.error.setRequiresMode", defaultValue: "right-sidebar set requires a mode: files, find, vault, sessions, feed, or dock")) + } + let mode = parsed.positional[1].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard isRightSidebarCLIMode(mode) else { + throw CLIError(message: String(localized: "cli.rightSidebar.error.unknownMode", defaultValue: "Unknown right-sidebar mode '\(parsed.positional[1])'")) + } + var args = ["set", mode] + if parsed.noFocus { + args.append("--no-focus") + } + return args + + case "files", "find", "vault", "sessions", "feed", "dock": + guard parsed.positional.count == 1 else { + throw CLIError(message: String(localized: "cli.rightSidebar.error.unexpectedArguments", defaultValue: "right-sidebar \(action) received unexpected arguments")) + } + guard !parsed.noFocus else { + throw CLIError(message: String(localized: "cli.rightSidebar.error.noFocusOnlySet", defaultValue: "right-sidebar: --no-focus is only valid with set")) + } + return ["set", action] + + default: + throw CLIError(message: String(localized: "cli.rightSidebar.error.unknownCommand", defaultValue: "Unknown right-sidebar command '\(action)'")) + } + } + + private func isRightSidebarCLIMode(_ value: String) -> Bool { + switch value { + case "files", "find", "vault", "sessions", "feed", "dock": + return true + default: + return false + } + } + + private func resolveRightSidebarWindowId(_ raw: String?, client: SocketClient) throws -> String? { + guard let normalized = try normalizeWindowHandle(raw, client: client) else { return nil } + return try resolvedRightSidebarHandleID( + normalized, + expectedRefKind: "window", + invalidMessage: String(localized: "cli.rightSidebar.error.invalidWindow", defaultValue: "Invalid window handle: \(normalized)"), + missingRefMessage: String(localized: "cli.rightSidebar.error.windowRefNotFound", defaultValue: "Window ref not found"), + listMethod: "window.list", + listKey: "windows", + client: client + ) + } + + private func resolveRightSidebarWorkspaceId( + _ raw: String?, + windowId: String?, + client: SocketClient + ) throws -> String? { + var params: [String: Any] = [:] + if let windowId { + params["window_id"] = windowId + } + guard let normalized = try normalizeWorkspaceHandle(raw, client: client, windowHandle: windowId) else { return nil } + return try resolvedRightSidebarHandleID( + normalized, + expectedRefKind: "workspace", + invalidMessage: String(localized: "cli.rightSidebar.error.invalidWorkspace", defaultValue: "Invalid workspace handle: \(normalized)"), + missingRefMessage: String(localized: "cli.rightSidebar.error.workspaceRefNotFound", defaultValue: "Workspace ref not found"), + listMethod: "workspace.list", + listKey: "workspaces", + listParams: params, + client: client + ) + } + + private func resolvedRightSidebarHandleID( + _ handle: String, + expectedRefKind: String, + invalidMessage: String, + missingRefMessage: String, + listMethod: String, + listKey: String, + listParams: [String: Any] = [:], + client: SocketClient + ) throws -> String { + let trimmed = handle.trimmingCharacters(in: .whitespacesAndNewlines) + if isUUID(trimmed) { return trimmed } + let refIndex: Int? + if isHandleRef(trimmed) { + let pieces = trimmed.split(separator: ":", omittingEmptySubsequences: false) + guard pieces.count == 2, pieces[0].lowercased() == expectedRefKind else { + throw CLIError(message: invalidMessage) + } + refIndex = Int(pieces[1]) + } else { + refIndex = nil + } + + let listed = try client.sendV2(method: listMethod, params: listParams) + let items = listed[listKey] as? [[String: Any]] ?? [] + for item in items { + guard let id = item["id"] as? String else { continue } + if id == trimmed || + (item["ref"] as? String) == trimmed || + (refIndex != nil && intFromAny(item["index"]) == refIndex) { + return id + } + } + throw CLIError(message: missingRefMessage) + } + /// Pick the display handle for an item dict based on --id-format. private func textHandle(_ item: [String: Any], idFormat: CLIIDFormat) -> String { let ref = item["ref"] as? String @@ -20638,6 +20879,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { notify --title [--subtitle ] [--body ] [--workspace ] [--surface ] list-notifications clear-notifications + right-sidebar [--workspace ] [--window ] [--no-focus] set-status [--workspace ] [--icon ] [--color <#hex>] clear-status [--workspace ] list-status [--workspace ] diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 897fb006a6db..d11940b9ec11 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -426,6 +426,7 @@ B35750000000000000000006 /* VaultAgentProcessScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B35750000000000000000005 /* VaultAgentProcessScanner.swift */; }; FE003102 /* SessionIndexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003002 /* SessionIndexView.swift */; }; B37A00000000000000000003 /* RightSidebarMode+Availability.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37A00000000000000000004 /* RightSidebarMode+Availability.swift */; }; + B37A0000000000000000000D /* RightSidebarRemoteCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37A0000000000000000000E /* RightSidebarRemoteCommand.swift */; }; FE003103 /* RightSidebarPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003003 /* RightSidebarPanelView.swift */; }; FE003104 /* SessionIndexStore+CodexSQL.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003004 /* SessionIndexStore+CodexSQL.swift */; }; FE003105 /* RovoDevIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003005 /* RovoDevIndex.swift */; }; @@ -573,6 +574,7 @@ B7F9A603B7F9A603B7F9A603 /* WindowChromeMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowChromeMetrics.swift; sourceTree = ""; }; B7F9A605B7F9A605B7F9A605 /* RightSidebarChromeStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarChromeStyle.swift; sourceTree = ""; }; B37A00000000000000000004 /* RightSidebarMode+Availability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RightSidebarMode+Availability.swift"; sourceTree = ""; }; + B37A0000000000000000000E /* RightSidebarRemoteCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarRemoteCommand.swift; sourceTree = ""; }; B37A00000000000000000006 /* MainWindowFocusTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowFocusTypes.swift; sourceTree = ""; }; A11EAB000000000000000001 /* AppearanceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettings.swift; sourceTree = ""; }; A11EAC000000000000000001 /* GhosttyTerminalAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalAppearance.swift; sourceTree = ""; }; @@ -1311,6 +1313,7 @@ B7F9A605B7F9A605B7F9A605 /* RightSidebarChromeStyle.swift */, B7F9A603B7F9A603B7F9A603 /* WindowChromeMetrics.swift */, B37A00000000000000000004 /* RightSidebarMode+Availability.swift */, + B37A0000000000000000000E /* RightSidebarRemoteCommand.swift */, FE003003 /* RightSidebarPanelView.swift */, D0C0D0C0D0C0D0C0D0C0D002 /* DockPanelView.swift */, D0C0D0C0D0C0D0C0D0C0D004 /* DockEmptyView.swift */, @@ -1949,6 +1952,7 @@ B7F9A604B7F9A604B7F9A604 /* RightSidebarChromeStyle.swift in Sources */, B7F9A602B7F9A602B7F9A602 /* WindowChromeMetrics.swift in Sources */, B37A00000000000000000003 /* RightSidebarMode+Availability.swift in Sources */, + B37A0000000000000000000D /* RightSidebarRemoteCommand.swift in Sources */, FE003103 /* RightSidebarPanelView.swift in Sources */, D0C0D0C0D0C0D0C0D0C0D001 /* DockPanelView.swift in Sources */, D0C0D0C0D0C0D0C0D0C0D003 /* DockEmptyView.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index f51105c327ad..c9fc552c0c53 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -111175,6 +111175,46 @@ "en": { "stringUnit": { "state": "translated", "value": "Process" } }, "ja": { "stringUnit": { "state": "translated", "value": "プロセス" } } } - } + }, + "cli.rightSidebar.usage": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Usage: cmux right-sidebar [flags]\n\nControl the right sidebar from the CLI.\n\nCommands:\n toggle Toggle right sidebar visibility\n show Show the right sidebar\n hide Hide the right sidebar\n focus Focus the current right sidebar mode\n set \n Show, switch mode, and focus\n mode Print {\"visible\":bool,\"mode\":string}\n files|find|vault|sessions|feed|dock\n Alias for show + set + focus\n\nFlags:\n --workspace Target the window containing a workspace\n --window Target a window\n --no-focus With set, switch mode without moving focus\n\nExamples:\n cmux right-sidebar toggle\n cmux right-sidebar set find\n cmux right-sidebar set vault --no-focus\n cmux right-sidebar mode" } }, + "ja": { "stringUnit": { "state": "translated", "value": "使用法: cmux right-sidebar [flags]\n\nCLI から右サイドバーを操作します。\n\nコマンド:\n toggle 右サイドバーの表示を切り替えます\n show 右サイドバーを表示します\n hide 右サイドバーを隠します\n focus 現在の右サイドバーモードにフォーカスします\n set \n 表示して、モードを切り替え、フォーカスします\n mode {\"visible\":bool,\"mode\":string} を出力します\n files|find|vault|sessions|feed|dock\n show + set + focus のエイリアス\n\nフラグ:\n --workspace ワークスペースを含むウィンドウを対象にします\n --window ウィンドウを対象にします\n --no-focus set で、フォーカスを移さずにモードだけ切り替えます\n\n例:\n cmux right-sidebar toggle\n cmux right-sidebar set find\n cmux right-sidebar set vault --no-focus\n cmux right-sidebar mode" } } + } + }, + "cli.rightSidebar.error.workspaceRequiresValue": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "right-sidebar: --workspace requires an id" } }, "ja": { "stringUnit": { "state": "translated", "value": "right-sidebar: --workspace には ID が必要です" } } } }, + "cli.rightSidebar.error.windowRequiresValue": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "right-sidebar: --window requires an id" } }, "ja": { "stringUnit": { "state": "translated", "value": "right-sidebar: --window には ID が必要です" } } } }, + "cli.rightSidebar.error.unknownFlag": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "right-sidebar: unknown flag '%@'" } }, "ja": { "stringUnit": { "state": "translated", "value": "right-sidebar: 不明なフラグ '%@'" } } } }, + "cli.rightSidebar.error.missingCommand": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "right-sidebar requires a subcommand" } }, "ja": { "stringUnit": { "state": "translated", "value": "right-sidebar にはサブコマンドが必要です" } } } }, + "cli.rightSidebar.error.unexpectedArguments": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "right-sidebar %@ received unexpected arguments" } }, "ja": { "stringUnit": { "state": "translated", "value": "right-sidebar %@ に予期しない引数があります" } } } }, + "cli.rightSidebar.error.noFocusOnlySet": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "right-sidebar: --no-focus is only valid with set" } }, "ja": { "stringUnit": { "state": "translated", "value": "right-sidebar: --no-focus は set でのみ使えます" } } } }, + "cli.rightSidebar.error.setRequiresMode": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "right-sidebar set requires a mode: files, find, vault, sessions, feed, or dock" } }, "ja": { "stringUnit": { "state": "translated", "value": "right-sidebar set にはモードが必要です: files, find, vault, sessions, feed, dock" } } } }, + "cli.rightSidebar.error.unknownMode": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Unknown right-sidebar mode '%@'" } }, "ja": { "stringUnit": { "state": "translated", "value": "不明な right-sidebar モード '%@'" } } } }, + "cli.rightSidebar.error.unknownCommand": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Unknown right-sidebar command '%@'" } }, "ja": { "stringUnit": { "state": "translated", "value": "不明な right-sidebar コマンド '%@'" } } } }, + "cli.rightSidebar.error.invalidWindow": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Invalid window handle: %@" } }, "ja": { "stringUnit": { "state": "translated", "value": "無効なウィンドウ指定: %@" } } } }, + "cli.rightSidebar.error.invalidWorkspace": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Invalid workspace handle: %@" } }, "ja": { "stringUnit": { "state": "translated", "value": "無効なワークスペース指定: %@" } } } }, + "cli.rightSidebar.error.windowRefNotFound": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Window ref not found" } }, "ja": { "stringUnit": { "state": "translated", "value": "ウィンドウ参照が見つかりません" } } } }, + "cli.rightSidebar.error.workspaceRefNotFound": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Workspace ref not found" } }, "ja": { "stringUnit": { "state": "translated", "value": "ワークスペース参照が見つかりません" } } } }, + "rightSidebar.remote.error.optionRequiresID": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: %@ requires an id" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: %@ には ID が必要です" } } } }, + "rightSidebar.remote.error.unknownOption": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: Unknown right sidebar option '%@'" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: 不明な右サイドバーオプション '%@'" } } } }, + "rightSidebar.remote.error.usage": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: Usage: right_sidebar [mode] [--workspace=] [--window=] [--no-focus]" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: 使用法: right_sidebar [mode] [--workspace=] [--window=] [--no-focus]" } } } }, + "rightSidebar.remote.error.usage.toggle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: Usage: right_sidebar toggle [--workspace=] [--window=]" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: 使用法: right_sidebar toggle [--workspace=] [--window=]" } } } }, + "rightSidebar.remote.error.usage.show": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: Usage: right_sidebar show [--workspace=] [--window=]" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: 使用法: right_sidebar show [--workspace=] [--window=]" } } } }, + "rightSidebar.remote.error.usage.hide": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: Usage: right_sidebar hide [--workspace=] [--window=]" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: 使用法: right_sidebar hide [--workspace=] [--window=]" } } } }, + "rightSidebar.remote.error.usage.focus": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: Usage: right_sidebar focus [--workspace=] [--window=]" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: 使用法: right_sidebar focus [--workspace=] [--window=]" } } } }, + "rightSidebar.remote.error.usage.mode": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: Usage: right_sidebar mode [--workspace=] [--window=]" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: 使用法: right_sidebar mode [--workspace=] [--window=]" } } } }, + "rightSidebar.remote.error.usage.set": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: Usage: right_sidebar set [--no-focus] [--workspace=] [--window=]" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: 使用法: right_sidebar set [--no-focus] [--workspace=] [--window=]" } } } }, + "rightSidebar.remote.error.unknownMode": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: Unknown right sidebar mode '%@'" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: 不明な右サイドバーモード '%@'" } } } }, + "rightSidebar.remote.error.noFocusOnlySet": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: --no-focus is only valid with right_sidebar set" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: --no-focus は right_sidebar set でのみ使えます" } } } }, + "rightSidebar.remote.error.unknownCommand": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: Unknown right sidebar command '%@'" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: 不明な右サイドバーコマンド '%@'" } } } }, + "rightSidebar.remote.error.invalidTargetID": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: Invalid right sidebar --%@ id '%@'" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: 無効な右サイドバー --%@ ID '%@'" } } } }, + "rightSidebar.remote.error.unknownTargetOption": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: Unknown right sidebar target option '%@'" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: 不明な右サイドバー対象オプション '%@'" } } } }, + "rightSidebar.remote.error.targetNotFound": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: Right sidebar target not found" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: 右サイドバーの対象が見つかりません" } } } }, + "rightSidebar.remote.error.stateUnavailable": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: Right sidebar state not available" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: 右サイドバーの状態を利用できません" } } } }, + "rightSidebar.remote.error.unavailable": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: Right sidebar not available" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: 右サイドバーを利用できません" } } } }, + "rightSidebar.remote.error.focusFailed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: Failed to focus right sidebar" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: 右サイドバーにフォーカスできませんでした" } } } }, + "rightSidebar.remote.error.modeUnavailable": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: Right sidebar mode '%@' is not available" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: 右サイドバーのモード '%@' は利用できません" } } } }, + "rightSidebar.remote.error.appDelegateUnavailable": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: App delegate not available" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: アプリデリゲートを利用できません" } } } } } } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index df9d93cfa3c3..a36f373a905c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -3728,14 +3728,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func registerMainWindowContextForTesting( windowId: UUID = UUID(), - tabManager: TabManager + tabManager: TabManager, + fileExplorerState: FileExplorerState? = nil ) -> UUID { mainWindowContexts[ObjectIdentifier(tabManager)] = MainWindowContext( windowId: windowId, tabManager: tabManager, sidebarState: SidebarState(), sidebarSelectionState: SidebarSelectionState(), - fileExplorerState: nil, + fileExplorerState: fileExplorerState, cmuxConfigStore: nil, window: nil ) @@ -5472,6 +5473,105 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + func applyRightSidebarRemoteCommand( + _ command: RightSidebarRemoteCommand, + target: RightSidebarRemoteTarget = RightSidebarRemoteTarget() + ) -> RightSidebarRemoteApplyResult { + let context = rightSidebarRemoteContext(target: target) + if !target.isActiveTarget, context == nil { + return .failure(String(localized: "rightSidebar.remote.error.targetNotFound", defaultValue: "ERROR: Right sidebar target not found")) + } + let state: FileExplorerState? + if target.isActiveTarget { + state = context?.fileExplorerState ?? fileExplorerState + } else { + state = context?.fileExplorerState + } + guard let state else { + return .failure(String(localized: "rightSidebar.remote.error.stateUnavailable", defaultValue: "ERROR: Right sidebar state not available")) + } + + let preferredWindow = context.flatMap { $0.window ?? windowForMainWindowId($0.windowId) } + let requiresWindowFocus: Bool + switch command { + case .focus: + requiresWindowFocus = true + case .setMode(_, let focus): + requiresWindowFocus = focus + case .toggle, .show, .hide, .getState: + requiresWindowFocus = false + } + if requiresWindowFocus, !target.isActiveTarget, preferredWindow == nil { + return .failure(String(localized: "rightSidebar.remote.error.targetNotFound", defaultValue: "ERROR: Right sidebar target not found")) + } + + switch command { + case .toggle: + guard target.isActiveTarget || preferredWindow != nil else { + return .failure(String(localized: "rightSidebar.remote.error.targetNotFound", defaultValue: "ERROR: Right sidebar target not found")) + } + guard toggleRightSidebarInActiveMainWindow(preferredWindow: preferredWindow) else { + return .failure(String(localized: "rightSidebar.remote.error.unavailable", defaultValue: "ERROR: Right sidebar not available")) + } + return .ok + + case .show: + guard !state.isVisible else { + return .ok + } + guard target.isActiveTarget || preferredWindow != nil else { + return .failure(String(localized: "rightSidebar.remote.error.targetNotFound", defaultValue: "ERROR: Right sidebar target not found")) + } + guard toggleRightSidebarInActiveMainWindow(preferredWindow: preferredWindow) else { + return .failure(String(localized: "rightSidebar.remote.error.unavailable", defaultValue: "ERROR: Right sidebar not available")) + } + return .ok + + case .hide: + let wasVisible = state.isVisible + state.setVisible(false) + if wasVisible { + _ = context?.keyboardFocusCoordinator.restoreTerminalFocusAfterRightSidebarHiddenIfNeeded() + } + return .ok + + case .focus: + guard focusRightSidebarInActiveMainWindow(preferredWindow: preferredWindow) else { + return .failure(String(localized: "rightSidebar.remote.error.focusFailed", defaultValue: "ERROR: Failed to focus right sidebar")) + } + return .ok + + case .setMode(let mode, let focus): + guard mode.isAvailable() else { + return .failure(String(localized: "rightSidebar.remote.error.modeUnavailable", defaultValue: "ERROR: Right sidebar mode '\(mode.rawValue)' is not available")) + } + if focus { + guard focusRightSidebarInActiveMainWindow(mode: mode, focusFirstItem: true, preferredWindow: preferredWindow) else { + return .failure(String(localized: "rightSidebar.remote.error.focusFailed", defaultValue: "ERROR: Failed to focus right sidebar")) + } + } else { + state.setVisible(true) + state.mode = mode + } + return .ok + + case .getState: + return .state(.init(visible: state.isVisible, mode: state.mode)) + } + } + + private func rightSidebarRemoteContext(target: RightSidebarRemoteTarget) -> MainWindowContext? { + if let windowId = target.windowId { + return mainWindowContexts.values.first(where: { $0.windowId == windowId }) + } + if let workspaceId = target.workspaceId { + return mainWindowContexts.values.first { context in + context.tabManager.tabs.contains(where: { $0.id == workspaceId }) + } + } + return preferredRegisteredMainWindowContext() + } + @discardableResult func closeRightSidebarInActiveMainWindow(preferredWindow: NSWindow? = nil) -> Bool { guard let context = preferredRegisteredMainWindowContext(preferredWindow: preferredWindow) else { diff --git a/Sources/RightSidebarMode+Availability.swift b/Sources/RightSidebarMode+Availability.swift index 5409f6dff0da..f7ed79aea599 100644 --- a/Sources/RightSidebarMode+Availability.swift +++ b/Sources/RightSidebarMode+Availability.swift @@ -1,6 +1,23 @@ import Foundation extension RightSidebarMode { + static func from(cliArgument rawValue: String) -> RightSidebarMode? { + switch rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "files": + return .files + case "find": + return .find + case "vault", "sessions": + return .sessions + case "feed": + return .feed + case "dock": + return .dock + default: + return nil + } + } + static func availableModes(defaults: UserDefaults = .standard) -> [RightSidebarMode] { availableModes( feedEnabled: RightSidebarBetaFeatureSettings.isFeedEnabled(defaults: defaults), diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index 66f2dec1b168..77c22e825c13 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -10,7 +10,7 @@ private func rightSidebarDebugResponder(_ responder: NSResponder?) -> String { } /// Mode shown in the right sidebar (the panel toggled by ⌘⌥B). -enum RightSidebarMode: String, CaseIterable { +nonisolated enum RightSidebarMode: String, CaseIterable, Sendable { case files case find case sessions diff --git a/Sources/RightSidebarRemoteCommand.swift b/Sources/RightSidebarRemoteCommand.swift new file mode 100644 index 000000000000..9729e540de27 --- /dev/null +++ b/Sources/RightSidebarRemoteCommand.swift @@ -0,0 +1,164 @@ +import Foundation + +nonisolated struct RightSidebarRemoteTarget: Equatable, Sendable { + var windowId: UUID? = nil + var workspaceId: UUID? = nil + + var isActiveTarget: Bool { + windowId == nil && workspaceId == nil + } +} + +nonisolated enum RightSidebarRemoteCommand: Equatable, Sendable { + case toggle + case show + case hide + case focus + case setMode(RightSidebarMode, focus: Bool) + case getState +} + +nonisolated struct RightSidebarRemoteRequest: Equatable, Sendable { + let command: RightSidebarRemoteCommand + let target: RightSidebarRemoteTarget +} + +nonisolated struct RightSidebarRemoteParseError: Error, Equatable, Sendable { + let message: String +} + +nonisolated struct RightSidebarRemoteState: Equatable, Sendable { + let visible: Bool + let mode: RightSidebarMode +} + +nonisolated enum RightSidebarRemoteApplyResult: Equatable, Sendable { + case ok + case state(RightSidebarRemoteState) + case failure(String) +} + +extension RightSidebarRemoteRequest { + static func parse(tokens: [String]) -> Result { + var positional: [String] = [] + var target = RightSidebarRemoteTarget() + var noFocus = false + var index = 0 + + while index < tokens.count { + let token = tokens[index] + if token == "--no-focus" { + noFocus = true + index += 1 + continue + } + if token == "--workspace" || token == "--tab" || token == "--window" { + guard index + 1 < tokens.count else { + return .failure(.init(message: String(localized: "rightSidebar.remote.error.optionRequiresID", defaultValue: "ERROR: \(token) requires an id"))) + } + let value = tokens[index + 1] + if let error = parseTargetOption(name: String(token.dropFirst(2)), value: value, target: &target) { + return .failure(error) + } + index += 2 + continue + } + if token.hasPrefix("--workspace=") { + let value = String(token.dropFirst("--workspace=".count)) + if let error = parseTargetOption(name: "workspace", value: value, target: &target) { + return .failure(error) + } + index += 1 + continue + } + if token.hasPrefix("--tab=") { + let value = String(token.dropFirst("--tab=".count)) + if let error = parseTargetOption(name: "tab", value: value, target: &target) { + return .failure(error) + } + index += 1 + continue + } + if token.hasPrefix("--window=") { + let value = String(token.dropFirst("--window=".count)) + if let error = parseTargetOption(name: "window", value: value, target: &target) { + return .failure(error) + } + index += 1 + continue + } + if token.hasPrefix("--") { + return .failure(.init(message: String(localized: "rightSidebar.remote.error.unknownOption", defaultValue: "ERROR: Unknown right sidebar option '\(token)'"))) + } + positional.append(token) + index += 1 + } + + guard let action = positional.first?.lowercased() else { + return .failure(.init(message: String(localized: "rightSidebar.remote.error.usage", defaultValue: "ERROR: Usage: right_sidebar [mode] [--workspace=] [--window=] [--no-focus]"))) + } + + switch action { + case "toggle": + guard positional.count == 1, !noFocus else { + return .failure(.init(message: String(localized: "rightSidebar.remote.error.usage.toggle", defaultValue: "ERROR: Usage: right_sidebar toggle [--workspace=] [--window=]"))) + } + return .success(.init(command: .toggle, target: target)) + case "show": + guard positional.count == 1, !noFocus else { + return .failure(.init(message: String(localized: "rightSidebar.remote.error.usage.show", defaultValue: "ERROR: Usage: right_sidebar show [--workspace=] [--window=]"))) + } + return .success(.init(command: .show, target: target)) + case "hide": + guard positional.count == 1, !noFocus else { + return .failure(.init(message: String(localized: "rightSidebar.remote.error.usage.hide", defaultValue: "ERROR: Usage: right_sidebar hide [--workspace=] [--window=]"))) + } + return .success(.init(command: .hide, target: target)) + case "focus": + guard positional.count == 1, !noFocus else { + return .failure(.init(message: String(localized: "rightSidebar.remote.error.usage.focus", defaultValue: "ERROR: Usage: right_sidebar focus [--workspace=] [--window=]"))) + } + return .success(.init(command: .focus, target: target)) + case "mode", "state": + guard positional.count == 1, !noFocus else { + return .failure(.init(message: String(localized: "rightSidebar.remote.error.usage.mode", defaultValue: "ERROR: Usage: right_sidebar mode [--workspace=] [--window=]"))) + } + return .success(.init(command: .getState, target: target)) + case "set": + guard positional.count == 2 else { + return .failure(.init(message: String(localized: "rightSidebar.remote.error.usage.set", defaultValue: "ERROR: Usage: right_sidebar set [--no-focus] [--workspace=] [--window=]"))) + } + guard let mode = RightSidebarMode.from(cliArgument: positional[1]) else { + return .failure(.init(message: String(localized: "rightSidebar.remote.error.unknownMode", defaultValue: "ERROR: Unknown right sidebar mode '\(positional[1])'"))) + } + return .success(.init(command: .setMode(mode, focus: !noFocus), target: target)) + default: + guard !noFocus else { + return .failure(.init(message: String(localized: "rightSidebar.remote.error.noFocusOnlySet", defaultValue: "ERROR: --no-focus is only valid with right_sidebar set"))) + } + guard positional.count == 1, let mode = RightSidebarMode.from(cliArgument: action) else { + return .failure(.init(message: String(localized: "rightSidebar.remote.error.unknownCommand", defaultValue: "ERROR: Unknown right sidebar command '\(action)'"))) + } + return .success(.init(command: .setMode(mode, focus: true), target: target)) + } + } + + private static func parseTargetOption( + name: String, + value: String, + target: inout RightSidebarRemoteTarget + ) -> RightSidebarRemoteParseError? { + guard let uuid = UUID(uuidString: value.trimmingCharacters(in: .whitespacesAndNewlines)) else { + return .init(message: String(localized: "rightSidebar.remote.error.invalidTargetID", defaultValue: "ERROR: Invalid right sidebar --\(name) id '\(value)'")) + } + switch name { + case "window": + target.windowId = uuid + case "workspace", "tab": + target.workspaceId = uuid + default: + return .init(message: String(localized: "rightSidebar.remote.error.unknownTargetOption", defaultValue: "ERROR: Unknown right sidebar target option '\(name)'")) + } + return nil + } +} diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index ae34bcf44827..4f2a966c7a22 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -318,9 +318,25 @@ class TerminalController { return focusIntentV2Methods.contains(commandKey) || explicitFocusParamAllowsFocus(commandKey: commandKey, params: params) } + if commandKey == "right_sidebar" { + return rightSidebarCommandAllowsInAppFocusMutations(args: params["args"] as? String ?? "") + } return focusIntentV1Commands.contains(commandKey) } + private nonisolated static func rightSidebarCommandAllowsInAppFocusMutations(args: String) -> Bool { + let parsed = RightSidebarRemoteRequest.parse(tokens: Self.tokenizeArgs(args)) + guard case .success(let request) = parsed else { return false } + switch request.command { + case .toggle, .show, .focus: + return true + case .setMode(_, let focus): + return focus + case .hide, .getState: + return false + } + } + nonisolated func withSocketCommandPolicy(commandKey: String, isV2: Bool, params: [String: Any] = [:], _ body: () -> T) -> T { let allowsFocusMutation = Self.socketCommandAllowsInAppFocusMutations(commandKey: commandKey, isV2: isV2, params: params) var stack = Self.currentSocketCommandFocusAllowanceStack() @@ -2040,7 +2056,8 @@ class TerminalController { let cmd = parts[0].lowercased() let args = parts.count > 1 ? parts[1] : "" - return withSocketCommandPolicy(commandKey: cmd, isV2: false) { + let policyParams = cmd == "right_sidebar" ? ["args": args] : [:] + return withSocketCommandPolicy(commandKey: cmd, isV2: false, params: policyParams) { switch cmd { case "ping": return "PONG" @@ -2216,6 +2233,9 @@ class TerminalController { case "reset_sidebar": return resetSidebar(args) + case "right_sidebar": + return rightSidebar(args) + case "read_screen": return readScreenText(args) @@ -12964,6 +12984,7 @@ class TerminalController { report_pr_action [--target=X] [--tab=X] [--panel=Y] - Hint that a PR-affecting command completed in the panel report_pwd [--tab=X] [--panel=Y] - Report current working directory clear_ports [--tab=X] [--panel=Y] - Clear listening ports + right_sidebar [mode] [--tab=X] [--window=Y] [--no-focus] - Control right sidebar visibility, mode, and focus sidebar_state [--tab=X] - Dump sidebar metadata reset_sidebar [--tab=X] - Clear sidebar metadata @@ -15922,7 +15943,7 @@ class TerminalController { // MARK: - Option Parsing (sidebar metadata commands) - private func tokenizeArgs(_ args: String) -> [String] { + private nonisolated static func tokenizeArgs(_ args: String) -> [String] { let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return [] } @@ -15998,7 +16019,7 @@ class TerminalController { } private func parseOptions(_ args: String) -> (positional: [String], options: [String: String]) { - let tokens = tokenizeArgs(args) + let tokens = Self.tokenizeArgs(args) guard !tokens.isEmpty else { return ([], [:]) } var positional: [String] = [] @@ -16034,7 +16055,7 @@ class TerminalController { } private func parseOptionsNoStop(_ args: String) -> (positional: [String], options: [String: String]) { - let tokens = tokenizeArgs(args) + let tokens = Self.tokenizeArgs(args) guard !tokens.isEmpty else { return ([], [:]) } var positional: [String] = [] @@ -17287,6 +17308,52 @@ class TerminalController { return result } + private func rightSidebar(_ args: String) -> String { + let parsed = RightSidebarRemoteRequest.parse(tokens: Self.tokenizeArgs(args)) + let request: RightSidebarRemoteRequest + switch parsed { + case .success(let value): + request = value + case .failure(let error): + return error.message + } + + return v2MainSync { + guard let app = AppDelegate.shared else { + return String(localized: "rightSidebar.remote.error.appDelegateUnavailable", defaultValue: "ERROR: App delegate not available") + } + switch app.applyRightSidebarRemoteCommand(request.command, target: request.target) { + case .ok: + return "OK" + case .state(let state): + return v2Encode([ + "visible": state.visible, + "mode": state.mode.rawValue + ]) + case .failure(let message): + return message + } + } + } + +#if DEBUG + func parseRightSidebarRemoteRequestForTesting(_ commandLine: String) -> Result { + let trimmed = commandLine.trimmingCharacters(in: .whitespacesAndNewlines) + let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init) + guard parts.first?.lowercased() == "right_sidebar" else { + return .failure(.init(message: "ERROR: Usage: right_sidebar ")) + } + return RightSidebarRemoteRequest.parse(tokens: Self.tokenizeArgs(parts.count > 1 ? parts[1] : "")) + } + + func rightSidebarCommandAllowsInAppFocusMutationsForTesting(_ commandLine: String) -> Bool { + let trimmed = commandLine.trimmingCharacters(in: .whitespacesAndNewlines) + let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init) + guard parts.first?.lowercased() == "right_sidebar" else { return false } + return Self.rightSidebarCommandAllowsInAppFocusMutations(args: parts.count > 1 ? parts[1] : "") + } +#endif + private func resetSidebar(_ args: String) -> String { var result = "OK" v2MainSync { diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index d0d539ffe114..9b0c56e62ab9 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -2,6 +2,234 @@ import XCTest import Darwin final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { + func testRightSidebarCLIForwardsV1SocketCommandsQuietly() throws { + let cliPath = try bundledCLIPath() + let cases: [(name: String, arguments: [String], expectedCommand: String, response: String, stdout: String)] = [ + ("toggle", ["right-sidebar", "toggle"], "right_sidebar toggle", "OK", ""), + ("show", ["right-sidebar", "show"], "right_sidebar show", "OK", ""), + ("hide", ["right-sidebar", "hide"], "right_sidebar hide", "OK", ""), + ("focus", ["right-sidebar", "focus"], "right_sidebar focus", "OK", ""), + ("set-find", ["right-sidebar", "set", "find"], "right_sidebar set find", "OK", ""), + ("set-no-focus", ["right-sidebar", "set", "vault", "--no-focus"], "right_sidebar set vault --no-focus", "OK", ""), + ("set-sessions", ["right-sidebar", "set", "sessions"], "right_sidebar set sessions", "OK", ""), + ("files-alias", ["right-sidebar", "files"], "right_sidebar set files", "OK", ""), + ("find-alias", ["right-sidebar", "find"], "right_sidebar set find", "OK", ""), + ("vault-alias", ["right-sidebar", "vault"], "right_sidebar set vault", "OK", ""), + ("sessions-alias", ["right-sidebar", "sessions"], "right_sidebar set sessions", "OK", ""), + ("feed-alias", ["right-sidebar", "feed"], "right_sidebar set feed", "OK", ""), + ("dock-alias", ["right-sidebar", "dock"], "right_sidebar set dock", "OK", ""), + ("mode", ["right-sidebar", "mode"], "right_sidebar mode", #"{"visible":true,"mode":"find"}"#, #"{"visible":true,"mode":"find"}"# + "\n"), + ] + + for item in cases { + let socketPath = makeSocketPath("rs-\(item.name)") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + XCTAssertEqual(line, item.expectedCommand) + return item.response + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: item.arguments, + environment: environment, + timeout: 5 + ) + + wait(for: [serverHandled], timeout: 5) + XCTAssertFalse(result.timedOut, "\(item.name): \(result.stderr)") + XCTAssertEqual(result.status, 0, "\(item.name): \(result.stderr)") + XCTAssertEqual(result.stdout, item.stdout, item.name) + XCTAssertTrue(result.stderr.isEmpty, "\(item.name): \(result.stderr)") + XCTAssertEqual(state.commands, [item.expectedCommand], item.name) + } + } + + func testRightSidebarInvalidCommandValidatesBeforeTargetResolution() throws { + let cliPath = try bundledCLIPath() + let missingSocketPath = "/tmp/cmux-test-missing-\(UUID().uuidString).sock" + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = missingSocketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: ["right-sidebar", "unknown", "--workspace", "workspace:2"], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 1, result.stderr) + XCTAssertTrue(result.stdout.isEmpty, result.stdout) + XCTAssertTrue(result.stderr.contains("Unknown right-sidebar command 'unknown'"), result.stderr) + XCTAssertFalse(result.stderr.contains("Socket"), result.stderr) + } + + func testRightSidebarInvalidSetModeValidatesBeforeTargetResolution() throws { + let cliPath = try bundledCLIPath() + let missingSocketPath = "/tmp/cmux-test-missing-\(UUID().uuidString).sock" + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = missingSocketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: ["right-sidebar", "set", "unknown", "--workspace", "workspace:2"], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 1, result.stderr) + XCTAssertTrue(result.stdout.isEmpty, result.stdout) + XCTAssertTrue(result.stderr.contains("Unknown right-sidebar mode 'unknown'"), result.stderr) + XCTAssertFalse(result.stderr.contains("Socket"), result.stderr) + } + + func testRightSidebarCLIResolvesWindowAndWorkspaceHandlesBeforeForwarding() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("rs-target") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let windowId = "11111111-1111-1111-1111-111111111111" + let workspaceId = "22222222-2222-2222-2222-222222222222" + + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + if let payload = self.jsonObject(line), + let id = payload["id"] as? String, + let method = payload["method"] as? String { + switch method { + case "window.list": + return self.v2Response( + id: id, + ok: true, + result: [ + "windows": [ + ["id": "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", "index": 1], + ["id": windowId, "index": 3], + ] + ] + ) + case "workspace.list": + let params = payload["params"] as? [String: Any] ?? [:] + XCTAssertEqual(params["window_id"] as? String, windowId) + return self.v2Response( + id: id, + ok: true, + result: [ + "workspaces": [ + ["id": "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", "index": 1], + ["id": workspaceId, "index": 2], + ] + ] + ) + default: + return self.v2Response( + id: id, + ok: false, + error: ["code": "unexpected_method", "message": "Unexpected method \(method)"] + ) + } + } + + XCTAssertEqual(line, "right_sidebar set find --tab=\(workspaceId) --window=\(windowId)") + return "OK" + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: ["right-sidebar", "set", "find", "--window", "window:3", "--workspace", "workspace:2"], + environment: environment, + timeout: 5 + ) + + wait(for: [serverHandled], timeout: 5) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertTrue(result.stdout.isEmpty, result.stdout) + XCTAssertTrue(result.stderr.isEmpty, result.stderr) + XCTAssertEqual( + state.commands.compactMap { self.jsonObject($0)?["method"] as? String }, + ["window.list", "workspace.list"] + ) + XCTAssertEqual(state.commands.last, "right_sidebar set find --tab=\(workspaceId) --window=\(windowId)") + } + + func testRightSidebarCLIRejectsUnresolvedWorkspaceHandleBeforeForwarding() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("rs-miss") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = self.jsonObject(line), + let id = payload["id"] as? String, + let method = payload["method"] as? String else { + return "ERROR: Unexpected command \(line)" + } + XCTAssertEqual(method, "workspace.list") + return self.v2Response( + id: id, + ok: true, + result: [ + "workspaces": [ + ["id": "11111111-1111-1111-1111-111111111111", "index": 1] + ] + ] + ) + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: ["right-sidebar", "show", "--workspace", "workspace:99"], + environment: environment, + timeout: 5 + ) + + wait(for: [serverHandled], timeout: 5) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 1, result.stderr) + XCTAssertTrue(result.stdout.isEmpty, result.stdout) + XCTAssertTrue(result.stderr.contains("Workspace ref not found"), result.stderr) + XCTAssertEqual( + state.commands.compactMap { self.jsonObject($0)?["method"] as? String }, + ["workspace.list"] + ) + XCTAssertFalse( + state.commands.contains { $0.hasPrefix("right_sidebar ") }, + "Expected no right_sidebar command after target resolution failed, saw \(state.commands)" + ) + } + @MainActor func testNotifyWithUUIDSurfaceKeepsCallerWorkspaceFallback() throws { let cliPath = try bundledCLIPath() diff --git a/cmuxTests/FileExplorerStateModePersistenceTests.swift b/cmuxTests/FileExplorerStateModePersistenceTests.swift index 91f79ca27b7b..cc76e9e7f650 100644 --- a/cmuxTests/FileExplorerStateModePersistenceTests.swift +++ b/cmuxTests/FileExplorerStateModePersistenceTests.swift @@ -61,6 +61,17 @@ final class FileExplorerStateModePersistenceTests: XCTestCase { } } + func testCLIArgumentNormalizerMapsVaultAndSessionsToSessions() { + XCTAssertEqual(RightSidebarMode.from(cliArgument: "files"), .files) + XCTAssertEqual(RightSidebarMode.from(cliArgument: "find"), .find) + XCTAssertEqual(RightSidebarMode.from(cliArgument: "vault"), .sessions) + XCTAssertEqual(RightSidebarMode.from(cliArgument: "sessions"), .sessions) + XCTAssertEqual(RightSidebarMode.from(cliArgument: "feed"), .feed) + XCTAssertEqual(RightSidebarMode.from(cliArgument: "dock"), .dock) + XCTAssertEqual(RightSidebarMode.from(cliArgument: " Vault "), .sessions) + XCTAssertNil(RightSidebarMode.from(cliArgument: "unknown")) + } + private func withSavedRightSidebarModeDefaults(_ body: () -> Void) { let defaults = UserDefaults.standard let previousMode = defaults.object(forKey: modeKey) diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index ae94a8e3b1d4..5cc13af5265b 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -167,6 +167,290 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertEqual(payload["has_ssh_options"] as? Bool, true) } + func testRightSidebarV1CommandsDriveExistingState() throws { + let previousAppDelegate = AppDelegate.shared + let appDelegate = AppDelegate() + defer { AppDelegate.shared = previousAppDelegate } + + let windowId = UUID() + let tabManager = TabManager() + let sidebarState = SidebarState() + let sidebarSelectionState = SidebarSelectionState() + let fileExplorerState = FileExplorerState() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(windowId.uuidString)") + + appDelegate.fileExplorerState = fileExplorerState + appDelegate.registerMainWindow( + window, + windowId: windowId, + tabManager: tabManager, + sidebarState: sidebarState, + sidebarSelectionState: sidebarSelectionState, + fileExplorerState: fileExplorerState + ) + defer { + appDelegate.unregisterMainWindowContextForTesting(windowId: windowId) + window.close() + } + + fileExplorerState.setVisible(false) + fileExplorerState.mode = .files + + XCTAssertEqual(TerminalController.shared.handleSocketLine("right_sidebar show"), "OK") + XCTAssertTrue(fileExplorerState.isVisible) + + XCTAssertEqual(TerminalController.shared.handleSocketLine("right_sidebar set find"), "OK") + XCTAssertEqual(fileExplorerState.mode, .find) + XCTAssertTrue(fileExplorerState.isVisible) + + XCTAssertEqual(TerminalController.shared.handleSocketLine("right_sidebar set vault --no-focus"), "OK") + XCTAssertEqual(fileExplorerState.mode, .sessions) + + XCTAssertEqual(TerminalController.shared.handleSocketLine("right_sidebar set sessions --no-focus"), "OK") + XCTAssertEqual(fileExplorerState.mode, .sessions) + + XCTAssertEqual(TerminalController.shared.handleSocketLine("right_sidebar hide"), "OK") + XCTAssertFalse(fileExplorerState.isVisible) + + XCTAssertEqual(TerminalController.shared.handleSocketLine("right_sidebar toggle"), "OK") + XCTAssertTrue(fileExplorerState.isVisible) + + XCTAssertEqual(TerminalController.shared.handleSocketLine("right_sidebar focus"), "OK") + XCTAssertTrue(fileExplorerState.isVisible) + + let modeResponse = TerminalController.shared.handleSocketLine("right_sidebar mode") + let modeData = try XCTUnwrap(modeResponse.data(using: .utf8)) + let modePayload = try XCTUnwrap(JSONSerialization.jsonObject(with: modeData) as? [String: Any]) + XCTAssertEqual(modePayload["visible"] as? Bool, true) + XCTAssertEqual(modePayload["mode"] as? String, "sessions") + + XCTAssertTrue(TerminalController.shared.handleSocketLine("right_sidebar set unknown").hasPrefix("ERROR:")) + } + + func testRightSidebarV1ParserProducesRemoteCommands() throws { +#if DEBUG + let workspaceId = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! + let windowId = UUID(uuidString: "22222222-2222-2222-2222-222222222222")! + let cases: [(String, RightSidebarRemoteRequest)] = [ + ( + "right_sidebar toggle", + RightSidebarRemoteRequest(command: .toggle, target: RightSidebarRemoteTarget()) + ), + ( + "right_sidebar show --window=\(windowId.uuidString)", + RightSidebarRemoteRequest(command: .show, target: RightSidebarRemoteTarget(windowId: windowId, workspaceId: nil)) + ), + ( + "right_sidebar hide --tab=\(workspaceId.uuidString)", + RightSidebarRemoteRequest(command: .hide, target: RightSidebarRemoteTarget(windowId: nil, workspaceId: workspaceId)) + ), + ( + "right_sidebar focus", + RightSidebarRemoteRequest(command: .focus, target: RightSidebarRemoteTarget()) + ), + ( + "right_sidebar set find", + RightSidebarRemoteRequest(command: .setMode(.find, focus: true), target: RightSidebarRemoteTarget()) + ), + ( + "right_sidebar set vault --no-focus", + RightSidebarRemoteRequest(command: .setMode(.sessions, focus: false), target: RightSidebarRemoteTarget()) + ), + ( + "right_sidebar sessions", + RightSidebarRemoteRequest(command: .setMode(.sessions, focus: true), target: RightSidebarRemoteTarget()) + ), + ( + "right_sidebar mode", + RightSidebarRemoteRequest(command: .getState, target: RightSidebarRemoteTarget()) + ), + ( + "right_sidebar state --workspace \(workspaceId.uuidString) --window \(windowId.uuidString)", + RightSidebarRemoteRequest(command: .getState, target: RightSidebarRemoteTarget(windowId: windowId, workspaceId: workspaceId)) + ), + ] + + for (line, expected) in cases { + let result = TerminalController.shared.parseRightSidebarRemoteRequestForTesting(line) + XCTAssertEqual(try result.get(), expected, line) + } + + let invalidCases: [(String, String)] = [ + ("right_sidebar", "Usage: right_sidebar"), + ("right_sidebar set", "Usage: right_sidebar set"), + ("right_sidebar set unknown", "Unknown right sidebar mode"), + ("right_sidebar show --no-focus", "Usage: right_sidebar show"), + ("right_sidebar files --no-focus", "--no-focus is only valid"), + ("right_sidebar --bad", "Unknown right sidebar option"), + ("right_sidebar show --tab not-a-uuid", "Invalid right sidebar --tab id"), + ("right_sidebar show --window", "--window requires an id"), + ] + + for (line, expectedMessage) in invalidCases { + switch TerminalController.shared.parseRightSidebarRemoteRequestForTesting(line) { + case .success(let request): + XCTFail("Expected parser failure for \(line), got \(request)") + case .failure(let error): + XCTAssertTrue( + error.message.contains(expectedMessage), + "Expected \(line) to contain \(expectedMessage), got \(error.message)" + ) + } + } +#else + throw XCTSkip("Right sidebar parser helper is debug-only.") +#endif + } + + func testRightSidebarV1FocusPolicyIsCommandSpecific() throws { +#if DEBUG + let cases: [(String, Bool)] = [ + ("right_sidebar toggle", true), + ("right_sidebar show", true), + ("right_sidebar focus", true), + ("right_sidebar set find", true), + ("right_sidebar sessions", true), + ("right_sidebar set vault --no-focus", false), + ("right_sidebar hide", false), + ("right_sidebar mode", false), + ("right_sidebar state", false), + ("right_sidebar set unknown", false), + ] + + for (line, expected) in cases { + XCTAssertEqual( + TerminalController.shared.rightSidebarCommandAllowsInAppFocusMutationsForTesting(line), + expected, + line + ) + } +#else + throw XCTSkip("Right sidebar focus policy helper is debug-only.") +#endif + } + + func testRightSidebarRemoteCommandsCanTargetRegisteredWindowOrWorkspaceWithoutFocus() throws { + let previousAppDelegate = AppDelegate.shared + let appDelegate = AppDelegate() + defer { AppDelegate.shared = previousAppDelegate } + let windowAId = UUID() + let windowBId = UUID() + let managerA = TabManager() + let managerB = TabManager() + let managerC = TabManager() + _ = managerA.addWorkspace(select: false, eagerLoadTerminal: false) + let workspaceB = managerB.addWorkspace(select: false, eagerLoadTerminal: false) + let workspaceC = managerC.addWorkspace(select: false, eagerLoadTerminal: false) + let stateA = FileExplorerState() + let stateB = FileExplorerState() + let fallbackState = FileExplorerState() + + stateA.setVisible(false) + stateA.mode = .files + stateB.setVisible(false) + stateB.mode = .files + fallbackState.setVisible(true) + fallbackState.mode = .dock + appDelegate.fileExplorerState = fallbackState + + appDelegate.registerMainWindowContextForTesting( + windowId: windowAId, + tabManager: managerA, + fileExplorerState: stateA + ) + appDelegate.registerMainWindowContextForTesting( + windowId: windowBId, + tabManager: managerB, + fileExplorerState: stateB + ) + let windowCId = appDelegate.registerMainWindowContextForTesting( + tabManager: managerC + ) + defer { + appDelegate.unregisterMainWindowContextForTesting(windowId: windowAId) + appDelegate.unregisterMainWindowContextForTesting(windowId: windowBId) + appDelegate.unregisterMainWindowContextForTesting(windowId: windowCId) + } + + XCTAssertEqual( + appDelegate.applyRightSidebarRemoteCommand( + .setMode(.find, focus: false), + target: RightSidebarRemoteTarget(windowId: windowAId, workspaceId: nil) + ), + .ok + ) + XCTAssertTrue(stateA.isVisible) + XCTAssertEqual(stateA.mode, .find) + XCTAssertFalse(stateB.isVisible) + XCTAssertEqual(stateB.mode, .files) + + XCTAssertEqual( + appDelegate.applyRightSidebarRemoteCommand( + .setMode(.sessions, focus: false), + target: RightSidebarRemoteTarget(windowId: nil, workspaceId: workspaceB.id) + ), + .ok + ) + XCTAssertTrue(stateB.isVisible) + XCTAssertEqual(stateB.mode, .sessions) + XCTAssertEqual(stateA.mode, .find) + + XCTAssertEqual( + appDelegate.applyRightSidebarRemoteCommand( + .hide, + target: RightSidebarRemoteTarget(windowId: nil, workspaceId: workspaceB.id) + ), + .ok + ) + XCTAssertFalse(stateB.isVisible) + XCTAssertTrue(stateA.isVisible) + + switch appDelegate.applyRightSidebarRemoteCommand( + .toggle, + target: RightSidebarRemoteTarget(windowId: nil, workspaceId: workspaceB.id) + ) { + case .failure(let message): + XCTAssertTrue(message.contains("target not found"), message) + case .ok, .state: + XCTFail("Expected targeted toggle without a window to fail") + } + XCTAssertFalse(stateB.isVisible) + + XCTAssertEqual( + appDelegate.applyRightSidebarRemoteCommand( + .getState, + target: RightSidebarRemoteTarget(windowId: nil, workspaceId: workspaceB.id) + ), + .state(.init(visible: false, mode: .sessions)) + ) + + switch appDelegate.applyRightSidebarRemoteCommand( + .getState, + target: RightSidebarRemoteTarget(windowId: nil, workspaceId: workspaceC.id) + ) { + case .failure(let message): + XCTAssertTrue(message.contains("state not available"), message) + case .ok, .state: + XCTFail("Expected explicit target without right-sidebar state to fail") + } + + switch appDelegate.applyRightSidebarRemoteCommand( + .hide, + target: RightSidebarRemoteTarget(windowId: nil, workspaceId: UUID()) + ) { + case .failure(let message): + XCTAssertTrue(message.contains("target not found"), message) + case .ok, .state: + XCTFail("Expected missing workspace target to fail") + } + } + func testNotificationCreateUsesExplicitSurfaceIDWhenProvided() async throws { let socketPath = makeSocketPath("notify-surface") let store = TerminalNotificationStore.shared diff --git a/docs/cli-contract.md b/docs/cli-contract.md index ae170a6c84ac..e6d47380d71e 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -127,6 +127,7 @@ Environment: | `notify` | Send a notification to a workspace/surface. | | `list-notifications` | List queued notifications. | | `clear-notifications` | Clear queued notifications. | +| `right-sidebar` | Control right sidebar visibility, mode, focus, and state reads. | | `set-status` | Set a sidebar status pill. | | `clear-status` | Remove a sidebar status pill. | | `list-status` | List sidebar status pills. | @@ -275,6 +276,19 @@ Hook subcommands: | `hooks feed --source ` | Convert agent hook events into Feed context. | | `hooks ` | Generic hook surface for `opencode`, `pi`, `cursor`, `gemini`, `rovodev`, `copilot`, `codebuddy`, `factory`, and `qoder`. | +Right sidebar commands: + +| Command | Contract | +| --- | --- | +| `right-sidebar toggle`, `right-sidebar show`, `right-sidebar hide` | Change right-sidebar visibility without printing on success. | +| `right-sidebar focus` | Focus the current right-sidebar mode. | +| `right-sidebar set ` | Show the right sidebar, switch mode, and focus it unless `--no-focus` is passed. | +| `right-sidebar files`, `right-sidebar find`, `right-sidebar vault`, `right-sidebar sessions`, `right-sidebar feed`, `right-sidebar dock` | Short aliases for `right-sidebar set ` with focus. | +| `right-sidebar mode` | Print JSON with `visible` and `mode`. | +| `--workspace ` | Target the window containing a workspace. Refs and indexes resolve before the V1 socket command is sent. | +| `--window ` | Target a window. Refs and indexes resolve before the V1 socket command is sent. | +| `--no-focus` | Only valid with `set`; switches mode without moving focus. | + Docs topics: | Command | Contract | @@ -447,6 +461,7 @@ the expected text without connecting to a cmux socket. - `cmux notify --help` -> `Usage: cmux notify` - `cmux list-notifications --help` -> `Usage: cmux list-notifications` - `cmux clear-notifications --help` -> `Usage: cmux clear-notifications` +- `cmux right-sidebar --help` -> `Usage: cmux right-sidebar [flags]` - `cmux set-status --help` -> `Usage: cmux set-status` - `cmux clear-status --help` -> `Usage: cmux clear-status` - `cmux list-status --help` -> `Usage: cmux list-status`