Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
97058a8
cmux NEXT: Needs you list and the Needs your input pill
teamleaderleo Oct 20, 2026
84fdd42
Needs you: format, layer token, autofocus lint note
teamleaderleo Oct 20, 2026
a627fcf
Needs you: answers as Claude's permission card draws its options
teamleaderleo Oct 21, 2026
a5792e1
Merge remote-tracking branch 'origin/feat-cmux-next' into leo/needs-you
teamleaderleo Oct 21, 2026
26fcf1e
Needs you highlight: a try-it action
teamleaderleo Oct 21, 2026
abaef19
Needs you: review fixes (foreign-chat answers, read races and storms,…
teamleaderleo Oct 21, 2026
3d83169
Merge feat-cmux-next into leo/needs-you
teamleaderleo Oct 21, 2026
7694e90
Agent pane: the Needs you tunables import CmuxNextPages, where base m…
teamleaderleo Oct 21, 2026
54aeca3
test: Needs you reads open asks through the host, rereads after a lag…
teamleaderleo Oct 21, 2026
02822d0
Needs you: read open asks through the host, reread after a lag, ask w…
teamleaderleo Oct 21, 2026
c4305d1
Agent pane strings: keep base's catalog format, add only the needsYou…
teamleaderleo Oct 21, 2026
a500708
Needs you: read the host's pending list without relying on optional-c…
teamleaderleo Oct 21, 2026
a6bbfd1
Merge feat-cmux-next into leo/needs-you
teamleaderleo Oct 21, 2026
8117ccd
cmux-next agent pane: Needs you reads this Mac only from a local chat…
teamleaderleo Oct 21, 2026
fc0a5d3
cmux-next agent pane: Needs you reads the pane's own acpmux, not the …
teamleaderleo Oct 21, 2026
2ee8bf9
cmux-next agent pane: Needs you's pending-asks read imports JSONValue…
teamleaderleo Oct 21, 2026
2ed18a0
cmux-next agent pane: AgentPanePendingAsksTests imports JSONValue's m…
teamleaderleo Oct 21, 2026
0304c67
Merge feat-cmux-next into leo/needs-you
teamleaderleo Oct 21, 2026
667a06b
Merge feat-cmux-next into leo/needs-you
teamleaderleo Oct 21, 2026
4dd1f9b
Needs you: with no ask open, Jump to latest jumps as before
teamleaderleo Oct 21, 2026
d730bbd
test: format agent-pane-needs-you-asks (Oxfmt breaks out two function…
teamleaderleo Oct 21, 2026
a3aaea8
Needs you: small-radius rects and the theme accent in needsYou.css
teamleaderleo Oct 21, 2026
f6cc83c
Merge feat-cmux-next 8db75b7f689c into leo/needs-you
teamleaderleo Oct 21, 2026
037c733
Needs you: the header button's tooltip only while it is icon-only
teamleaderleo Oct 21, 2026
1f22414
Needs you: tests that a chat on another computer is never read
teamleaderleo Oct 21, 2026
2324168
Needs you: a chat on another computer is never read, only shown as wa…
teamleaderleo Oct 21, 2026
e70b6d2
Merge feat-cmux-next f90fbd9b7db9 into leo/needs-you
teamleaderleo Oct 21, 2026
5f21214
Needs you: test that a reply forwarded from a peer named local is ref…
teamleaderleo Oct 21, 2026
959265c
Needs you: the host refuses a pending-asks reply forwarded from a peer
teamleaderleo Oct 21, 2026
977d6b4
Merge feat-cmux-next 2ef423b86f06 into leo/needs-you
teamleaderleo Oct 21, 2026
82978ee
Merge feat-cmux-next d6dcbed05b13 into leo/needs-you
teamleaderleo Oct 21, 2026
b741d7c
Merge feat-cmux-next ad9c59437951 into leo/needs-you
teamleaderleo Oct 21, 2026
0ff30f7
Merge feat-cmux-next 00f97d8084af into leo/needs-you
teamleaderleo Oct 21, 2026
05acd0d
Needs you gallery: Ask about this scrolls the third ask into view bef…
teamleaderleo Oct 21, 2026
38c9c16
Merge feat-cmux-next 037690841164 into leo/needs-you
teamleaderleo Oct 21, 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 @@ -35,7 +35,7 @@ public nonisolated struct AgentPageOps {
"pane.edit", "pane.setQuickActions", "tab.open", "run.attach", "tab.typeAhead", "tab.jump",
"newTab.remember", "newTab.setTemplate", "nowPlaying.command", "newTab.inputReady", "newTab.touched", "newTab.context", "newTab.warm", "shortcut.edit", "action.run", "file.open", "browser.open",
"project.list", "project.icon", "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.upload", "chat.sideChat", "chat.subagentPane", "chat.processTerminal", "chat.openTab", "chat.newHere", "chat.unsentDraft", "chat.scrollAnchor", "chat.scrollAnchor.get", "chat.tail", "chat.tail.get", "chat.draftAttachments", "chat.draftAttachments.get",
"quick.dismiss", "quick.openInWindow", "quick.startInBackground", "quick.setOnTop", "quick.setCollapsed", "quick.floatingState", "quick.context", "pane.action", "pane.tabState", "pane.tabRowTools", "chat.toolCard.respond", "chat.upload", "chat.sideChat", "chat.subagentPane", "chat.processTerminal", "chat.openTab", "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",
Expand Down Expand Up @@ -76,6 +76,12 @@ public final class AgentPageProvider: PageProvider {
public var replay: (@MainActor () -> [AgentPageEvent])?
/// Runs a composer menu edit (`pane.edit`) in the page view; ``AgentPanePageHost`` sets it.
public var onEdit: (@MainActor (AgentPaneEditCommand) -> Void)?
/// The pane's own acpmux socket (``AgentPaneModel/pendingAsksSocket``), asked at each
/// `chat.asks.pending`: its chats are read there and nowhere else. ``AgentPaneView`` sets it;
/// until then, and whenever it is nil, the op answers null.
public var pendingAsksSocket: @MainActor () -> String? = { nil }
/// Reads `chat.asks.pending` through ``pendingAsksSocket`` (tests inject the reader).
public var pendingAsks = AgentPanePendingAsks()

public init(prepare: @escaping Prepare) {
self.prepare = prepare
Expand Down Expand Up @@ -110,6 +116,8 @@ public final class AgentPageProvider: PageProvider {
// `chat.scrollAnchor.forget` would have erased a chat's place instead of being refused.
if AgentPaneScrollAnchors.handles(method: method) { return try Self.scrollAnchor(method, params: params, op: op) }
if AgentPaneTailCache.handles(method: method) { return try Self.tail(method, params: params, op: op) }
// Needs you's read of a chat's open requests: the host's, never the relay's (AgentPanePendingAsks).
if method == "chat.asks.pending" { return try await pendingAsks.reply(params, op: op, socket: pendingAsksSocket()) }
if method.hasPrefix("chat.draftAttachments") { return try await Self.draftAttachments(method, params: params, op: op) }
let request = AgentPaneRequest(body: ["method": method, "params": params.foundationObject])
if case .unsupported = request { throw PageError.invalidParams(op) }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import CmuxNextPages
import CmuxNextSettings
public import Foundation

/// `chat.asks.pending {sessionId}`: the permission requests acpmux holds open for one chat, for the
/// pane's Needs you list (webviews `needsYou/asks.ts`). The pane's relay never sends `_acpmux/info`
/// (it is not in ``AcpmuxPaneMethods/requests``), so the host reads it itself and hands the page
/// only each entry's `permissionId`, `request`, `turnId` and `groupId`: what
/// `_acpmux/permission_pending` already tells the pane's watch of every chat. It only reads;
/// nothing is answered here.
///
/// The read goes to the pane's own acpmux (``AgentPaneModel/pendingAsksSocket``), the daemon its
/// chat lives on: this app's main daemon for a chat on this Mac, the Chief home's own daemon for a
/// Chief chat (`ChiefHomeAcpmux`, its own `ACPMUX_HOME`). A pane never reads another daemon's
/// chats, and a reread of a Chief chat sees its answers (PR #22646 review).
///
/// The read names the chat `local/<id>`, acpmux's qualified id for one of the daemon's own
/// sessions (`session_identity.rs`, hub `resolve`). A chat on another computer (an SSH or Cloud
/// peer's) is listed by the pane's daemon too, and a read naming it by its bare id would be
/// forwarded to that computer's daemon (`server/peer_forward.rs`); `local/<id>` never resolves
/// to it, so its asks are never read here (PR #22646 review). Only that computer answers them.
/// A peer may itself be named `local`, and acpmux keys its rows `local/<id>`, so a reply that names
/// a `peer` (every forwarded reply does, `peer_forward.rs`; a daemon's own chat never does) is
/// refused too.
@MainActor
public final class AgentPanePendingAsks {
/// Reads `_acpmux/info` for `session` (a `local/<id>`, ``localSession(_:)``) from the acpmux
/// listening on `socket`: its result, or nil when acpmux did not answer (no such session of its
/// own there, no daemon).
public typealias Read = @MainActor (_ socket: String, _ session: String) async -> [String: Any]?

/// The keys of a pending entry the page gets.
static let entryKeys: Set<String> = ["permissionId", "request", "turnId", "groupId"]
/// Most entries one reply carries.
static let maximumEntries = 64
/// Longest session id the op takes (acpmux ids are UUIDs).
static let maximumSession = 256
/// acpmux's host name for the daemon's own sessions (`LOCAL_HOST_ID`).
static let localHost = "local/"

/// `session` as acpmux's qualified id for the daemon's own session (`local/<id>`): a bare id is
/// qualified, one already qualified as local is kept, and an id naming another host (or any
/// other `/`) is nil, so it is never sent.
static func localSession(_ session: String) -> String? {
let mux = session.hasPrefix(localHost) ? String(session.dropFirst(localHost.count)) : session
guard !mux.isEmpty, !mux.contains("/") else { return nil }
return localHost + mux
}

private let readInfo: Read

public init(read: @escaping Read) {
readInfo = read
}

/// Reads with one short plain connection to the socket per read, as the agent CLI card's
/// requests on the same socket do (``AgentPaneHarnessCard``). `_acpmux/info` needs no person
/// proof (acpmux `server/requests.rs` answers it on any local connection), so none is made:
/// this read holds no more authority than the pane's own socket gives.
public convenience init() {
self.init { socket, session in
try? await AcpmuxStatusClient.call(socketPath: socket, method: "_acpmux/info", params: ["sessionId": session],
deadline: .seconds(5))
}
}

/// The op's answer: `{pending: [entry]}`, or null when the chat could not be read. Throws for
/// params that are not exactly a session id. `socket`: the calling pane's own acpmux
/// (``AgentPaneModel/pendingAsksSocket``); nil (a chat on another machine, a pane before its
/// handshake, a closed pane) answers null for every id and reads nothing.
func reply(_ params: JSONValue, op: String, socket: String?) async throws -> JSONValue {
guard let body = params.foundationObject as? [String: Any], Set(body.keys) == ["sessionId"],
let session = body["sessionId"] as? String, !session.isEmpty, session.utf8.count <= Self.maximumSession
else { throw PageError.invalidParams(op) }
guard let socket, !socket.isEmpty else { return .null }
return await pending(of: session, at: socket)
}

/// `session`'s open requests on the acpmux at `socket`, filtered to ``entryKeys``.
func pending(of session: String, at socket: String) async -> JSONValue {
guard let local = Self.localSession(session),
let info = await readInfo(socket, local), info["peer"] == nil, let pending = info["pending"] as? [[String: Any]] else { return .null }
let entries = pending.prefix(Self.maximumEntries).map { entry in entry.filter { Self.entryKeys.contains($0.key) } }
return JSONValue(foundation: ["pending": entries]) ?? .null
}
}

extension AgentPaneModel {
/// The acpmux socket Needs you reads this pane's chats through (`chat.asks.pending`): the
/// pane's own daemon from its last handshake (``AgentPaneTransport/acpmuxSocket``, set only for
/// a connection on this Mac). Nil for a chat on another machine (``remoteChat``, or a
/// ``RemoteAcpmuxWire`` connection, scoped to that one session) and before the handshake.
var pendingAsksSocket: String? {
guard !remoteChat, !transport.remoteWire else { return nil }
return transport.acpmuxSocket
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
public import CmuxNextDesign
import CmuxNextPages

/// How the agent pane's header draws Needs you (webviews needsYou/NeedsYou.tsx):
/// `agentPane.debug.needsYou`. `pinned` sets nothing on the page.
public nonisolated enum AgentPaneNeedsYouStyle: String, Sendable, CaseIterable, TunableChoice {
/// A badge with the count of open asks; a press opens every ask with its answers (default).
case pinned
/// The same list, one line per ask, with no answers in it.
case compact
/// No badge.
case off

public var tunableTitle: String {
switch self {
case .pinned: "Pinned list with answers (default)"
case .compact: "Compact list, one line per ask"
case .off: "Off"
}
}
}

nonisolated extension AgentPaneTunables {
/// Needs you (Leo 2026-10-20): one pinned list of every open ask (approvals, decisions,
/// verify-this asks, held messages) from every agent chat and the Chief, behind a badge in the
/// chat header. On by default. New agent tabs read it.
public static let needsYou = Tunable<AgentPaneNeedsYouStyle>.choice(
"agentPane.debug.needsYou", PageTunables.section, "Needs you list in the chat header",
help: "A badge in the agent chat header counts every open ask; a press lists them with one-click answers, Go to source and Ask about this. New agent tabs.",
default: .pinned, code: "AgentPaneTunables.needsYou")

/// While an ask is open, Jump to latest becomes a "Needs your input" pill that goes to the ask
/// and cycles through the others (Claude Code desktop's, Leo 2026-10-20). On by default; off
/// keeps the plain button. New agent tabs read it.
public static let needsYouPill = Tunable<Bool>.toggle(
"agentPane.debug.needsYouPill", PageTunables.section, "Needs your input pill",
help: "While an agent waits on you, the floating Jump to latest button reads Needs your input and goes to the ask. New agent tabs.",
default: true, code: "AgentPaneTunables.needsYouPill")
}
Original file line number Diff line number Diff line change
Expand Up @@ -304,17 +304,21 @@ public nonisolated struct AgentPaneTunables {
static var documentAttributes: [String: String] {
attributes(foldLongThoughts: foldLongThoughts.value, topStripStyle: topStripStyle.value,
thoughtHeader: thoughtHeader.value, liveThoughtViewport: liveThoughtViewport.value,
thoughtFoldThreshold: thoughtFoldThreshold.value, turnPreviewCards: turnPreviewCards.value,
thoughtFoldThreshold: thoughtFoldThreshold.value, needsYou: needsYou.value,
needsYouPill: needsYouPill.value, turnPreviewCards: turnPreviewCards.value,
streamWords: streamWords.value)
}

static func attributes(foldLongThoughts: Bool, topStripStyle: AgentPaneTopStripStyle,
thoughtHeader: AgentPaneThoughtHeader = .duration,
liveThoughtViewport: Bool = true,
thoughtFoldThreshold: AgentPaneThoughtFoldThreshold = .viewport,
needsYou: AgentPaneNeedsYouStyle = .pinned, needsYouPill: Bool = true,
turnPreviewCards: Bool = false,
streamWords: Bool = true) -> [String: String] {
var attributes: [String: String] = [:]
if needsYou != .pinned { attributes["needs-you"] = needsYou.rawValue }
if !needsYouPill { attributes["needs-you-pill"] = "off" }
if foldLongThoughts { attributes["fold-long-thoughts"] = "on" }
if thoughtFoldThreshold != .viewport { attributes["thought-fold-threshold"] = thoughtFoldThreshold.rawValue }
if thoughtHeader != .duration { attributes["thought-header"] = thoughtHeader.rawValue }
Expand Down Expand Up @@ -399,6 +403,8 @@ public nonisolated struct AgentPaneTunables {
liveLineBelow.descriptor,
liveThoughtViewport.descriptor,
modelMenu.descriptor,
needsYou.descriptor,
needsYouPill.descriptor,
pagesFollowLinkPlacement.descriptor,
newTabPrivateChatLists.descriptor,
newTabWidgets.descriptor,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ public final class AgentPaneView: NSView {
let webView: WKWebView
if pageHost, case .bundled(let index) = source {
let provider = AgentPageProvider { [weak model] _ in model }
// Needs you reads the pane's own acpmux (a Chief chat's is the Chief home's), never another.
provider.pendingAsksSocket = { [weak model] in model?.pendingAsksSocket }
guard let page = AgentPanePageHost.makePage(root: index.deletingLastPathComponent(), provider: provider, renderRate: renderRate)
else { return nil }
self.page = page
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Testing
@testable import CmuxNextAgentPane

/// Debug Settings' Needs you list and pill: both on by default, so the defaults set no attribute;
/// the other choices set `data-needs-you` / `data-needs-you-pill` for the page (needsYou/NeedsYou.tsx).
struct AgentPaneNeedsYouTunablesTests {
@Test func defaultsAreOnAndSetNoAttribute() {
#expect(AgentPaneTunables.needsYou.descriptor.key == "agentPane.debug.needsYou")
#expect(AgentPaneTunables.needsYou.defaultValue == .pinned)
#expect(AgentPaneTunables.needsYouPill.descriptor.key == "agentPane.debug.needsYouPill")
#expect(AgentPaneTunables.needsYouPill.defaultValue == true)
#expect(AgentPaneTunables.attributes(foldLongThoughts: false, topStripStyle: .default).isEmpty)
}

@Test(arguments: [AgentPaneNeedsYouStyle.compact, .off])
func otherStylesSetTheirAttribute(style: AgentPaneNeedsYouStyle) {
#expect(AgentPaneTunables.attributes(foldLongThoughts: false, topStripStyle: .default, needsYou: style)
== ["needs-you": style.rawValue])
}

@Test func pillOffSetsItsAttribute() {
#expect(AgentPaneTunables.attributes(foldLongThoughts: false, topStripStyle: .default, needsYouPill: false)
== ["needs-you-pill": "off"])
}

@Test func listedInDebugSettings() {
#expect(AgentPaneTunables.all.contains { $0.key == "agentPane.debug.needsYou" })
#expect(AgentPaneTunables.all.contains { $0.key == "agentPane.debug.needsYouPill" })
#expect(AgentPaneNeedsYouStyle.allCases.map(\.rawValue) == ["pinned", "compact", "off"])
}
}
Loading