Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,124 @@
{
"sourceLanguage": "en",
"strings": {
"action.palette.forkAgentConversationTo": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Fork to…"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "إنشاء فرع في…"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Verzweigen nach…"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Créer une branche dans…"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Crear rama en…"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "分岐先…"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "다음 위치로 분기…"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "分支到…"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "分支至…"
}
}
}
},
"action.palette.forkAgentConversation": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Fork Conversation"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "إنشاء فرع للمحادثة"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Unterhaltung verzweigen"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Créer une branche de la conversation"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Crear rama de la conversación"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "会話を分岐"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "대화 분기"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "创建对话分支"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "建立對話分支"
}
}
}
},
"action.cloudOpenDesktop": {
"extractionState": "manual",
"localizations": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,7 @@ extension ActionDescriptor {
"notifications.dismissal.keystroke", "notifications.dismissal.never",
"notifications.dismissal.timeout",
]),
PaletteFamily("forkConversation", title: familyTitle("palette.family.forkConversation", "Fork Conversation…"),
symbol: "arrow.triangle.branch", members: [
PaletteFamily("forkConversation", parent: "palette.forkAgentConversation", members: [
"palette.forkAgentConversationRight", "palette.forkAgentConversationLeft",
"palette.forkAgentConversationTop", "palette.forkAgentConversationBottom",
"palette.forkAgentConversationNewTab", "palette.forkAgentConversationNewWorkspace",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ nonisolated extension ActionSurfaceCatalog {
"notifications.previewSound", "notifications.importSound",
"notifications.dismissal.focus", "notifications.dismissal.click", "notifications.dismissal.explicit",
"notifications.dismissal.timeout", "notifications.dismissal.never", "palette.newAgentChat", "palette.quickAgentChat",
"palette.launchClaudeTeams", "palette.launchCodexTeams", "palette.forkAgentConversationRight",
"palette.launchClaudeTeams", "palette.launchCodexTeams", "palette.forkAgentConversation", "palette.forkAgentConversationRight",
"palette.forkAgentConversationLeft", "palette.forkAgentConversationTop", "palette.forkAgentConversationBottom",
"palette.forkAgentConversationNewTab", "palette.forkAgentConversationNewWorkspace", "palette.forkAgentConversationNewWindow", "computerUseStop",
"newCloudWorkspace", "newCloudMachine", "palette.cloud.fork", "palette.cloud.snapshot",
Expand Down Expand Up @@ -168,7 +168,7 @@ nonisolated extension ActionSurfaceCatalog {
static let cliExemptionsByReason: [SurfaceExemption: [ActionID]] = [
.ownerVerb: ownerVerbActions,
.guiOnly: [
"agentHistory.show", "music.open", "music.stop",
"agentHistory.show", "music.open", "music.stop", "palette.forkAgentConversationTo",
"notifications.dismissHighlight", "openSettings", "minimizeWindow", "toggleFullScreen", "globalSearch", "commandPalette", "appearance.customize",
"zoomWindow", "selectNextWindow", "selectPreviousWindow",
"palette.openTaskManager", "palette.sleepyMode", "about",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,13 +147,15 @@ nonisolated extension ActionSurfaceCatalog {
// A tab kind: listed with the others, before the profile and New Tab Page rows (c0c88e7b24ff).
"macScreen.open": [p(.newTab, .create, 3)],
"newTab.page": [p(.newTab, .create, 101)],
"palette.forkAgentConversationRight": [p(.terminalSelection, .create, 200, folder: .agent), p(.agentChat, .create, 200, folder: .agent)],
"palette.forkAgentConversationLeft": [p(.terminalSelection, .create, 202, folder: .agent), p(.agentChat, .create, 202, folder: .agent)],
"palette.forkAgentConversationTop": [p(.terminalSelection, .create, 203, folder: .agent), p(.agentChat, .create, 203, folder: .agent)],
"palette.forkAgentConversationBottom": [p(.terminalSelection, .create, 204, folder: .agent), p(.agentChat, .create, 204, folder: .agent)],
"palette.forkAgentConversationNewTab": [p(.terminalSelection, .create, 201, folder: .agent), p(.agentChat, .create, 201, folder: .agent)],
"palette.forkAgentConversationNewWorkspace": [p(.terminalSelection, .create, 205, folder: .agent), p(.agentChat, .create, 205, folder: .agent)],
"palette.forkAgentConversationNewWindow": [p(.terminalSelection, .create, 206, folder: .agent), p(.agentChat, .create, 206, folder: .agent)],
"palette.forkAgentConversation": [p(.terminalSelection, .create, 199), p(.agentChat, .create, 199)],
"palette.forkAgentConversationTo": [p(.terminalSelection, .create, 200, .submenu), p(.agentChat, .create, 200, .submenu)],
"palette.forkAgentConversationRight": [p(.terminalSelection, .create, 200, in: "palette.forkAgentConversationTo"), p(.agentChat, .create, 200, in: "palette.forkAgentConversationTo")],
"palette.forkAgentConversationLeft": [p(.terminalSelection, .create, 202, in: "palette.forkAgentConversationTo"), p(.agentChat, .create, 202, in: "palette.forkAgentConversationTo")],
"palette.forkAgentConversationTop": [p(.terminalSelection, .create, 203, in: "palette.forkAgentConversationTo"), p(.agentChat, .create, 203, in: "palette.forkAgentConversationTo")],
"palette.forkAgentConversationBottom": [p(.terminalSelection, .create, 204, in: "palette.forkAgentConversationTo"), p(.agentChat, .create, 204, in: "palette.forkAgentConversationTo")],
"palette.forkAgentConversationNewTab": [p(.terminalSelection, .create, 201, in: "palette.forkAgentConversationTo"), p(.agentChat, .create, 201, in: "palette.forkAgentConversationTo")],
"palette.forkAgentConversationNewWorkspace": [p(.terminalSelection, .create, 205, in: "palette.forkAgentConversationTo"), p(.agentChat, .create, 205, in: "palette.forkAgentConversationTo")],
"palette.forkAgentConversationNewWindow": [p(.terminalSelection, .create, 206, in: "palette.forkAgentConversationTo"), p(.agentChat, .create, 206, in: "palette.forkAgentConversationTo")],
"newCloudWorkspace": [p(.sidebarBackground, .create, 200, folder: .new)],
"palette.cloud.fork": [p(.cloudMachine, .connection, 203, folder: .connection)],
"palette.cloud.snapshot": [p(.cloudMachine, .connection, 202, folder: .connection)],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -288,10 +288,23 @@ nonisolated enum AgentActionCatalog: ActionCatalogGroup {
keywords: ["agent", "codex", "team"], category: .agents, symbol: "person.3.fill", surfaces: [.palette],
cliName: "agent launch-codex-teams"
),
ActionDescriptor(
id: "palette.forkAgentConversation",
title: String(localized: "action.palette.forkAgentConversation", defaultValue: "Fork Conversation", bundle: .module),
keywords: ["agent", "fork", "branch"], defaultShortcut: Shortcut("y", modifiers: [.option, .command]),
category: .agents, symbol: "arrow.triangle.branch", surfaces: [.palette, .contextMenu],
cliName: "agent fork-conversation"
),
ActionDescriptor(
id: "palette.forkAgentConversationTo",
title: String(localized: "action.palette.forkAgentConversationTo", defaultValue: "Fork to…", bundle: .module),
keywords: ["agent", "fork"], category: .agents, symbol: "arrow.triangle.branch",
surfaces: [.contextMenu]
),
ActionDescriptor(
id: "palette.forkAgentConversationRight",
title: String(localized: "action.palette.forkAgentConversationRight", defaultValue: "Fork Conversation to the Right", bundle: .module),
keywords: ["agent", "fork", "branch", "split"], defaultShortcut: Shortcut("y", modifiers: [.option, .command]),
keywords: ["agent", "fork", "branch", "split"],
category: .agents, symbol: "rectangle.righthalf.inset.filled",
surfaces: [.palette, .contextMenu],
cliName: "agent fork-conversation-to-right"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,9 @@ public enum AgentForkDestination: String, CaseIterable, Sendable {
/// The destination a fork takes when none is named.
public static let `default`: AgentForkDestination = .right

/// The destination a page's `placement` names; a missing or unknown one is a tab, as the
/// chat page's Fork opened before destinations existed.
/// Every entry point uses the same default when the page names no destination.
public init(placement: String?) {
self = placement.flatMap(Self.init(rawValue:)) ?? .tab
self = placement.flatMap(Self.init(rawValue:)) ?? .default
}

/// The `direction` argument of `tab.moveToNewSplit` for a split destination, else nil.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import AppKit
import CmuxNextActions

/// Destination choices are secondary to the one-step Fork action on every surface.
@MainActor enum AgentForkMenu {
static var title: String {
ActionCatalog.all.first { $0.id == "palette.forkAgentConversationTo" }?.title ?? ""
}

static let destinations: [(AgentForkDestination, ActionID)] = [
(.right, "palette.forkAgentConversationRight"), (.left, "palette.forkAgentConversationLeft"),
(.up, "palette.forkAgentConversationTop"), (.down, "palette.forkAgentConversationBottom"),
(.tab, "palette.forkAgentConversationNewTab"), (.workspace, "palette.forkAgentConversationNewWorkspace"),
(.window, "palette.forkAgentConversationNewWindow"),
]

static func menu(perform: @escaping (AgentForkDestination) -> Void) -> NSMenu {
let menu = NSMenu(title: title)
for (destination, id) in destinations {
guard let action = ActionCatalog.all.first(where: { $0.id == id }) else { continue }
menu.addItem(AgentPaneMenuAction.item(action.title, symbol: action.symbol) {
perform(destination)
})
}
return menu
}

static func item(perform: @escaping (AgentForkDestination) -> Void) -> NSMenuItem {
let item = NSMenuItem(title: title, action: nil, keyEquivalent: "")
item.image = AgentPaneContextMenu.icon("arrow.triangle.branch")
item.submenu = menu(perform: perform)
return item
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,11 @@ enum AgentPaneContextMenu {
})
}
}
if let seq = target.forkSeq {
message.append(AgentForkMenu.item { destination in
actions.page("chat.fork", ["throughSeq": String(seq), "placement": destination.rawValue])
})
}
// On a link, the part's Open Link stands for the message's.
links = target.part?.link == nil ? linkItems(target.links, open: actions.open) : []
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ extension AgentPaneView {
/// report from a message's More button (`popUp`) opens that message's menu under the button now.
func receiveContextMenuReport(_ body: Any?) {
if let anchor = AgentPaneContextMenu.popUpAnchor(report: body) {
if let seq = (body as? [String: Any])?["forkDestinations"] as? Int {
// task-owner: one destination menu per Option-click; ends when the menu closes
Task { @MainActor [weak self] in self?.popUpForkMenu(through: seq, anchor: anchor) }
return
}
let target = AgentPaneMessageTarget(report: body)
// task-owner: one menu per More press, after the page's message returns; it ends when the menu closes
Task { @MainActor [weak self] in self?.popUpMessageMenu(target: target, anchor: anchor) }
Expand Down Expand Up @@ -198,6 +203,25 @@ extension AgentPaneView {
menu.popUp(positioning: nil, at: NSPoint(x: left, y: view.isFlipped ? bottom : view.bounds.height - bottom), in: view)
}

func popUpForkMenu(through seq: Int? = nil, anchor: CGRect? = nil) {
let view: NSView = page?.focusView ?? webView
guard view.window != nil else { return }
let menu = AgentForkMenu.menu { [weak self] destination in
var params = ["placement": destination.rawValue]
if let seq { params["throughSeq"] = String(seq) }
self?.runMessageAction(seq == nil ? "chat.forkTo" : "chat.fork", params)
}
menu.themeRows(in: themeScope.fullStrength)
guard let anchor else {
menu.popUp(positioning: nil, at: NSEvent.mouseLocation, in: nil)
return
}
let zoom: CGFloat = page.map { CGFloat(Double(DesignSettings.shared.uiScale) * $0.additionalZoom) }
?? webView.pageZoom * webView.magnification
let bottom = anchor.maxY * zoom + 2
menu.popUp(positioning: nil, at: NSPoint(x: anchor.minX * zoom, y: view.isFlipped ? bottom : view.bounds.height - bottom), in: view)
}

/// The pane's menu for `target` and `selection` in `menu`, which holds WebKit's items (none for More).
private func fillContextMenu(_ menu: NSMenu, target: AgentPaneMessageTarget?, selection: String?) {
let chatMenu = target == nil && selection == nil ? model.chatMenuItems?() ?? [] : []
Expand Down Expand Up @@ -235,6 +259,10 @@ extension AgentPaneView {
/// the user's gesture, as a native permission shortcut is.
private func fork(through seq: Int) {
AgentPanePersonInput.record(model.transport.gestures)
evaluateScript("window.cmuxAcpmuxActions?.['chat.fork']?.({ throughSeq: \(seq) });")
if NSEvent.modifierFlags.contains(.option) {
popUpForkMenu(through: seq)
} else {
runMessageAction("chat.fork", ["throughSeq": String(seq)])
}
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
import AppKit

extension AgentPaneView {
/// Fork Conversation from the palette, the chat's right-click menu or its shortcut: the page
/// forks the open chat through its latest finished turn (`chat.forkTo`) and asks the host to
/// open the fork at `destination` (`chat.openTab`'s `placement`). A chat that cannot fork now
/// (no turn yet, a turn running, a harness without fork) says why in its transcript.
/// (no turn yet, a harness without fork) says why in its transcript.
///
/// The request is the user's gesture, as a native permission shortcut is.
public func forkConversation(to destination: AgentForkDestination) {
public func forkConversation(to destination: AgentForkDestination, offerDestinations: Bool = false) {
AgentPanePersonInput.record(model.transport.gestures)
if offerDestinations, let event = NSApp.currentEvent, event.type == .leftMouseUp, event.modifierFlags.contains(.option) {
popUpForkMenu()
} else {
evaluateScript(Self.forkScript(destination))
}
}

public func chooseForkDestination() {
AgentPanePersonInput.record(model.transport.gestures)
evaluateScript(Self.forkScript(destination))
popUpForkMenu()
}

/// The page call ``forkConversation(to:)`` runs; the destination is one of a closed set of
/// The page call the fork action runs; the destination is one of a closed set of
/// raw values, so the script never carries outside text.
static func forkScript(_ destination: AgentForkDestination) -> String {
"window.cmuxAcpmuxActions?.['chat.forkTo']?.({ placement: '\(destination.rawValue)' });"
Expand Down
Loading
Loading