Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
826a8e8
Add native agent tab row tools protocol
teamleaderleo Oct 29, 2026
0b845f6
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/orch-n…
teamleaderleo Oct 29, 2026
cc4d616
Fix native toolbar callback ownership
teamleaderleo Oct 29, 2026
a4e09e8
Keep native tool state and menus synchronized
teamleaderleo Oct 29, 2026
04d77c1
Fix native tab row fallback and isolation
teamleaderleo Oct 29, 2026
20b34cf
Import design types in agent pane
teamleaderleo Oct 29, 2026
6922521
Re-export design types from agent pane
teamleaderleo Oct 29, 2026
1ade7ac
Accept absent native tool optionals
teamleaderleo Oct 29, 2026
89ac1f6
fix: make native tab tools keyboard accessible
teamleaderleo Oct 29, 2026
1b6a099
fix: cap total native tab menu items
teamleaderleo Oct 29, 2026
7ec6cff
Merge origin/feat-cmux-next into lane/orch-native-tools-a
teamleaderleo Oct 29, 2026
909b6a9
fix: keep native menu tools on their menu activation path
teamleaderleo Oct 29, 2026
b9ddc92
fix: authenticate native tab tool gestures
teamleaderleo Oct 29, 2026
9d942e2
fix: gate native tab tools on displayed page host
teamleaderleo Oct 29, 2026
0b9c3d9
fix: observe displayed agent model for native tools
teamleaderleo Oct 29, 2026
10f356b
fix: preserve disabled native tools in overflow
teamleaderleo Oct 30, 2026
f090747
fix: bound native tool text bytes
teamleaderleo Oct 30, 2026
52434fd
fix: refresh native tools after tab ownership changes
teamleaderleo Oct 30, 2026
7ec0e53
Gate native tab-row tools on shared-host event support
teamleaderleo Oct 30, 2026
90fcb5a
Authenticate native tool selections before returning page events
teamleaderleo Oct 30, 2026
34192e4
Cover encoded text bounds and require decoded optional rows
teamleaderleo Oct 30, 2026
48c9a37
Observe the displayed page owner and bind its toolbar callbacks
teamleaderleo Oct 30, 2026
f9e4339
Preserve disabled native tools in overflow menus
teamleaderleo Oct 30, 2026
bc40506
Place native tool ownership state with agent pane models
teamleaderleo Oct 30, 2026
db2fe2b
Merge remote-tracking branch 'origin/feat-cmux-next' into HEAD
teamleaderleo Oct 30, 2026
8f0b7bb
cmux next: the tabRow event reports the native-tools flag last offere…
teamleaderleo Oct 30, 2026
dba1d68
cmux next: native row tools drop on page reload, list only hidden too…
teamleaderleo Oct 30, 2026
5aba361
Refresh native menu enabled state during validation
teamleaderleo Oct 30, 2026
faf5edf
Merge remote native tool review fixes
teamleaderleo Oct 30, 2026
b1b2d35
Merge feat-cmux-next base into native tab-row tools
teamleaderleo Oct 30, 2026
da09f21
Restore design import for tab row tool types
teamleaderleo Oct 30, 2026
a5695e4
Export Observation for native tool owner
teamleaderleo Oct 30, 2026
f1f053f
Restore incoming native tool owner test surface
teamleaderleo Oct 30, 2026
b0d6113
Keep native tool ownership aligned through reparenting
teamleaderleo Oct 30, 2026
e6bc95d
Clear native tool ownership callbacks when panes retire
teamleaderleo Oct 30, 2026
f8e3ebd
Bound native tool ownership tests and preserve adopted hooks
teamleaderleo Oct 30, 2026
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
Expand Up @@ -71,6 +71,13 @@ public nonisolated struct AgentPageEvent: Equatable, Sendable {
/// A dispatcher command (`searchChats`, `continueIn`, `createCheckpoint`, `permissionAllowOnce`, ...).
public static func command(_ name: String) -> AgentPageEvent { AgentPageEvent(kind: "command", value: .string(name)) }

/// A native tab-row control or one of its menu items was invoked.
public static func tabRowToolInvoked(id: String, menuItemId: String? = nil) -> AgentPageEvent {
var value: [String: Any] = ["id": id]
if let menuItemId { value["menuItemId"] = menuItemId }
return AgentPageEvent(kind: "tabRowToolInvoked", value: JSONValue(foundation: value) ?? .null)
}

/// The palette's side-question command, carrying the session selected when it was invoked.
public static func sideQuestion(_ sessionId: String) -> AgentPageEvent {
AgentPageEvent(kind: "sideQuestion", value: .string(sessionId))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public nonisolated struct AgentPageOps {
"pane.edit", "pane.setQuickActions", "tab.open", "run.attach", "tab.typeAhead", "tab.jump",
"newTab.remember", "newTab.setTemplate", "newTab.editWidgets", "nowPlaying.command", "newTab.inputReady", "newTab.touched", "newTab.context", "newTab.warm", "shortcut.edit", "action.run", "file.open", "browser.open",
"project.list", "project.icon", "agent.avatar", "agent.avatar.set", "agent.avatar.remove", "chief.face", "harness.symbol", "project.browse", "workspace.chooseFolder", "workspace.useFolder", "chat.folder.choose", "chat.attach.choose", "chat.attach.dropped", "app.action", "chats.open", "chats.openInTerminal", "chats.watch", "chats.page",
"quick.dismiss", "quick.openInWindow", "quick.startInBackground", "quick.setOnTop", "quick.setCollapsed", "quick.floatingState", "quick.context", "pane.action", "pane.tabState", "pane.tabRowTools", "chat.toolCard.respond", "chat.computerUsePreview", "chat.upload", "chat.btw.inline", "chat.sideChat", "chat.subagentPane", "chat.processTerminal", "chat.openTab", "chat.offer.open", "chat.asks.open", "chat.asks.answer", "chat.newHere", "chat.unsentDraft", "chat.scrollAnchor", "chat.scrollAnchor.get", "chat.tail", "chat.tail.get", "chat.draftAttachments", "chat.draftAttachments.get", "chat.asks.pending",
"quick.dismiss", "quick.openInWindow", "quick.startInBackground", "quick.setOnTop", "quick.setCollapsed", "quick.floatingState", "quick.context", "pane.action", "pane.tabState", "pane.tabRowTools", "pane.tabRowTools.set", "chat.toolCard.respond", "chat.computerUsePreview", "chat.upload", "chat.btw.inline", "chat.sideChat", "chat.subagentPane", "chat.processTerminal", "chat.openTab", "chat.offer.open", "chat.asks.open", "chat.asks.answer", "chat.newHere", "chat.unsentDraft", "chat.scrollAnchor", "chat.scrollAnchor.get", "chat.tail", "chat.tail.get", "chat.draftAttachments", "chat.draftAttachments.get", "chat.asks.pending",
"shell.run", "shell.read", "shell.stop", "harness.update", "harness.restart",
"git.diff", "git.status", "git.commit", "git.push", "git.worktree.create", "git.worktree.apply", "git.worktree.remove", "git.githubRepository", "file.search", "git.checkpoint.diff", "turn.undo", "git.stage", "git.unstage", "git.stageHunk", "git.unstageHunk", "git.pullRequest", "project.instructions", "project.addFiles", "composer.capture",
"dictation.toggle", "dictation.start", "dictation.stop", "dictation.cancel", "dictation.openSettings", "dictation.audio", "dictation.recording.upload", "dictation.recording.cancel",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import Foundation
enum AgentPaneHandshakeReply {
static func reply(to request: AgentPaneRequest, in model: AgentPaneModel) async -> [String: Any] {
// A page that loaded again starts without the tab row: it is offered again.
if request == .ready { model.tabRow.pageLoaded() }
// Its native tools were the old page's: they go too, so no stale button sends an old id to the new page.
if request == .ready {
model.tabRow.pageLoaded()
model.setTabRowTools([])
}
model.setCheckpointAvailable(false)
model.pageCommands = []
do {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AppKit
import CmuxNextActions
import CmuxNextDesign
import Foundation

/// The app side of the chat header (`pane.action`, `pane.tabState`): runs a
Expand Down Expand Up @@ -166,6 +167,10 @@ extension AgentPaneModel {
case .tabRowTools(let width):
tabRow.report(width: CGFloat(width)) // used only while chrome.tabs.rowTools is on and the pane offered the row
return AgentPaneReply.success()
case .tabRowToolsSet(let tools):
guard tabRow.nativeToolsEnabled else { return AgentPaneReply.success() }
setTabRowTools(tools)
return AgentPaneReply.success()
case .tabState(let refresh):
guard let header else {
return AgentPaneReply.failure(code: "unsupported", message: "Unsupported agent pane request: pane.tabState")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
public import AppKit
public import CmuxNextDesign
public import CmuxNextSettings
public import Foundation
public import Observation
Expand Down Expand Up @@ -103,6 +104,9 @@ public final class AgentPaneModel {
@ObservationIgnored public var header: AgentPaneHeaderHooks?
/// The header's tools in the pane's tab row (``AgentPaneTabRow``, `ChromeTunables.tabRowTools`).
@ObservationIgnored public let tabRow = AgentPaneTabRow()
/// Declarative native controls published by the page for the shared tab row.
public private(set) var tabRowTools: [PaneTabRowTool] = []
@ObservationIgnored public var onTabRowToolInvoked: ((String, String?) -> Void)?
/// Gets the composer's dictation requests (the pane's mic).
@ObservationIgnored public var onDictation: ((AgentPaneDictationCommand) -> Void)?
/// Takes back a long recording the pane's dictation gave the page, for chat `session`
Expand Down Expand Up @@ -298,7 +302,7 @@ public final class AgentPaneModel {
// Boot traffic, and a request the host refused (it changed nothing), leave it untouched.
case .ready, .reconnect, .uiPointerReport, .framePacing, .renderRate, .checkpointAvailability, .commandAvailability, .painted, .newTabInputReady, .newTabContext, .newTabWarm, .unsupported,
.transportOpen, .transportSend, .transportClose, .transportGesture, .transportGestureRelease, .chatsPage, .quickFloatingState, .quickContext,
.tabRowTools, .projectIcon, .agentAvatar, .chiefFace, .harnessSymbol, .environment(.list), .cloudMachine(.list), .sandboxState: break
.tabRowTools, .tabRowToolsSet, .projectIcon, .agentAvatar, .chiefFace, .harnessSymbol, .environment(.list), .cloudMachine(.list), .sandboxState: break
case .reply(let reply) where reply.isPassive: break
default:
if !userTouched { touchedBy = String(String(describing: request).prefix { $0 != "(" }) }
Expand Down Expand Up @@ -480,7 +484,7 @@ public final class AgentPaneModel {
} catch {
return AgentPaneReply.failure(code: "btw_failed", message: "Inline side question failed")
}
case .paneAction, .diffTurn, .preview, .setQuickActions, .tabState, .tabRowTools, .sideChat, .subagentPane, .processTerminal, .chatTab, .offerOpen, .asksOpen, .asksAnswer, .newChatHere: return await respondToHeader(request)
case .paneAction, .diffTurn, .preview, .setQuickActions, .tabState, .tabRowTools, .tabRowToolsSet, .sideChat, .subagentPane, .processTerminal, .chatTab, .offerOpen, .asksOpen, .asksAnswer, .newChatHere: return await respondToHeader(request)
case .appAction(let id):
guard newTab?.omnibar.actions.contains(where: { $0.id == id }) == true, let onAppAction else { return Self.unsupported("app.action") }
onAppAction(id)
Expand Down Expand Up @@ -532,6 +536,11 @@ public final class AgentPaneModel {
onSessionChange?(id)
}

func setTabRowTools(_ tools: [PaneTabRowTool]) {
guard tabRowTools != tools else { return }
tabRowTools = tools
}

func setCheckpointAvailable(_ available: Bool) {
guard checkpointAvailable != available else { return }
checkpointAvailable = available
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ enum AgentPanePersonInput {
/// Records the gesture of a native choice (a menu item, a shortcut, a drop) only when the event
/// in dispatch is the person's: a posted click or key, or a scripted run, grants nothing.
/// `answer`: the native permission shortcut, which may answer a prompt as a click does.
@MainActor static func record(_ gestures: AgentPaneUserGestures, if byPerson: Bool = true, answer: Bool = false) {
if byPerson, currentIsPerson { gestures.record(click: answer) }
@MainActor static func record(_ gestures: AgentPaneUserGestures, if byPerson: Bool = true, answer: Bool = false,
event: NSEvent? = NSApp.currentEvent) {
if byPerson, let event, isPerson(event) { gestures.record(click: answer) }
}

static func executablePath(_ pid: pid_t) -> String? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,9 @@ extension AgentPaneRequest {
} else {
self = .unsupported(method)
}
case "pane.tabRowTools.set":
if let tools = AgentPaneTabRowTools.list(params?["tools"]) { self = .tabRowToolsSet(tools) }
else { self = .unsupported(method) }
case "pane.setQuickActions":
if let list = AgentPaneQuickAction.list(params?["quickActions"]) { self = .setQuickActions(list) }
else { self = .unsupported(method) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ extension AgentPaneRequest: CustomStringConvertible, CustomDebugStringConvertibl
case .setQuickActions: "setQuickActions"
case .tabState: "tabState"
case .tabRowTools: "tabRowTools"
case .tabRowToolsSet: "tabRowToolsSet"
case .btwInline: "btwInline"
case .sideChat: "sideChat"
case .chatTab: "chatTab"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import CmuxNextDictation
public import CmuxNextDesign
public import CmuxNextSettings
public import Foundation

Expand Down Expand Up @@ -170,6 +171,8 @@ public nonisolated enum AgentPaneRequest: Equatable, Sendable {
/// `pane.tabRowTools {width}`: the chat header's tools take `width` CSS pixels at the trailing
/// end of the pane's tab row (``AgentPaneTabRow``), 0 when the page draws none there.
case tabRowTools(width: Double)
/// `pane.tabRowTools.set {tools}`: declarative native controls for the shared tab row.
case tabRowToolsSet([PaneTabRowTool])
/// `/btw <question>` asks acpmux for one bounded inline side answer.
case btwInline(question: String, sessionId: String)
/// New side chat: open the fork `sessionId` the page just made beside this chat.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import CmuxNextSettings
public var onChange: (() -> Void)?
/// The row last pushed to the page, nil before the first.
private(set) var offered: (height: CGFloat, trailingInset: CGFloat)?
private var offeredNativeRowTools: Bool?
/// Only a shared page host has the return event stream needed by native controls.
var supportsNativeTools = false
public var nativeToolsEnabled: Bool { supportsNativeTools && ChromeTunables.nativeRowTools.value }

/// The page reported its tools' width.
func report(width: CGFloat) {
Expand All @@ -30,12 +34,13 @@ import CmuxNextSettings
func pageLoaded() {
guard offered != nil || toolsWidth != 0 else { return }
offered = nil
offeredNativeRowTools = nil
toolsWidth = 0
onChange?()
}

/// The `tabRow` event for the row last offered (a page that subscribes again gets it first).
var event: AgentPageEvent { .tabRow(height: offered?.height ?? 0, trailingInset: offered?.trailingInset ?? 0) }
var event: AgentPageEvent { .tabRow(height: offered?.height ?? 0, trailingInset: offered?.trailingInset ?? 0, nativeRowTools: offeredNativeRowTools ?? false) }

/// The tools' width in points at `zoom` (points per CSS pixel); nil while the setting is off or
/// the page draws no tools in the row.
Expand All @@ -55,6 +60,7 @@ import CmuxNextSettings
public static func offer(points height: CGFloat, trailingInset: CGFloat, to view: AgentPaneView) {
let zoom = max(0.25, view.webView.pageZoom)
let on = ChromeTunables.tabRowTools.value && height > 0
let nativeRowTools = view.model.tabRow.nativeToolsEnabled
let row = view.model.tabRow
let next = (height: on ? height / zoom : 0, trailingInset: on ? max(0, trailingInset) / zoom : 0)
let shares = on && row.toolsWidth > 0
Expand All @@ -68,8 +74,9 @@ import CmuxNextSettings
tools: row.sharedWidth(zoom: zoom) ?? 0) : []
page.nativeRects = holes.map { page.convert($0.offsetBy(dx: view.bounds.minX, dy: top), from: view) }
}
guard row.offered.map({ $0 != next }) ?? (next.height > 0) else { return }
guard row.offered.map({ $0 != next || row.offeredNativeRowTools != nativeRowTools }) ?? (next.height > 0 || nativeRowTools) else { return }
row.offered = next
row.offeredNativeRowTools = nativeRowTools
let event = row.event
view.deliver([event], scripts: ["window.cmuxAcpmuxBridge?.applyTabRow?.(\(event.value.compactText));"])
}
Expand All @@ -88,8 +95,8 @@ private func nativeHoles(width: CGFloat, height: CGFloat, trailingInset: CGFloat

extension AgentPageEvent {
/// The tab row the chat header's tools may sit in (`tabRow`): `{height, trailingInset}`.
static func tabRow(height: CGFloat, trailingInset: CGFloat) -> AgentPageEvent {
AgentPageEvent(kind: "tabRow", value: ["height": .number(Double(height)), "trailingInset": .number(Double(trailingInset))])
static func tabRow(height: CGFloat, trailingInset: CGFloat, nativeRowTools: Bool) -> AgentPageEvent {
AgentPageEvent(kind: "tabRow", value: ["height": .number(Double(height)), "trailingInset": .number(Double(trailingInset)), "nativeRowTools": .bool(nativeRowTools)])
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import CmuxNextDesign
import Foundation

/// Strict decoding for the page's declarative native tab-row controls.
nonisolated enum AgentPaneTabRowTools {
nonisolated static let maximumTools = 16
nonisolated static let maximumMenuItems = 32
nonisolated static let maximumTotalMenuItems = 256
nonisolated static let maximumDepth = 2
nonisolated static let maximumText = 64
nonisolated static let maximumTextBytes = 256

nonisolated static func list(_ raw: Any?) -> [PaneTabRowTool]? {
guard let raw = raw as? [Any], raw.count <= maximumTools else { return nil }
var tools: [PaneTabRowTool] = []
tools.reserveCapacity(raw.count)
var totalMenuItems = 0
for value in raw {
guard let parsed = tool(value, totalMenuItems: &totalMenuItems) else { return nil }
tools.append(parsed)
}
return tools
}

private nonisolated static func tool(_ raw: Any, totalMenuItems: inout Int) -> PaneTabRowTool? {
guard let object = raw as? [String: Any],
let id = text(object["id"]), let symbol = text(object["symbol"]), let label = text(object["label"]),
object["enabled"] is Bool, object["toggled"] is Bool,
let enabled = object["enabled"] as? Bool, let toggled = object["toggled"] as? Bool,
let menu = menu(object["menu"], depth: 0, totalMenuItems: &totalMenuItems) else { return nil }
let badge: String?
if let rawBadge = object["badge"], !(rawBadge is NSNull) {
guard let parsed = optionalText(rawBadge) else { return nil }
badge = parsed
} else {
badge = nil
}
return PaneTabRowTool(id: id, symbol: symbol, label: label, enabled: enabled, toggled: toggled,
badge: badge, menu: menu)
}

private nonisolated static func menu(_ raw: Any?, depth: Int, totalMenuItems: inout Int) -> [PaneTabRowMenuItem]? {
guard let raw else { return [] }
guard depth <= maximumDepth, let list = raw as? [Any], list.count <= maximumMenuItems else { return nil }
var rows: [PaneTabRowMenuItem] = []
rows.reserveCapacity(list.count)
for value in list {
totalMenuItems += 1
guard totalMenuItems <= maximumTotalMenuItems,
let parsed = item(value, depth: depth, totalMenuItems: &totalMenuItems) else { return nil }
rows.append(parsed)
}
return rows
}

private nonisolated static func item(_ raw: Any, depth: Int, totalMenuItems: inout Int) -> PaneTabRowMenuItem? {
guard let object = raw as? [String: Any], let id = text(object["id"]), let label = text(object["label"]),
object["enabled"] is Bool, let enabled = object["enabled"] as? Bool,
let children = menu(object["children"], depth: depth + 1, totalMenuItems: &totalMenuItems) else { return nil }
let checked: Bool?
if let rawChecked = object["checked"], !(rawChecked is NSNull) {
guard let parsed = optionalBool(rawChecked) else { return nil }
checked = parsed
} else {
checked = nil
}
let role: String?
if let rawRole = object["role"], !(rawRole is NSNull) {
guard let parsed = optionalRole(rawRole) else { return nil }
role = parsed
} else {
role = nil
}
return PaneTabRowMenuItem(id: id, label: label, enabled: enabled, checked: checked, role: role, children: children)
}

private nonisolated static func text(_ value: Any?) -> String? {
guard let value = value as? String, !value.isEmpty,
value.utf8.count <= maximumTextBytes, value.count <= maximumText else { return nil }
return value
}

private nonisolated static func optionalText(_ value: Any?) -> String? {
guard let value, !(value is NSNull) else { return nil }
return text(value)
}

private nonisolated static func optionalBool(_ value: Any?) -> Bool? {
guard let value, !(value is NSNull) else { return nil }
return value as? Bool
}

private nonisolated static func optionalRole(_ value: Any?) -> String? {
guard let value, !(value is NSNull) else { return nil }
guard let value = text(value), value == "destructive" else { return nil }
return value
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import AppKit

extension AgentPaneView {
/// A native button or menu selection is outside the webview's input monitor. Authenticate
/// its AppKit event before publishing so the ensuing page request can spend one gesture.
/// Keep this synchronous: a later task no longer has the selection's current event.
func invokeTabRowTool(_ id: String, menuItemID: String?, event: NSEvent? = NSApp.currentEvent) {
guard model.tabRow.nativeToolsEnabled, let pageEvents else { return }
AgentPanePersonInput.record(model.transport.gestures, event: event)
pageEvents.publish(.tabRowToolInvoked(id: id, menuItemId: menuItemID))
}
}
Loading
Loading