diff --git a/CLI/CMUXCLI+WorkspaceTodo.swift b/CLI/CMUXCLI+WorkspaceTodo.swift index e96fd0e15219..6a3ec065e39a 100644 --- a/CLI/CMUXCLI+WorkspaceTodo.swift +++ b/CLI/CMUXCLI+WorkspaceTodo.swift @@ -14,6 +14,8 @@ extension CMUXCLI { client: SocketClient, windowOverride: String? ) throws -> (params: [String: Any], rest: [String]) { + try validateWorkspaceTodoSelectorOption(commandArgs, name: "--workspace") + try validateWorkspaceTodoSelectorOption(commandArgs, name: "--window") let (workspaceArg, rem0) = parseOption(commandArgs, name: "--workspace") let (windowArg, rem1) = parseOption(rem0, name: "--window") var params: [String: Any] = [:] @@ -31,6 +33,38 @@ extension CMUXCLI { return (params, rest) } + private func validateWorkspaceTodoSelectorOption(_ args: [String], name: String) throws { + var pastTerminator = false + for (index, arg) in args.enumerated() { + if arg == "--" { + pastTerminator = true + continue + } + guard !pastTerminator else { continue } + if arg.hasPrefix("\(name)=") { + let value = String(arg.dropFirst(name.count + 1)) + try validateWorkspaceTodoSelectorValue(value, name: name) + continue + } + if arg == name { + guard index + 1 < args.count else { + throw CLIError(message: "\(name) requires a non-empty value") + } + let value = args[index + 1] + guard !value.hasPrefix("--") else { + throw CLIError(message: "\(name) requires a non-empty value") + } + try validateWorkspaceTodoSelectorValue(value, name: name) + } + } + } + + private func validateWorkspaceTodoSelectorValue(_ value: String, name: String) throws { + guard !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw CLIError(message: "\(name) requires a non-empty value") + } + } + /// Parses a checklist item selector: a UUID id, or a 1-based index as /// printed by `cmux todo list` (sent as the wire's 0-based `index`). private func workspaceTodoItemSelectorParams(_ raw: String) throws -> [String: Any] { diff --git a/Packages/macOS/CmuxControlSocket/Package.swift b/Packages/macOS/CmuxControlSocket/Package.swift index 68b8591199f4..5a224c333b59 100644 --- a/Packages/macOS/CmuxControlSocket/Package.swift +++ b/Packages/macOS/CmuxControlSocket/Package.swift @@ -15,12 +15,14 @@ let package = Package( ], dependencies: [ .package(path: "../CmuxSettings"), + .package(path: "../CmuxWorkspaces"), ], targets: [ .target( name: "CmuxControlSocket", dependencies: [ .product(name: "CmuxSettings", package: "CmuxSettings"), + .product(name: "CmuxWorkspaces", package: "CmuxWorkspaces"), ], swiftSettings: [ .swiftLanguageMode(.v6), @@ -33,6 +35,7 @@ let package = Package( dependencies: [ "CmuxControlSocket", .product(name: "CmuxSettings", package: "CmuxSettings"), + .product(name: "CmuxWorkspaces", package: "CmuxWorkspaces"), ] ), ] diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceTodo/ControlCommandCoordinator+WorkspaceTodoSetOpen.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceTodo/ControlCommandCoordinator+WorkspaceTodoSetOpen.swift index 7410090ea304..8f5b11733361 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceTodo/ControlCommandCoordinator+WorkspaceTodoSetOpen.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceTodo/ControlCommandCoordinator+WorkspaceTodoSetOpen.swift @@ -1,4 +1,5 @@ internal import Foundation +internal import CmuxWorkspaces /// Workspace-todo set/open verbs extracted from the primary workspace-todo coordinator file, which sits at its file-length budget. extension ControlCommandCoordinator { @@ -16,6 +17,9 @@ extension ControlCommandCoordinator { guard case .array(let rawItems)? = params["items"] else { return .invalid(.err(code: "invalid_params", message: "Missing or invalid items", data: nil)) } + guard rawItems.count <= WorkspaceChecklistItem.maxChecklistItems else { + return .invalid(workspaceTodoSetTooManyItemsError(count: rawItems.count)) + } var items: [ControlWorkspaceTodoSetItemParam] = [] items.reserveCapacity(rawItems.count) for (index, rawItem) in rawItems.enumerated() { @@ -88,11 +92,7 @@ extension ControlCommandCoordinator { data: .object(["index": .int(Int64(index))]) ) case .tooManyItems(let count): - return .err( - code: "invalid_params", - message: "items exceeds the checklist cap of 50", - data: .object(["count": .int(Int64(count))]) - ) + return workspaceTodoSetTooManyItemsError(count: count) case .invalidState(let raw): return .err( code: "invalid_params", @@ -110,6 +110,14 @@ extension ControlCommandCoordinator { } } + private func workspaceTodoSetTooManyItemsError(count: Int) -> ControlCallResult { + .err( + code: "invalid_params", + message: "items exceeds the checklist cap of \(WorkspaceChecklistItem.maxChecklistItems)", + data: .object(["count": .int(Int64(count))]) + ) + } + /// `workspace.todo.open` — open (or focus) the workspace's todo pane. func workspaceTodoOpen(_ params: [String: JSONValue]) -> ControlCallResult { let resolution = context?.controlWorkspaceTodoOpen( diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorWorkspaceTodoSetOpenTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorWorkspaceTodoSetOpenTests.swift index a58f74142509..6b77575ff30f 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorWorkspaceTodoSetOpenTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorWorkspaceTodoSetOpenTests.swift @@ -1,4 +1,5 @@ import Foundation +import CmuxWorkspaces import Testing @testable import CmuxControlSocket @@ -95,6 +96,24 @@ extension ControlCommandCoordinatorWorkspaceTodoTests { #expect(context.lastSetItems == nil) } + @Test func todoSetOverCapRejectsBeforeParsingItems() throws { + let (coordinator, context) = makeCoordinator() + let rawItems = (0...WorkspaceChecklistItem.maxChecklistItems).map { _ in + JSONValue.object(["text": .string("x")]) + } + let result = try #require(coordinator.handle(request("workspace.todo.set", [ + "items": .array(rawItems), + ]))) + guard case .err(let code, let message, let data) = result else { + Issue.record("expected err, got \(result)") + return + } + #expect(code == "invalid_params") + #expect(message == "items exceeds the checklist cap of \(WorkspaceChecklistItem.maxChecklistItems)") + #expect(data == .object(["count": .int(Int64(WorkspaceChecklistItem.maxChecklistItems + 1))])) + #expect(context.lastSetItems == nil) + } + @Test func todoSetEchoesAtomicRejections() throws { let (coordinator, context) = makeCoordinator() context.setResolution = .emptyText(index: 2) diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift index f2caa2fa1701..1e8e88f27733 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift @@ -61,11 +61,8 @@ extension ShortcutAction { case .renameTab: return ShortcutStroke(key: "r", command: true) case .renameWorkspace: return ShortcutStroke(key: "r", command: true, shift: true) case .editWorkspaceDescription: return ShortcutStroke(key: "e", command: true, option: true) - // Cmd+; pins the status to done; the Cmd-"D" family is taken by - // split/diff actions and Cmd+Ctrl+D is macOS-reserved. Mirrors the - // app-side table. - case .markWorkspaceDone: return ShortcutStroke(key: ";", command: true) - case .cycleWorkspaceStatus: return ShortcutStroke(key: ";", command: true, shift: true) + case .markWorkspaceDone: return ShortcutStroke(key: ";", command: true, control: true) + case .cycleWorkspaceStatus: return ShortcutStroke(key: ";", command: true, shift: true, control: true) case .toggleChecklistItemComplete: return ShortcutStroke(key: "\r", command: true) case .closeTab: return ShortcutStroke(key: "w", command: true) case .closeOtherTabsInPane: return ShortcutStroke(key: "t", command: true, option: true) diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift index 04e8df3e8240..17456aee79eb 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift @@ -264,6 +264,7 @@ extension ShortcutAction { self != .fileExplorerOpenSelection && self != .fileExplorerOpenSelectionFinderAlias && self != .cycleTextBoxSubmitAction + && self != .toggleChecklistItemComplete } /// The action's built-in focus context expressed as a ``ShortcutWhenClause``, diff --git a/Sources/AppDelegate+DockSurfaceMove.swift b/Sources/AppDelegate+DockSurfaceMove.swift index 0b5463dfa7fa..052d1e9c437b 100644 --- a/Sources/AppDelegate+DockSurfaceMove.swift +++ b/Sources/AppDelegate+DockSurfaceMove.swift @@ -174,6 +174,10 @@ extension AppDelegate { focus: Bool, focusWindow: Bool ) -> Bool { + guard let panel = sourceDock.panels[panelId], + canTransferSurfaceAcrossWorkspaceBoundary(panel: panel) else { + return false + } guard let destinationManager = tabManagerFor(tabId: targetWorkspaceId), let destinationWorkspace = destinationManager.tabs.first(where: { $0.id == targetWorkspaceId }) else { return false @@ -232,6 +236,10 @@ extension AppDelegate { focus: Bool = true, focusWindow: Bool = false ) -> Bool { + guard let panel = sourceDock.panels[panelId], + canTransferSurfaceAcrossWorkspaceBoundary(panel: panel) else { + return false + } // A window Dock resolves its owning window; a Workspace Dock resolves // that workspace's window (see `dockReferenceTabManager`). guard let manager = dockReferenceTabManager(for: sourceDock) else { return false } @@ -266,12 +274,23 @@ extension AppDelegate { } private func canMoveSurfaceIntoDock(_ source: ContainerSurfaceLocation) -> Bool { - if case .workspace(_, let workspace, _, _) = source, - workspace.isRemoteTmuxMirror { - // Remote tmux mirror panes are manually driven by the mirror - // workspace. Dock has no mirror-owned I/O routing yet, so moving one - // would leave the Dock panel detached from its remote owner. - return false + switch source { + case .workspace(_, let workspace, let panelId, _): + guard let panel = workspace.panels[panelId], + canTransferSurfaceAcrossWorkspaceBoundary(panel: panel) else { + return false + } + if workspace.isRemoteTmuxMirror { + // Remote tmux mirror panes are manually driven by the mirror + // workspace. Dock has no mirror-owned I/O routing yet, so moving one + // would leave the Dock panel detached from its remote owner. + return false + } + case .dock(let dock, let panelId): + guard let panel = dock.panels[panelId], + canTransferSurfaceAcrossWorkspaceBoundary(panel: panel) else { + return false + } } return true } diff --git a/Sources/AppDelegate+MoveTabToNewWorkspace.swift b/Sources/AppDelegate+MoveTabToNewWorkspace.swift index 5006b8d3ef3c..dc8cb760c6a6 100644 --- a/Sources/AppDelegate+MoveTabToNewWorkspace.swift +++ b/Sources/AppDelegate+MoveTabToNewWorkspace.swift @@ -12,10 +12,15 @@ struct SurfaceNewWorkspaceMoveResult { @MainActor extension AppDelegate { + func canTransferSurfaceAcrossWorkspaceBoundary(panel: any Panel) -> Bool { + panel.panelType != .workspaceTodo + } + func canMoveSurfaceToNewWorkspace(panelId: UUID) -> Bool { guard let source = locateSurface(surfaceId: panelId), let sourceWorkspace = source.tabManager.tabs.first(where: { $0.id == source.workspaceId }), - sourceWorkspace.panels[panelId] != nil else { + let panel = sourceWorkspace.panels[panelId], + canTransferSurfaceAcrossWorkspaceBoundary(panel: panel) else { return false } return sourceWorkspace.panels.count > 1 @@ -29,16 +34,25 @@ extension AppDelegate { func canMoveBonsplitTab(tabId: UUID, toWorkspace targetWorkspaceId: UUID) -> Bool { guard let located = locateBonsplitSurface(tabId: tabId), let sourceWorkspace = located.tabManager.tabs.first(where: { $0.id == located.workspaceId }), - sourceWorkspace.panels[located.panelId] != nil, + let panel = sourceWorkspace.panels[located.panelId], let destinationManager = tabManagerFor(tabId: targetWorkspaceId), destinationManager.tabs.contains(where: { $0.id == targetWorkspaceId }) else { return false } + if sourceWorkspace.id != targetWorkspaceId, + !canTransferSurfaceAcrossWorkspaceBoundary(panel: panel) { + return false + } return true } func workspaceMoveTargets(forSurface panelId: UUID) -> [WorkspaceMoveTarget] { - guard let source = locateSurface(surfaceId: panelId) else { return [] } + guard let source = locateSurface(surfaceId: panelId), + let sourceWorkspace = source.tabManager.tabs.first(where: { $0.id == source.workspaceId }), + let panel = sourceWorkspace.panels[panelId], + canTransferSurfaceAcrossWorkspaceBoundary(panel: panel) else { + return [] + } return workspaceMoveTargets( excludingWorkspaceId: source.workspaceId, referenceWindowId: source.windowId @@ -46,7 +60,12 @@ extension AppDelegate { } func workspaceMoveTargets(forBonsplitTab tabId: UUID) -> [WorkspaceMoveTarget] { - guard let located = locateBonsplitSurface(tabId: tabId) else { return [] } + guard let located = locateBonsplitSurface(tabId: tabId), + let sourceWorkspace = located.tabManager.tabs.first(where: { $0.id == located.workspaceId }), + let panel = sourceWorkspace.panels[located.panelId], + canTransferSurfaceAcrossWorkspaceBoundary(panel: panel) else { + return [] + } return workspaceMoveTargets( excludingWorkspaceId: located.workspaceId, referenceWindowId: located.windowId @@ -88,7 +107,8 @@ extension AppDelegate { guard let source = locateSurface(surfaceId: panelId), let sourceWorkspace = source.tabManager.tabs.first(where: { $0.id == source.workspaceId }), let sourcePanel = sourceWorkspace.panels[panelId], - sourceWorkspace.panels.count > 1 else { + sourceWorkspace.panels.count > 1, + canTransferSurfaceAcrossWorkspaceBoundary(panel: sourcePanel) else { return nil } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index da3023ebb298..fb81d55fc564 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -13860,8 +13860,7 @@ struct TabItemView: View, Equatable { // workspace-todos feature is on and there is either content or a // pending "Add Checklist Item…" request (which needs the add // field visible on an empty checklist). - if workspaceSnapshot.taskStatus != nil, - !workspaceSnapshot.checklistItems.isEmpty || checklistAddFieldActivationToken > 0 { + if !workspaceSnapshot.checklistItems.isEmpty || checklistAddFieldActivationToken > 0 { SidebarWorkspaceChecklistSection( items: workspaceSnapshot.checklistItems, completedCount: workspaceSnapshot.checklistCompletedCount, diff --git a/Sources/DockSplitStore+SurfaceTransfer.swift b/Sources/DockSplitStore+SurfaceTransfer.swift index deb3e00efe74..966c682877d6 100644 --- a/Sources/DockSplitStore+SurfaceTransfer.swift +++ b/Sources/DockSplitStore+SurfaceTransfer.swift @@ -208,6 +208,7 @@ extension DockSplitStore { focus: Bool = true ) -> UUID? { guard bonsplitController.allPaneIds.contains(paneId), panels[detached.panelId] == nil else { return nil } + guard detached.panel.panelType != .workspaceTodo else { return nil } let panel = detached.panel if let terminal = panel as? TerminalPanel { diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 61ac4c898e61..3a7a383236c6 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -410,15 +410,11 @@ enum KeyboardShortcutSettings { case .editWorkspaceDescription: return StoredShortcut(key: "e", command: true, shift: false, option: true, control: false) case .markWorkspaceDone: - // Cmd+; pins the selected workspace's status to done. The - // Cmd-"D" family was taken by split/diff actions and the - // natural Cmd+Ctrl+D chord is reserved by macOS; Cmd+; - // (semicolon) is free and sits next to the status-cycle chord. - return StoredShortcut(key: ";", command: true, shift: false, option: false, control: false) + // Ctrl+Cmd+; avoids macOS spelling shortcuts while keeping the status pair adjacent. + return StoredShortcut(key: ";", command: true, shift: false, option: false, control: true) case .cycleWorkspaceStatus: - // Cmd+Shift+; cycles the selected workspace's status one lane - // forward (todo → working → needs-attention → review → done). - return StoredShortcut(key: ";", command: true, shift: true, option: false, control: false) + // Ctrl+Cmd+Shift+; cycles one lane forward without stealing spelling keys. + return StoredShortcut(key: ";", command: true, shift: true, option: false, control: true) case .toggleChecklistItemComplete: // Cmd+Return toggles the highlighted checklist item in the // focused todo pane / checklist popover. Registered here for @@ -633,7 +629,10 @@ enum KeyboardShortcutSettings { } var allowsChordShortcut: Bool { - self != .fileExplorerOpenSelection && self != .fileExplorerOpenSelectionFinderAlias && self != .cycleTextBoxSubmitAction + self != .fileExplorerOpenSelection + && self != .fileExplorerOpenSelectionFinderAlias + && self != .cycleTextBoxSubmitAction + && self != .toggleChecklistItemComplete } var isBrowserContentShortcut: Bool { diff --git a/Sources/Workspace+PanelLifecycle.swift b/Sources/Workspace+PanelLifecycle.swift index 0d42b598a03f..5c135a261378 100644 --- a/Sources/Workspace+PanelLifecycle.swift +++ b/Sources/Workspace+PanelLifecycle.swift @@ -32,7 +32,7 @@ extension Workspace { var agentLifecycleStatesByPanelId: [UUID: [String: AgentHibernationLifecycleState]] { get { sidebarAgentRuntimeObservation.agentLifecycleStatesByPanelId } - set { sidebarAgentRuntimeObservation.setAgentLifecycleStatesByPanelId(newValue) } + set { sidebarAgentRuntimeObservation.setAgentLifecycleStatesByPanelId(newValue); reconcileExpiredTaskStatusOverride() } } func agentRuntimeState(forPanelId panelId: UUID) -> DetachedAgentRuntimeState? { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index e4c069528032..1cb6dc1065ff 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2275,19 +2275,19 @@ final class Workspace: Identifiable, ObservableObject { } var gitBranch: SidebarGitBranchState? { get { sidebarMetadata.gitBranch } - set { sidebarMetadata.gitBranch = newValue } + set { sidebarMetadata.gitBranch = newValue; reconcileExpiredTaskStatusOverride() } } var panelGitBranches: [UUID: SidebarGitBranchState] { get { sidebarMetadata.panelGitBranches } - set { sidebarMetadata.panelGitBranches = newValue } + set { sidebarMetadata.panelGitBranches = newValue; reconcileExpiredTaskStatusOverride() } } var pullRequest: SidebarPullRequestState? { get { sidebarMetadata.pullRequest } - set { sidebarMetadata.pullRequest = newValue } + set { sidebarMetadata.pullRequest = newValue; reconcileExpiredTaskStatusOverride() } } var panelPullRequests: [UUID: SidebarPullRequestState] { get { sidebarMetadata.panelPullRequests } - set { sidebarMetadata.panelPullRequests = newValue } + set { sidebarMetadata.panelPullRequests = newValue; reconcileExpiredTaskStatusOverride() } } @Published var surfaceListeningPorts: [UUID: [Int]] = [:] var agentListeningPorts: [Int] = [] @@ -9404,6 +9404,9 @@ final class Workspace: Identifiable, ObservableObject { #endif return nil } + guard detached.panel.panelType != .workspaceTodo || detached.sourceWorkspaceId == id else { + return nil + } if let directory = detached.directory { panelDirectories[detached.panelId] = directory diff --git a/docs/configuration.md b/docs/configuration.md index 9b48e10dbf10..442e02c53627 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -292,8 +292,8 @@ Default: `enabled: false`. The setting turns on automatically the first time a s Three keyboard shortcuts drive the todo state, all editable in **Settings > Keyboard Shortcuts** or `shortcuts.bindings`: -- `markWorkspaceDone` (default `cmd+;`) pins the selected workspace's status to done. -- `cycleWorkspaceStatus` (default `cmd+shift+;`) advances the status one lane forward (todo → working → needs-attention → review → done → todo). +- `markWorkspaceDone` (default `ctrl+cmd+;`) pins the selected workspace's status to done. +- `cycleWorkspaceStatus` (default `ctrl+cmd+shift+;`) advances the status one lane forward (todo → working → needs-attention → review → done → todo). - `toggleChecklistItemComplete` (default `cmd+return`) toggles the highlighted checklist item in the focused todo pane or checklist popover. cmux also posts a notification when a workspace's status first reaches done, and when its checklist first becomes fully complete, so you can watch agent progress without keeping the pane open. diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts index 49ccb9a1ec58..1bee9b8b615f 100644 --- a/web/data/cmux-shortcuts.ts +++ b/web/data/cmux-shortcuts.ts @@ -122,8 +122,8 @@ export const shortcutCategories: ShortcutCategory[] = [ { id: "selectWorkspaceByNumber", combos: [["⌘", "1…9"]], description: { en: "Select workspace 1…9", ja: "ワークスペース1…9を選択" } }, { id: "renameWorkspace", combos: [["⌘", "⇧", "R"]], description: { en: "Rename workspace", ja: "ワークスペース名を変更" } }, { id: "editWorkspaceDescription", combos: [["⌥", "⌘", "E"]], description: { en: "Edit workspace description", ja: "ワークスペースの説明を編集" } }, - { id: "markWorkspaceDone", combos: [["⌘", ";"]], description: { en: "Mark workspace as done", ja: "ワークスペースを完了にする" } }, - { id: "cycleWorkspaceStatus", combos: [["⌘", "⇧", ";"]], description: { en: "Cycle workspace status one lane forward", ja: "ワークスペースのステータスを1つ先へ切り替え" } }, + { id: "markWorkspaceDone", combos: [["⌃", "⌘", ";"]], description: { en: "Mark workspace as done", ja: "ワークスペースを完了にする" } }, + { id: "cycleWorkspaceStatus", combos: [["⌃", "⌘", "⇧", ";"]], description: { en: "Cycle workspace status one lane forward", ja: "ワークスペースのステータスを1つ先へ切り替え" } }, { id: "toggleChecklistItemComplete", combos: [["⌘", "↩"]],