Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
e4d77b1
test: Session Info Usage and the hover card usage row read acpmux usa…
teamleaderleo Oct 30, 2026
ef9b192
cmux NEXT: Session Info's Usage and the hover card show what each ses…
teamleaderleo Oct 30, 2026
a72f7a8
Session Info Usage: no redraw per token while the numbers hold
teamleaderleo Oct 30, 2026
6bc2ccc
Session Info Usage: unknown is an em dash, and each number has its ne…
teamleaderleo Oct 30, 2026
e0e80cb
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/ao-vis-ui
teamleaderleo Oct 30, 2026
27f6e66
test: Session Info's section order includes Usage after Context
teamleaderleo Oct 30, 2026
5c6aea2
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/ao-vis-ui
teamleaderleo Oct 30, 2026
5199abf
Hover card usage row: round the day's cost half up, as Session Info does
teamleaderleo Oct 30, 2026
2339585
Session Info Usage: no dead clicks, no arrow on its buttons
teamleaderleo Oct 30, 2026
fab65d8
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/ao-vis-ui
teamleaderleo Oct 30, 2026
d1eba87
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/ao-vis-ui
teamleaderleo Oct 30, 2026
0b628f1
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/ao-vis-ui
teamleaderleo Oct 30, 2026
393db11
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/ao-vis-ui
teamleaderleo Oct 30, 2026
cd4225c
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/ao-vis-ui
teamleaderleo Oct 30, 2026
99e7ca0
Merge origin/feat-cmux-next into the carry of #27923
teamleaderleo Oct 31, 2026
e0319f0
Gallery: Session Info Usage sorted by 24h cost with a session whose c…
teamleaderleo Oct 31, 2026
9de6ae6
Session Info Usage: an unknown 24h cost is an em dash in the cost-sor…
teamleaderleo Oct 31, 2026
741a00a
Merge origin/feat-cmux-next into the carry of #27923
teamleaderleo Oct 31, 2026
b58c8b1
test: Session Info's Usage asks acpmux for every chat's usage
teamleaderleo Oct 31, 2026
1c55a89
Merge origin/feat-cmux-next into the carry of #27923
teamleaderleo Oct 31, 2026
dacf261
Session Info's Usage asks acpmux for every chat's usage when it shows
teamleaderleo Oct 31, 2026
221177f
Session Info's Usage: format sessionPages.ts and its test (vp fmt)
teamleaderleo Oct 31, 2026
f687496
test: the hover card's usage comes from a usage read, not the watch's…
teamleaderleo Oct 31, 2026
0039cf9
The sidebar hover card asks acpmux for each chat's usage when the wat…
teamleaderleo Oct 31, 2026
d881860
Merge origin/feat-cmux-next into the carry of #27923
teamleaderleo Oct 31, 2026
b8eadc6
test(usage): read base's section order after the merge (groupOrder is…
teamleaderleo Oct 31, 2026
d05e09b
ChatInspector: pass onCompact once (the merge kept base's and the car…
teamleaderleo Oct 31, 2026
e0c9b7c
Merge branch 'feat-cmux-next' into lane/carry-27923
cmux-bot[bot] Oct 31, 2026
a501cbe
Merge branch 'feat-cmux-next' into lane/carry-27923
teamleaderleo Nov 1, 2026
cc55f24
Merge commit '5a273091bbef97cbeaaee8346088a3499b0a1acc' into cw-28773
lawrencecchen Nov 1, 2026
47381de
Session Info Usage: the carried Usage section, its strings, CSS and t…
lawrencecchen Nov 1, 2026
f38ca14
test(hover-card usage): import CmuxNextAgentActivity and CmuxNextDaem…
lawrencecchen Nov 1, 2026
a8f49cb
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/carry-…
teamleaderleo Nov 1, 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 @@ -44,6 +44,9 @@ public final class AgentTurnStateFeed {
private var watchedDirectory: String?
private var reconnect: Task<Void, Never>?
private var backoff = Backoff(initial: .milliseconds(250), maximum: .seconds(30))
/// This connection's usage read (``usageRequest(cursor:)``): its numbers until the watch's page
/// arrives (the page would replace them), whether the page did, and the pages it read.
private var usageRead: (numbers: [String: AgentChatDetail], paged: Bool, pages: Int) = ([:], false, 0)
private let logger = Logger(subsystem: "com.cmuxterm.app.next", category: "agent-turn-states")

/// - Parameters:
Expand Down Expand Up @@ -103,6 +106,7 @@ public final class AgentTurnStateFeed {
logger.info("agent turn states: watching \(self.socket, privacy: .public)")
let connection = AgentActivityLineConnection(path: socket)
subscription = connection
usageRead = ([:], false, 0)
// Lines decode on the socket queue; one main-actor batch at a time, never a Task per
// line (MainActorLineBatch). The main actor only sets the states that changed.
connection.start(send: Self.watchRequest, decode: Message.init(line:)) { [weak self, weak connection] drain in
Expand All @@ -116,7 +120,8 @@ public final class AgentTurnStateFeed {
}
}

/// `initialize`, then `_acpmux/watch` (id 2), one JSON object per line.
/// `initialize`, then `_acpmux/watch` (id 2), then the usage read (id 3,
/// ``usageRequest(cursor:)``), one JSON object per line.
static let watchRequest: Data = {
let initialize: [String: Any] = [
"jsonrpc": "2.0", "id": 1, "method": "initialize",
Expand All @@ -128,9 +133,27 @@ public final class AgentTurnStateFeed {
payload += (try? JSONSerialization.data(withJSONObject: request)) ?? Data()
payload.append(0x0A)
}
return payload
return payload + usageRequest(cursor: nil)
}()

/// The most pages one usage read follows (`SESSION_PAGE`-sized byte pages: far past any real list).
static let usagePages = 50

/// `_acpmux/sessions` with `usage: true` (id 3), from `cursor` on: acpmux reads each chat's
/// harness records first (one pass at most every 5 s, waited for at most 750 ms) instead of
/// answering the last pass's numbers, or none when nothing asked since it started. The watch's
/// own list carries only those, so the hover card's usage row stayed empty or stale. Asked
/// once each time the watch (re)connects, never on a timer: after it, each chat's turn end
/// brings its own fresh numbers over `_acpmux/session_changed`.
static func usageRequest(cursor: String?) -> Data {
var params: [String: Any] = ["usage": true]
if let cursor { params["cursor"] = cursor }
let request: [String: Any] = ["jsonrpc": "2.0", "id": 3, "method": "_acpmux/sessions", "params": params]
var line = (try? JSONSerialization.data(withJSONObject: request)) ?? Data()
line.append(0x0A)
return line
}

/// One decoded line of the watch (decoded off the main actor).
nonisolated enum Message: Sendable {
/// The watch result: every session's state, last message and the muted sessions.
Expand All @@ -144,6 +167,9 @@ public final class AgentTurnStateFeed {
case change(session: String, state: AgentTurnState?, lastMessage: String?, muted: Bool, title: String?,
generatedTitle: String?, detail: AgentChatDetail?, lane: AgentActivityLane?, startedBy: String?,
deleted: Bool = false, peer: (name: String, stale: Bool)? = nil, sessionName: String? = nil)
/// A page of the usage read (id 3): each listed chat's usage numbers that acpmux read, and
/// the cursor of the next page (nil: the last).
case usage([String: AgentChatDetail], next: String?)
/// One `_acpmux/peer_state_changed`: peer `peer`'s link is now `state` (connected,
/// connecting, degraded, reconnecting or offline; a removed peer's last is offline).
case peerState(peer: String, state: String)
Expand All @@ -157,6 +183,9 @@ public final class AgentTurnStateFeed {
titles: AgentTurnStates.titles(result), generatedTitles: AgentTurnStates.titles(result, includingPrompt: false),
details: AgentChatDetail.details(result), lanes: Self.lanes(result),
startedBy: AgentTurnStateStore.startedBy(result), sessionNames: AgentTurnStates.sessionNames(result))
} else if (message["id"] as? NSNumber)?.intValue == 3, let result = message["result"] as? [String: Any] {
let next = (result["nextCursor"] as? String).flatMap { $0.isEmpty ? nil : $0 }
self = .usage(AgentChatDetail.details(result).filter(\.value.hasUsage), next: next)
} else if message["method"] as? String == "_acpmux/session_changed", let params = message["params"] as? [String: Any],
let change = AgentTurnStates.change(params) {
let purged = params["kind"] as? String == "purged"
Expand Down Expand Up @@ -200,6 +229,9 @@ public final class AgentTurnStateFeed {
if !self.lanes.live { self.lanes.live = true }
// A fresh page (the first, or after a reconnect): the peer list is read again.
if !isMirror { peerStateChanged?(nil, nil) }
// A usage read that answered first: its numbers are newer than the page's.
usageRead.paged = true
if !usageRead.numbers.isEmpty { turns.applyUsage(usageRead.numbers, source: source) }
case .change(let session, let state, let lastMessage, let muted, let title, let generatedTitle, let detail, let lane,
let startedBy, let deleted, let peer, let sessionName):
// Most session records leave the indicator as it was: the store writes (and so
Expand All @@ -210,6 +242,11 @@ public final class AgentTurnStateFeed {
if lanes.lanes[session] != lane { lanes.set(session, lane) }
if deleted, !isMirror { Self.purged?(session) }
if let peer, !isMirror { peerSessionChanged?(peer.name, peer.stale) }
case .usage(let numbers, let next):
usageRead.numbers.merge(numbers) { $1 }
usageRead.pages += 1
if usageRead.paged { turns.applyUsage(numbers, source: source) }
if let next, usageRead.pages < Self.usagePages { subscription?.send(Self.usageRequest(cursor: next)) }
case .peerState(let peer, let state):
if !isMirror { peerStateChanged?(peer, state) }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ nonisolated extension AcpmuxPaneMethods {
"session/cancel": (["sessionId"], []),
// `limit`: the newest page of the session list, the rest by `_acpmux/sessions` `cursor` (cx-2lv1k).
"_acpmux/watch": (["enabled", "limit"], []),
"_acpmux/sessions": (["limit", "cursor"], []),
// `usage`: each chat's usage read first (SessionSummary.usageStats), for Session Info's Usage.
"_acpmux/sessions": (["limit", "cursor", "usage"], []),
// `maxBytes`: the most one page downloads, opening a chat or paging older turns in (cx-2lv1k).
"_acpmux/events": (["sessionId", "afterSeq", "beforeSeq", "limit", "maxBytes", "kinds"], []),
"_acpmux/attachment": (["sessionId", "id"], []),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ public nonisolated struct AgentChatDetail: Hashable, Sendable {
public var laneSinceMs: UInt64?
/// The session's folder (`cwd`).
public var cwd: String?
/// What each model call carries now and the window it fits in, in tokens, and what the
/// session spent in the last 24 hours, in US dollars (acpmux `usageStats`: `contextTokens`,
/// `contextWindow`, `costUsd24h`); nil when acpmux has not read them.
public var contextTokens: Int?
public var contextWindow: Int?
public var costUsd24h: Double?
/// The running turn's prompt is acpmux's notice of other agents' messages ("[inbox] New
/// messages from other agents…", `turn.notice`), not anyone's words: the hover card's
/// History leaves it out.
Expand All @@ -62,14 +68,18 @@ public nonisolated struct AgentChatDetail: Hashable, Sendable {
public var commandsRunning = 0

public init(model: String? = nil, turnStartedAtMs: UInt64? = nil, prompt: String? = nil,
laneState: String? = nil, laneSinceMs: UInt64? = nil, cwd: String? = nil, promptIsNotice: Bool = false,
laneState: String? = nil, laneSinceMs: UInt64? = nil, cwd: String? = nil,
contextTokens: Int? = nil, contextWindow: Int? = nil, costUsd24h: Double? = nil, promptIsNotice: Bool = false,
subagentsRunning: Int = 0, subagentNames: [String] = [], commandsRunning: Int = 0) {
self.model = model
self.turnStartedAtMs = turnStartedAtMs
self.prompt = prompt
self.laneState = laneState
self.laneSinceMs = laneSinceMs
self.cwd = cwd
self.contextTokens = contextTokens
self.contextWindow = contextWindow
self.costUsd24h = costUsd24h
self.promptIsNotice = promptIsNotice
self.subagentsRunning = subagentsRunning
self.subagentNames = subagentNames
Expand All @@ -84,17 +94,40 @@ public nonisolated struct AgentChatDetail: Hashable, Sendable {
return text
}
let lane = summary["lane"] as? [String: Any]
let usage = summary["usageStats"] as? [String: Any]
// A count acpmux could not read is null; a negative or non-finite one is no count.
let amount = { (value: Any?) -> Double? in
guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil }
let amount = number.doubleValue
return amount.isFinite && amount >= 0 ? amount : nil
}
let tokens = { (value: Any?) -> Int? in amount(value).flatMap { $0 < Double(Int.max) ? Int($0) : nil } }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The tokens parser truncates fractional values, so malformed or non-integral contextTokens/contextWindow values are displayed as smaller valid counts instead of an em dash. Reject non-integral numbers with an exact Int conversion.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextBridge/AgentTurnStates.swift, line 104:

<comment>The `tokens` parser truncates fractional values, so malformed or non-integral `contextTokens`/`contextWindow` values are displayed as smaller valid counts instead of an em dash. Reject non-integral numbers with an exact `Int` conversion.</comment>

<file context>
@@ -84,17 +94,40 @@ public nonisolated struct AgentChatDetail: Hashable, Sendable {
+            let amount = number.doubleValue
+            return amount.isFinite && amount >= 0 ? amount : nil
+        }
+        let tokens = { (value: Any?) -> Int? in amount(value).flatMap { $0 < Double(Int.max) ? Int($0) : nil } }
         let count = { (value: Any?) -> Int in max(0, (value as? NSNumber)?.intValue ?? 0) }
         let detail = AgentChatDetail(model: text(summary["model"]), turnStartedAtMs: (turn?["startedAt"] as? NSNumber)?.uint64Value,
</file context>
Suggested change
let tokens = { (value: Any?) -> Int? in amount(value).flatMap { $0 < Double(Int.max) ? Int($0) : nil } }
let tokens = { (value: Any?) -> Int? in amount(value).flatMap { Int(exactly: $0) } }

let count = { (value: Any?) -> Int in max(0, (value as? NSNumber)?.intValue ?? 0) }
let detail = AgentChatDetail(model: text(summary["model"]), turnStartedAtMs: (turn?["startedAt"] as? NSNumber)?.uint64Value,
prompt: text(turn?["prompt"]), laneState: text(lane?["state"]),
laneSinceMs: (lane?["since"] as? NSNumber)?.uint64Value, cwd: text(summary["cwd"]),
contextTokens: tokens(usage?["contextTokens"]),
contextWindow: tokens(usage?["contextWindow"]).flatMap { $0 > 0 ? $0 : nil },
costUsd24h: amount(usage?["costUsd24h"]),
promptIsNotice: turn?["notice"] as? Bool == true,
subagentsRunning: count(summary["subagentsRunning"]),
subagentNames: (summary["subagentNames"] as? [Any] ?? []).compactMap(text),
commandsRunning: count(summary["commandsRunning"]))
return detail == AgentChatDetail() ? nil : detail
}

/// Whether acpmux read any of the chat's usage (`usageStats`).
public var hasUsage: Bool { contextTokens != nil || contextWindow != nil || costUsd24h != nil }

/// This detail with `usage`'s usage numbers in place of its own; the rest stays.
public func withUsage(of usage: AgentChatDetail) -> AgentChatDetail {
var detail = self
detail.contextTokens = usage.contextTokens
detail.contextWindow = usage.contextWindow
detail.costUsd24h = usage.costUsd24h
return detail
}

/// The detail of every session of a watch result that has one.
public static func details(_ result: [String: Any]) -> [String: AgentChatDetail] {
var details: [String: AgentChatDetail] = [:]
Expand Down Expand Up @@ -376,6 +409,17 @@ public final class AgentTurnStateStore {
}
}

/// Lays a usage read's numbers (`_acpmux/sessions` with `usage: true`, by session id) over
/// `source`'s sessions, leaving every other fact as it is; writes only what changed. A session
/// the store does not hold from `source` is skipped: the watch has not listed it.
public func applyUsage(_ usage: [String: AgentChatDetail], source: String = AgentTurnStateStore.localSource) {
for (id, numbers) in usage {
guard let record = sessions[id], record.source == source else { continue }
let detail = (record.detail ?? AgentChatDetail()).withUsage(of: numbers)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: applyUsage can overwrite newer session_changed usage with an older in-flight read, leaving the hover card stale until another refresh. Preserve usageStats.updatedAt and apply a read only when it is at least as new as the stored snapshot.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextBridge/AgentTurnStates.swift, line 418:

<comment>`applyUsage` can overwrite newer `session_changed` usage with an older in-flight read, leaving the hover card stale until another refresh. Preserve `usageStats.updatedAt` and apply a read only when it is at least as new as the stored snapshot.</comment>

<file context>
@@ -376,6 +409,17 @@ public final class AgentTurnStateStore {
+    public func applyUsage(_ usage: [String: AgentChatDetail], source: String = AgentTurnStateStore.localSource) {
+        for (id, numbers) in usage {
+            guard let record = sessions[id], record.source == source else { continue }
+            let detail = (record.detail ?? AgentChatDetail()).withUsage(of: numbers)
+            if record.detail != detail { record.detail = detail }
+        }
</file context>

if record.detail != detail { record.detail = detail }
}
}

/// `source`'s watch closed: nothing live is known of its sessions. Titles stay: a tab keeps
/// reading as its chat while the watch reconnects.
public func clearLive(source: String = AgentTurnStateStore.localSource) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ extension SidebarMapping {
detail.activitySince = Self.activitySince(tabs: tabs, indicator: indicator, turns: turns)
let chat = agentTab?.agentSession.flatMap(turns.detail(for:))
detail.model = chat?.model
detail.contextTokens = chat?.contextTokens
detail.contextWindow = chat?.contextWindow
detail.costUsd24h = chat?.costUsd24h
// The task: the chat's running prompt, else the terminal agent's last prompt (memory
// only in the daemon), else what a working program says it does (OSC 7501 title, msg).
detail.task = chat?.prompt ?? agentTab?.agent?.lastPrompt ?? tabs.lazy.filter { ProgramStatusRecord.strongest($0.programStatus)?.state == .working }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,23 @@ struct WorkspaceHoverCardContent: Equatable {
Calendar.current.isDate(date, inSameDayAs: now) ? clockFormatter.string(from: date) : dayFormatter.string(from: date)
}

/// What the row's agent chat carries per call and spent in the last 24 hours ("312K context ·
/// $1,421 in 24h"), with the context's share of its window as a ring; nil when acpmux reported
/// neither (Leo 2026-10-30: "that visibility into huge-blue guys was (and is?) pretty bad").
static func usageFact(_ detail: SidebarWorkspaceDetail) -> Fact? {
let context = detail.contextTokens.map { Strings.usageContext($0.formatted(.number.notation(.compactName))) }
let cost = detail.costUsd24h.map {
// Half away from zero, as the page's Intl rounds it ($1,420.50 is $1,421 in both).
Strings.usageCost($0.formatted(.currency(code: "USD").precision(.fractionLength($0 >= 100 ? 0 : 2))
.rounded(rule: .toNearestOrAwayFromZero)))
}
let parts = [context, cost].compactMap { $0 }
guard !parts.isEmpty else { return nil }
let share = detail.contextTokens.flatMap { tokens in detail.contextWindow.map { min(1, Double(tokens) / Double($0)) } }
return Fact(icon: .contextUsage, text: parts.joined(separator: " · "), kind: "usage",
accessory: share.map { .ring($0, tone: .secondary) })
}

/// The facts card (the classic layout): one row per meaningful fact. Without `showsGit` the
/// place reads as the folder alone (no branch, no repository from a pull request).
private static func facts(_ workspace: SidebarWorkspace, machine: SidebarMachine?, now: Date,
Expand Down Expand Up @@ -224,6 +241,7 @@ struct WorkspaceHoverCardContent: Equatable {
if let agent = workspace.agentName {
facts.append(Fact(icon: .agentChat, text: [agent, detail.model].compactMap { $0 }.joined(separator: " · "), kind: "agent"))
}
if let usage = usageFact(detail) { facts.append(usage) }
if let task = detail.task { facts.append(Fact(icon: .textDescription, text: task, lines: 3, kind: "task")) }
// What its agents run below their turns now: how many subagents and commands, and which
// (Leo 2026-11-01: "being able to see the # of associated subagents and whatnot").
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,30 @@ public nonisolated struct SidebarWorkspaceDetail: Hashable, Sendable {
/// when it started (the daemon's OSC 133 fact).
public var command: String?
public var commandSince: Date?
/// What the row's agent chat carries per model call and the window it fits in, in tokens,
/// and what it spent in the last 24 hours, in US dollars (acpmux `usageStats`); nil when
/// acpmux has not read them. Leo 2026-10-30: "that visibility into huge-blue guys was (and
/// is?) pretty bad".
public var contextTokens: Int?
public var contextWindow: Int?
public var costUsd24h: Double?
/// What the workspace's agent chats run below their turns now (acpmux, summed over its
/// tabs): running subagents, the first of their names, and background commands.
public var subagentsRunning = 0
public var subagentNames: [String] = []
public var commandsRunning = 0

public init(activitySince: Date? = nil, model: String? = nil, task: String? = nil, needsInput: String? = nil,
command: String? = nil, commandSince: Date? = nil) {
command: String? = nil, commandSince: Date? = nil, contextTokens: Int? = nil, contextWindow: Int? = nil,
costUsd24h: Double? = nil) {
self.activitySince = activitySince
self.model = model
self.task = task
self.needsInput = needsInput
self.command = command
self.commandSince = commandSince
self.contextTokens = contextTokens
self.contextWindow = contextWindow
self.costUsd24h = costUsd24h
}
}
Loading
Loading