diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/AgentTurnStates.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/AgentTurnStates.swift index d05963b25a15..1803b2ddd75d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/AgentTurnStates.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/AgentTurnStates.swift @@ -50,15 +50,25 @@ 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? public init(model: String? = nil, turnStartedAtMs: UInt64? = nil, prompt: String? = nil, - laneState: String? = nil, laneSinceMs: UInt64? = nil, cwd: String? = nil) { + laneState: String? = nil, laneSinceMs: UInt64? = nil, cwd: String? = nil, + contextTokens: Int? = nil, contextWindow: Int? = nil, costUsd24h: Double? = nil) { 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 } /// A summary's detail; nil when it names none of them. @@ -69,9 +79,20 @@ 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 } } 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"])) + 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"])) return detail == AgentChatDetail() ? nil : detail } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/SidebarMapping+Detail.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/SidebarMapping+Detail.swift index 8559bc93960d..a06ae94ab0e9 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/SidebarMapping+Detail.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/SidebarMapping+Detail.swift @@ -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 } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/WorkspaceHoverCardContent.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/WorkspaceHoverCardContent.swift index 247068a0406a..a10ca4f9eb57 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/WorkspaceHoverCardContent.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/WorkspaceHoverCardContent.swift @@ -186,6 +186,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, @@ -214,6 +231,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")) } for presence in workspace.presence.prefix(2) { facts.append(Fact(icon: toolIcon(presence.activity), text: Strings.presenceLine(presence), kind: "presence")) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Model/SidebarWorkspaceDetail.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Model/SidebarWorkspaceDetail.swift index a20db4d2c0dd..efdc6dc4a110 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Model/SidebarWorkspaceDetail.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Model/SidebarWorkspaceDetail.swift @@ -20,14 +20,25 @@ 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? 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 } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Resources/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Resources/Localizable.xcstrings index e0967e563e8e..104c739bff3e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Resources/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Resources/Localizable.xcstrings @@ -1,6 +1,270 @@ { "sourceLanguage": "en", "strings": { + "sidebar.hoverCard.usageContext": { + "comment": "Workspace hover card: the agent chat's usage row (acpmux usageStats)", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ context" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンテキスト %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "سياق %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "%@ konteksta" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "%@ kontekst" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ Kontext" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ de contexto" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ de contexte" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%@ di contesto" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បរិបទ %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "컨텍스트 %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "%@ kontekst" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "%@ kontekstu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%@ de contexto" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Контекст %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "บริบท %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@ bağlam" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Контекст %@" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "%@ ngữ cảnh" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上下文 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "上下文 %@" + } + } + } + }, + "sidebar.hoverCard.usageCost": { + "comment": "Workspace hover card: the agent chat's usage row (acpmux usageStats)", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ in 24h" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "24時間で %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ خلال 24 س" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "%@ za 24 h" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "%@ på 24 t" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ in 24 Std." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ en 24 h" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ en 24 h" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%@ in 24 h" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "%@ ក្នុង 24 ម៉ោង" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "24시간 %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "%@ på 24 t" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "%@ w 24 godz." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%@ em 24 h" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ за 24 ч" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "%@ ใน 24 ชม." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "24 saatte %@" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "%@ за 24 год" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "%@ trong 24 giờ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "24 小时 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "24 小時 %@" + } + } + } + }, "autoArchive.undoTitle": { "extractionState": "manual", "localizations": { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Strings.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Strings.swift index 7dbb2fab8e68..b6b0dde921dc 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Strings.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Strings.swift @@ -181,6 +181,15 @@ enum Strings { } } + /// The usage row's parts: the tokens each call carries ("312K context") and the last 24 + /// hours' cost ("$1,421 in 24h"). + static func usageContext(_ tokens: String) -> String { + String(format: String(localized: "sidebar.hoverCard.usageContext", defaultValue: "%@ context", bundle: .module), tokens) + } + static func usageCost(_ cost: String) -> String { + String(format: String(localized: "sidebar.hoverCard.usageCost", defaultValue: "%@ in 24h", bundle: .module), cost) + } + /// The card's stats line: how many prompts, and how long the agents worked. static func timelineStats(prompts: Int, worked: String?) -> String { let count = String(format: String(localized: "sidebar.hoverCard.statsPrompts", defaultValue: "Prompts: %d", bundle: .module), prompts) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceHoverCardGallery.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceHoverCardGallery.swift index c7338c463653..af9579a4d0cc 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceHoverCardGallery.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceHoverCardGallery.swift @@ -19,7 +19,8 @@ public struct WorkspaceHoverCardGallery { let branch = Fact(icon: .gitBranch, text: "cmux · feat/shared-hover-card", kind: "branch") let reply = Fact(icon: .agentChatList, text: "Done: the tab and session cards share one row now, with the PR status in color.", lines: 2, kind: Fact.lastReplyKind, isPreview: true) - let context = Fact(icon: .contextUsage, text: "Context", kind: "context", accessory: .ring(0.62, tone: .secondary)) + // What the agent chat carries per call and spent in 24 hours (acpmux `usageStats`). + let usage = Fact(icon: .contextUsage, text: "312K context · $1,421 in 24h", kind: "usage", accessory: .ring(0.31, tone: .secondary)) // A pull request as the card's preview: its state in color, the icon in that color too. func pr(_ status: String, _ tone: HoverCardTone, adds: Int, dels: Int, preview: Bool = true) -> Fact { Fact(icon: .gitPullrequest, text: "#22231 Share one hover card row", kind: "pullRequest", status: status, statusTone: tone, @@ -31,7 +32,7 @@ public struct WorkspaceHoverCardGallery { let states: [(String, WorkspaceHoverCardContent, [Double])] = [ ("Agent running", WorkspaceHoverCardContent( title: "Make the hover cards look like Codex's", icon: .agentChat, age: "3m", - facts: [agent, context, branch, pr("Checks running", .attention, adds: 412, dels: 96)], + facts: [agent, usage, branch, pr("Checks running", .attention, adds: 412, dels: 96)], activity: .working, activitySince: now.addingTimeInterval(-192)), [0.4, 0.9, 1.3, 1.8, 2.2, 1.6, 2.4]), ("Agent idle", WorkspaceHoverCardContent( title: "Fix the session hover card", icon: .agentChat, age: "1d", diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WorkspaceHoverCardUsageTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WorkspaceHoverCardUsageTests.swift new file mode 100644 index 000000000000..4f858ff0449e --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WorkspaceHoverCardUsageTests.swift @@ -0,0 +1,56 @@ +import CmuxNextBridge +@testable import CmuxNextDesign +import CmuxNextIcons +@testable import CmuxNextSidebar +import Foundation +import Testing + +/// Leo 2026-10-30: "that visibility into huge-blue guys was (and is?) pretty bad i feel like we +/// can make more improvements to acpmux/cmux-next". Four uncapped deputies carried 290-330k of +/// context per call for hours and nothing in the app showed it. The workspace hover card now names +/// what its agent chat carries per call and spent in the last 24 hours, from acpmux's `usageStats`. +@MainActor @Suite struct WorkspaceHoverCardUsageTests { + @Test func theSummaryCarriesUsageStats() throws { + let summary: [String: Any] = [ + "sessionId": "dep-fleet", "model": "claude-opus-5-5", + "usageStats": ["contextTokens": 312_000, "contextWindow": 1_000_000, "costUsd24h": 1_420.5, + "autocompactCap": NSNull(), "tokens24h": 900_000_000, "updatedAt": 1_761_800_000_000], + ] + let detail = try #require(AgentChatDetail.of(summary: summary)) + #expect(detail.contextTokens == 312_000) + #expect(detail.contextWindow == 1_000_000) + #expect(detail.costUsd24h == 1_420.5) + // Null, negative, boolean or missing numbers are unknown, never zero. + let unread = try #require(AgentChatDetail.of(summary: [ + "model": "m", "usageStats": ["contextTokens": NSNull(), "contextWindow": 0, "costUsd24h": -1, "updatedAt": true], + ])) + #expect(unread.contextTokens == nil && unread.contextWindow == nil && unread.costUsd24h == nil) + #expect(AgentChatDetail.of(summary: ["usageStats": ["costUsd24h": true]]) == nil) + } + + @Test func theCardNamesContextAndTheDaysCostAfterTheAgent() throws { + var workspace = SidebarWorkspace(id: CmuxNextSidebar.WorkspaceID("ws-usage"), machineID: CmuxNextSidebar.MachineID("local"), + title: "Fleet deputy") + workspace.agentName = "Claude" + workspace.detail = SidebarWorkspaceDetail(model: "claude-opus-5-5", contextTokens: 312_000, contextWindow: 1_000_000, + costUsd24h: 1_420.5) + let card = WorkspaceHoverCardContent.make(workspace, machine: nil, now: Date(timeIntervalSince1970: 1_800_000_000)) + let kinds = card.facts.map(\.kind) + let usage = try #require(card.facts.first { $0.kind == "usage" }) + #expect(kinds.firstIndex(of: "usage") == kinds.firstIndex(of: "agent").map { $0 + 1 }) + #expect(usage.text == "312K context · $1,421 in 24h") + #expect(usage.icon == .contextUsage) + #expect(usage.accessory == .ring(0.312, tone: .secondary)) + } + + @Test func aChatWithoutUsageHasNoUsageRowAndACostAloneHasNoRing() { + #expect(WorkspaceHoverCardContent.usageFact(SidebarWorkspaceDetail(model: "m")) == nil) + let cost = WorkspaceHoverCardContent.usageFact(SidebarWorkspaceDetail(costUsd24h: 12.4)) + #expect(cost?.text == "$12.40 in 24h") + #expect(cost?.accessory == nil) + let context = WorkspaceHoverCardContent.usageFact(SidebarWorkspaceDetail(contextTokens: 80_000)) + #expect(context?.text == "80K context") + // Half away from zero, as Session Info's Usage rounds it. + #expect(WorkspaceHoverCardContent.usageFact(SidebarWorkspaceDetail(costUsd24h: 1_420.5))?.text == "$1,421 in 24h") + } +} diff --git a/plans/cmux-next/sidebar-owner.md b/plans/cmux-next/sidebar-owner.md index 19935bc87099..2809f084967e 100644 --- a/plans/cmux-next/sidebar-owner.md +++ b/plans/cmux-next/sidebar-owner.md @@ -71,6 +71,7 @@ Enforced (Oct 23, Lawrence: "ensure this will always be true, and future agents | Group rail shape | Chrome's line on that edge: 2 pt, flat ends, the header bar's own color, a 6 pt cut below the header, down to the last member's bottom. Debug `sidebar.groupLineLayout`: `edge` (default, the row above), `joined` (no cut, the bar's bottom-left corner square: one L, band header only), `inside` (the header reaches the corner inset further left, 4 pt compact and 5 pt comfortable, capped at the list's edge: the line under its corner). Members never move. | Leo 2026-10-23 ("WTF HAPPENED TO FOLLOWING CHROME"; asks ask-5b16be6ee9, ask-40133bf821, ask-7e10ebd553), #24513 | | Row text | As far left as it originally was. A row with no icon starts its title at the row inset; no empty icon slot pushes it right. A top-level group's rows keep the ungrouped rows' left edge. Only nested groups step in. | Lawrence 11405 ("text needs to be as left as possible (how it originally was)"); #23975 | | Home unread | A blue dot, the same dot as other unread rows. No count badge. | Lawrence 11384 | +| Hover card usage | A workspace whose agent chat reports acpmux `usageStats` gets one row after the agent row: the context each call carries and the last 24 hours' cost ("312K context · $1,421 in 24h"), with the context-window ring. No usage, no row. | Leo 2026-10-30 ("that visibility into huge-blue guys was (and is?) pretty bad i feel like we can make more improvements to acpmux/cmux-next") | | Trailing indicators | One center x in every row kind and density (section, status, band and chat history headers' chevrons too, a band header in every look, cards included, through `SidebarRegionLayout.itemTrailingTrim`): Home and band items, selected and unselected workspace rows, pinned, nested and top-level group headers (chevron and resting spinner), members, at compact, comfortable and expanded, Minimal Mode on and off, and listed tab rows (a tab's status, else its unread dot) under workspaces at any depth. The dot or spinner owns the trailing column; a disclosure, a count, the keycap and the hover close and more sit left of it. On hover the mark gives way: a hovered workspace row draws no dot or spinner while its close, more or pin show (at the default end spot the end-most control takes the column), a hovered loading group header shows its chevron and no spinner, and leaving clears the controls at once as the mark returns. | Lawrence 11914 ("the home blue dot isn't lined up with the other trailing dots"), 11959 ("trailing dots and spinners must line up horizontally across sections"); Leo 2026-10-28 ("If you're hovering over a workspace or whatever, just don't show the spinner. Also ... why the fuck is it not aligned? Oh, because it's the inner tab thing") | | Status colors | One color per meaning in every row kind (Home, band items, workspace rows, group headers, nested): blue `systemBlue` unread or new, orange `systemOrange` needs attention (a permission prompt, a question, waiting on you), red `systemRed` an error. Tokens `Palette.statusUnread/statusAttention/statusError`; sidebar indicators opt in with `StatusIndicatorView.usesStatusColors`. A row shows one: red, else orange, else blue, else its spinner. A group header, open or collapsed, rolls up its subtree the same way; a collapsed header with none of those keeps its loading spinner. | Lawrence 11983 ("make sure sidebar has blue, orange/yellow for needs attention, and red for errors"); takes back 10-18's "an error is Ready (blue)" and 10-20's "All we care about is the loading" for headers | | Drag and reorder | The lifted row follows the pointer and a gap opens where it drops. No stray blue line during the drag, and nothing stays after a drop or a cancel. | Lawrence 11405 | @@ -630,3 +631,7 @@ Workspace previews anchor on the drawn row fill, including pinned workspace pill ## A hover control stays lit while its menu is open, and lights alone (2026-10-29) - Leo 2026-10-26: "If it's a toggle, if it's something like that, then you keep it on when you click the context menu, when you click the top, the drop-down, the popover, whatever the fucking. We say we keep it." Leo 2026-10-27: "why are you giving it the entire row when it's only this small little section? Just don't do that. Don't ever do that." Before (sw-sidebar audit): a workspace row's ⋮, ✕ and chevron followed `isHovered` alone, so they vanished as the pointer moved onto the row's open ⋮ menu; a group header's ⋮ was not lit while the group editor it opened was up, and the editor painted the hover step over the whole bar; a workspace tile's menu used the profile control's `showsMenu`, the selected fill over the whole pill. Now `OverflowButton.showsMenu` holds the button pressed (lit) while its menu or editor is open: `SidebarRowMenu` sets `WorkspaceRowView.showsMenu` for the synchronous `popUp` (the hover controls hold, the row's fill does not change), a workspace tile's more button holds its controls and lights alone (the pill keeps the selected step for the profile control only, whose pill is the control), and a group header's open editor lights its ⋮ instead of the bar. No position, size or slot changes. Test: `SidebarMenuHeldControlsTests`. + +## Hover card names each agent chat's usage (2026-10-30) + +- Leo, verbatim: "that visibility into huge-blue guys was (and is?) pretty bad i feel like we can make more improvements to acpmux/cmux-next". The workspace hover card adds one row after the agent row from acpmux's `usageStats` (`AgentChatDetail.contextTokens`, `contextWindow`, `costUsd24h`): "312K context · $1,421 in 24h" with the context-window ring (`HoverCardAccessory.ring`). A chat without usage shows no row; no other row moves. Test: `WorkspaceHoverCardUsageTests`. diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 049cf4326bc6..ac21eea95003 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -6748,6 +6748,19 @@ function AcpmuxPane() { workflows.length > 0 ? { list: workflows, onStop: stopSubagent, onOpenAgent: showWorkflowAgent } : undefined, [workflows, stopSubagent, showWorkflowAgent], ); + // Session Info's Usage offers Compact for the chat's context, with the reach a context-full + // failure's Compact has: never a View Only chat or a read-only pane, never the next chat. + const usageCompactOffered = offersCompact(snapshot.commands); + const usageCompact = useMemo( + () => + restartChat && usageCompactOffered + ? () => { + if (restartReaches(restartChat, sessionIdRef.current, directClient.current?.selectedSession)) + void compactThrough(callNative)(); + } + : undefined, + [restartChat, usageCompactOffered], + ); const inspector = useChatInspector({ snapshot, machineName, @@ -6778,6 +6791,9 @@ function AcpmuxPane() { onSaveEnvironment: undefined, // A failed MCP server's Reconnect in Connectors, where acpmux offers it (a Claude Code chat's). onReconnectMcp: snapshot.reconnectsMcp ? reconnectMcp : undefined, + // A Usage row opens that chat, this Mac's or a peer's, beside this one or in the tab showing it. + onOpenChat: openSenderChat, + onCompact: quick ? undefined : usageCompact, // Quick Chat's panel has no room beside it for the side panel. onOpenDocument: quick ? undefined : openArtifact, onShowCall: quick ? undefined : showCall, diff --git a/webviews/src/agent-session/acpmux/Localizable.xcstrings b/webviews/src/agent-session/acpmux/Localizable.xcstrings index e6f3e8dc1ce1..5a987b9f6c6b 100644 --- a/webviews/src/agent-session/acpmux/Localizable.xcstrings +++ b/webviews/src/agent-session/acpmux/Localizable.xcstrings @@ -1,6 +1,25 @@ { "sourceLanguage": "en", "strings": { + "chatInspector.usage.showSubagents": {"comment":"Agent pane: Session Info's Usage","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Show"}},"ar":{"stringUnit":{"state":"translated","value":"إظهار"}},"bs":{"stringUnit":{"state":"translated","value":"Prikaži"}},"da":{"stringUnit":{"state":"translated","value":"Vis"}},"de":{"stringUnit":{"state":"translated","value":"Anzeigen"}},"es":{"stringUnit":{"state":"translated","value":"Mostrar"}},"fr":{"stringUnit":{"state":"translated","value":"Afficher"}},"it":{"stringUnit":{"state":"translated","value":"Mostra"}},"ja":{"stringUnit":{"state":"translated","value":"表示"}},"km":{"stringUnit":{"state":"translated","value":"បង្ហាញ"}},"ko":{"stringUnit":{"state":"translated","value":"보기"}},"nb":{"stringUnit":{"state":"translated","value":"Vis"}},"pl":{"stringUnit":{"state":"translated","value":"Pokaż"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Mostrar"}},"ru":{"stringUnit":{"state":"translated","value":"Показать"}},"th":{"stringUnit":{"state":"translated","value":"แสดง"}},"tr":{"stringUnit":{"state":"translated","value":"Göster"}},"uk":{"stringUnit":{"state":"translated","value":"Показати"}},"vi":{"stringUnit":{"state":"translated","value":"Hiện"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"显示"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"顯示"}}}}, + "chatInspector.usage.restartWithCap": {"comment":"Agent pane: Session Info's Usage","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Restart with cap"}},"ar":{"stringUnit":{"state":"translated","value":"إعادة التشغيل مع حد"}},"bs":{"stringUnit":{"state":"translated","value":"Ponovo pokreni s ograničenjem"}},"da":{"stringUnit":{"state":"translated","value":"Genstart med grænse"}},"de":{"stringUnit":{"state":"translated","value":"Mit Grenze neu starten"}},"es":{"stringUnit":{"state":"translated","value":"Reiniciar con límite"}},"fr":{"stringUnit":{"state":"translated","value":"Redémarrer avec limite"}},"it":{"stringUnit":{"state":"translated","value":"Riavvia con limite"}},"ja":{"stringUnit":{"state":"translated","value":"上限付きで再起動"}},"km":{"stringUnit":{"state":"translated","value":"ចាប់ផ្ដើមឡើងវិញដោយមានកំណត់"}},"ko":{"stringUnit":{"state":"translated","value":"제한을 두고 다시 시작"}},"nb":{"stringUnit":{"state":"translated","value":"Start på nytt med grense"}},"pl":{"stringUnit":{"state":"translated","value":"Uruchom ponownie z limitem"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Reiniciar com limite"}},"ru":{"stringUnit":{"state":"translated","value":"Перезапустить с ограничением"}},"th":{"stringUnit":{"state":"translated","value":"เริ่มใหม่พร้อมขีดจำกัด"}},"tr":{"stringUnit":{"state":"translated","value":"Sınırla yeniden başlat"}},"uk":{"stringUnit":{"state":"translated","value":"Перезапустити з обмеженням"}},"vi":{"stringUnit":{"state":"translated","value":"Khởi động lại có giới hạn"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"设上限后重启"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"設上限後重新啟動"}}}}, + "chatInspector.usage.restartWithCapSoon": {"comment":"Agent pane: Session Info's Usage","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"acpmux cannot restart a session with an autocompact cap yet"}},"ar":{"stringUnit":{"state":"translated","value":"لا يستطيع acpmux بعد إعادة تشغيل جلسة مع حد للضغط التلقائي"}},"bs":{"stringUnit":{"state":"translated","value":"acpmux još ne može ponovo pokrenuti sesiju s ograničenjem automatskog sažimanja"}},"da":{"stringUnit":{"state":"translated","value":"acpmux kan endnu ikke genstarte en session med en grænse for automatisk komprimering"}},"de":{"stringUnit":{"state":"translated","value":"acpmux kann eine Sitzung noch nicht mit einer Grenze für automatisches Komprimieren neu starten"}},"es":{"stringUnit":{"state":"translated","value":"acpmux aún no puede reiniciar una sesión con un límite de compactación automática"}},"fr":{"stringUnit":{"state":"translated","value":"acpmux ne peut pas encore redémarrer une session avec une limite de compaction auto"}},"it":{"stringUnit":{"state":"translated","value":"acpmux non può ancora riavviare una sessione con un limite di compattazione automatica"}},"ja":{"stringUnit":{"state":"translated","value":"acpmux はまだ自動圧縮の上限付きでセッションを再起動できません"}},"km":{"stringUnit":{"state":"translated","value":"acpmux មិនទាន់អាចចាប់ផ្ដើមវគ្គឡើងវិញដោយមានកំណត់បង្រួមស្វ័យប្រវត្តិទេ"}},"ko":{"stringUnit":{"state":"translated","value":"acpmux는 아직 자동 압축 제한을 두고 세션을 다시 시작할 수 없습니다"}},"nb":{"stringUnit":{"state":"translated","value":"acpmux kan ennå ikke starte en økt på nytt med en grense for automatisk komprimering"}},"pl":{"stringUnit":{"state":"translated","value":"acpmux nie może jeszcze uruchomić sesji ponownie z limitem automatycznego kompaktowania"}},"pt-BR":{"stringUnit":{"state":"translated","value":"O acpmux ainda não consegue reiniciar uma sessão com limite de compactação automática"}},"ru":{"stringUnit":{"state":"translated","value":"acpmux пока не умеет перезапускать сеанс с ограничением автосжатия"}},"th":{"stringUnit":{"state":"translated","value":"acpmux ยังเริ่มเซสชันใหม่พร้อมขีดจำกัดการบีบอัดอัตโนมัติไม่ได้"}},"tr":{"stringUnit":{"state":"translated","value":"acpmux bir oturumu henüz otomatik sıkıştırma sınırıyla yeniden başlatamıyor"}},"uk":{"stringUnit":{"state":"translated","value":"acpmux поки не вміє перезапускати сеанс з обмеженням автостиснення"}},"vi":{"stringUnit":{"state":"translated","value":"acpmux chưa thể khởi động lại phiên với giới hạn tự động nén"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"acpmux 暂时无法以自动压缩上限重启会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"acpmux 暫時無法以自動壓縮上限重新啟動工作階段"}}}}, + "chatInspector.usage": {"comment":"Agent pane: Session Info's Usage","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Usage"}},"ar":{"stringUnit":{"state":"translated","value":"الاستخدام"}},"bs":{"stringUnit":{"state":"translated","value":"Potrošnja"}},"da":{"stringUnit":{"state":"translated","value":"Forbrug"}},"de":{"stringUnit":{"state":"translated","value":"Nutzung"}},"es":{"stringUnit":{"state":"translated","value":"Uso"}},"fr":{"stringUnit":{"state":"translated","value":"Utilisation"}},"it":{"stringUnit":{"state":"translated","value":"Utilizzo"}},"ja":{"stringUnit":{"state":"translated","value":"使用量"}},"km":{"stringUnit":{"state":"translated","value":"ការប្រើប្រាស់"}},"ko":{"stringUnit":{"state":"translated","value":"사용량"}},"nb":{"stringUnit":{"state":"translated","value":"Forbruk"}},"pl":{"stringUnit":{"state":"translated","value":"Użycie"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Uso"}},"ru":{"stringUnit":{"state":"translated","value":"Расход"}},"th":{"stringUnit":{"state":"translated","value":"การใช้งาน"}},"tr":{"stringUnit":{"state":"translated","value":"Kullanım"}},"uk":{"stringUnit":{"state":"translated","value":"Витрати"}},"vi":{"stringUnit":{"state":"translated","value":"Mức dùng"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"用量"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"用量"}}}}, + "chatInspector.usage.cost24hValue": {"comment":"Agent pane: Session Info's Usage","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"{cost} · 24h"}},"ar":{"stringUnit":{"state":"translated","value":"{cost} · 24 س"}},"bs":{"stringUnit":{"state":"translated","value":"{cost} · 24 h"}},"da":{"stringUnit":{"state":"translated","value":"{cost} · 24 t"}},"de":{"stringUnit":{"state":"translated","value":"{cost} · 24 Std."}},"es":{"stringUnit":{"state":"translated","value":"{cost} · 24 h"}},"fr":{"stringUnit":{"state":"translated","value":"{cost} · 24 h"}},"it":{"stringUnit":{"state":"translated","value":"{cost} · 24 h"}},"ja":{"stringUnit":{"state":"translated","value":"{cost} · 24時間"}},"km":{"stringUnit":{"state":"translated","value":"{cost} · 24 ម៉ោង"}},"ko":{"stringUnit":{"state":"translated","value":"{cost} · 24시간"}},"nb":{"stringUnit":{"state":"translated","value":"{cost} · 24 t"}},"pl":{"stringUnit":{"state":"translated","value":"{cost} · 24 godz."}},"pt-BR":{"stringUnit":{"state":"translated","value":"{cost} · 24 h"}},"ru":{"stringUnit":{"state":"translated","value":"{cost} · 24 ч"}},"th":{"stringUnit":{"state":"translated","value":"{cost} · 24 ชม."}},"tr":{"stringUnit":{"state":"translated","value":"{cost} · 24 sa"}},"uk":{"stringUnit":{"state":"translated","value":"{cost} · 24 год"}},"vi":{"stringUnit":{"state":"translated","value":"{cost} · 24 giờ"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"{cost} · 24 小时"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"{cost} · 24 小時"}}}}, + "chatInspector.usage.tokens24hValue": {"comment":"Agent pane: Session Info's Usage","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"{tokens} tokens · 24h"}},"ar":{"stringUnit":{"state":"translated","value":"{tokens} رمز · 24 س"}},"bs":{"stringUnit":{"state":"translated","value":"{tokens} tokena · 24 h"}},"da":{"stringUnit":{"state":"translated","value":"{tokens} tokens · 24 t"}},"de":{"stringUnit":{"state":"translated","value":"{tokens} Tokens · 24 Std."}},"es":{"stringUnit":{"state":"translated","value":"{tokens} tokens · 24 h"}},"fr":{"stringUnit":{"state":"translated","value":"{tokens} jetons · 24 h"}},"it":{"stringUnit":{"state":"translated","value":"{tokens} token · 24 h"}},"ja":{"stringUnit":{"state":"translated","value":"{tokens} トークン · 24時間"}},"km":{"stringUnit":{"state":"translated","value":"{tokens} តូខឹន · 24 ម៉ោង"}},"ko":{"stringUnit":{"state":"translated","value":"{tokens} 토큰 · 24시간"}},"nb":{"stringUnit":{"state":"translated","value":"{tokens} tokens · 24 t"}},"pl":{"stringUnit":{"state":"translated","value":"{tokens} tokenów · 24 godz."}},"pt-BR":{"stringUnit":{"state":"translated","value":"{tokens} tokens · 24 h"}},"ru":{"stringUnit":{"state":"translated","value":"{tokens} токенов · 24 ч"}},"th":{"stringUnit":{"state":"translated","value":"{tokens} โทเค็น · 24 ชม."}},"tr":{"stringUnit":{"state":"translated","value":"{tokens} token · 24 sa"}},"uk":{"stringUnit":{"state":"translated","value":"{tokens} токенів · 24 год"}},"vi":{"stringUnit":{"state":"translated","value":"{tokens} token · 24 giờ"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"{tokens} 个 token · 24 小时"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"{tokens} 個 token · 24 小時"}}}}, + "chatInspector.usage.cost24h": {"comment":"Agent pane: Session Info's Usage","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Cost, last 24h"}},"ar":{"stringUnit":{"state":"translated","value":"التكلفة، آخر 24 ساعة"}},"bs":{"stringUnit":{"state":"translated","value":"Trošak, zadnja 24 h"}},"da":{"stringUnit":{"state":"translated","value":"Pris, seneste 24 t"}},"de":{"stringUnit":{"state":"translated","value":"Kosten, letzte 24 Std."}},"es":{"stringUnit":{"state":"translated","value":"Coste, últimas 24 h"}},"fr":{"stringUnit":{"state":"translated","value":"Coût, dernières 24 h"}},"it":{"stringUnit":{"state":"translated","value":"Costo, ultime 24 h"}},"ja":{"stringUnit":{"state":"translated","value":"費用(過去24時間)"}},"km":{"stringUnit":{"state":"translated","value":"ថ្លៃ ២៤ ម៉ោងចុងក្រោយ"}},"ko":{"stringUnit":{"state":"translated","value":"비용(최근 24시간)"}},"nb":{"stringUnit":{"state":"translated","value":"Kostnad, siste 24 t"}},"pl":{"stringUnit":{"state":"translated","value":"Koszt, ostatnie 24 godz."}},"pt-BR":{"stringUnit":{"state":"translated","value":"Custo, últimas 24 h"}},"ru":{"stringUnit":{"state":"translated","value":"Стоимость за 24 ч"}},"th":{"stringUnit":{"state":"translated","value":"ค่าใช้จ่าย 24 ชม. ล่าสุด"}},"tr":{"stringUnit":{"state":"translated","value":"Maliyet, son 24 sa"}},"uk":{"stringUnit":{"state":"translated","value":"Вартість за 24 год"}},"vi":{"stringUnit":{"state":"translated","value":"Chi phí 24 giờ qua"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"费用(最近 24 小时)"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"費用(最近 24 小時)"}}}}, + "chatInspector.usage.tokens24h": {"comment":"Agent pane: Session Info's Usage","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Tokens, last 24h"}},"ar":{"stringUnit":{"state":"translated","value":"الرموز، آخر 24 ساعة"}},"bs":{"stringUnit":{"state":"translated","value":"Tokeni, zadnja 24 h"}},"da":{"stringUnit":{"state":"translated","value":"Tokens, seneste 24 t"}},"de":{"stringUnit":{"state":"translated","value":"Tokens, letzte 24 Std."}},"es":{"stringUnit":{"state":"translated","value":"Tokens, últimas 24 h"}},"fr":{"stringUnit":{"state":"translated","value":"Jetons, dernières 24 h"}},"it":{"stringUnit":{"state":"translated","value":"Token, ultime 24 h"}},"ja":{"stringUnit":{"state":"translated","value":"トークン(過去24時間)"}},"km":{"stringUnit":{"state":"translated","value":"តូខឹន ២៤ ម៉ោងចុងក្រោយ"}},"ko":{"stringUnit":{"state":"translated","value":"토큰(최근 24시간)"}},"nb":{"stringUnit":{"state":"translated","value":"Tokens, siste 24 t"}},"pl":{"stringUnit":{"state":"translated","value":"Tokeny, ostatnie 24 godz."}},"pt-BR":{"stringUnit":{"state":"translated","value":"Tokens, últimas 24 h"}},"ru":{"stringUnit":{"state":"translated","value":"Токены за 24 ч"}},"th":{"stringUnit":{"state":"translated","value":"โทเค็น 24 ชม. ล่าสุด"}},"tr":{"stringUnit":{"state":"translated","value":"Token, son 24 sa"}},"uk":{"stringUnit":{"state":"translated","value":"Токени за 24 год"}},"vi":{"stringUnit":{"state":"translated","value":"Token 24 giờ qua"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Token(最近 24 小时)"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Token(最近 24 小時)"}}}}, + "chatInspector.usage.perCall": {"comment":"Agent pane: Session Info's Usage","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Context per call"}},"ar":{"stringUnit":{"state":"translated","value":"السياق لكل استدعاء"}},"bs":{"stringUnit":{"state":"translated","value":"Kontekst po pozivu"}},"da":{"stringUnit":{"state":"translated","value":"Kontekst pr. kald"}},"de":{"stringUnit":{"state":"translated","value":"Kontext pro Aufruf"}},"es":{"stringUnit":{"state":"translated","value":"Contexto por llamada"}},"fr":{"stringUnit":{"state":"translated","value":"Contexte par appel"}},"it":{"stringUnit":{"state":"translated","value":"Contesto per chiamata"}},"ja":{"stringUnit":{"state":"translated","value":"1回あたりのコンテキスト"}},"km":{"stringUnit":{"state":"translated","value":"បរិបទក្នុងមួយការហៅ"}},"ko":{"stringUnit":{"state":"translated","value":"호출당 컨텍스트"}},"nb":{"stringUnit":{"state":"translated","value":"Kontekst per kall"}},"pl":{"stringUnit":{"state":"translated","value":"Kontekst na wywołanie"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Contexto por chamada"}},"ru":{"stringUnit":{"state":"translated","value":"Контекст на вызов"}},"th":{"stringUnit":{"state":"translated","value":"บริบทต่อการเรียก"}},"tr":{"stringUnit":{"state":"translated","value":"Çağrı başına bağlam"}},"uk":{"stringUnit":{"state":"translated","value":"Контекст на виклик"}},"vi":{"stringUnit":{"state":"translated","value":"Ngữ cảnh mỗi lần gọi"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"每次调用的上下文"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"每次呼叫的上下文"}}}}, + "chatInspector.usage.autocompact": {"comment":"Agent pane: Session Info's Usage","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Autocompact"}},"ar":{"stringUnit":{"state":"translated","value":"الضغط التلقائي"}},"bs":{"stringUnit":{"state":"translated","value":"Automatsko sažimanje"}},"da":{"stringUnit":{"state":"translated","value":"Automatisk komprimering"}},"de":{"stringUnit":{"state":"translated","value":"Automatisches Komprimieren"}},"es":{"stringUnit":{"state":"translated","value":"Compactación automática"}},"fr":{"stringUnit":{"state":"translated","value":"Compaction auto"}},"it":{"stringUnit":{"state":"translated","value":"Compattazione automatica"}},"ja":{"stringUnit":{"state":"translated","value":"自動圧縮"}},"km":{"stringUnit":{"state":"translated","value":"បង្រួមស្វ័យប្រវត្តិ"}},"ko":{"stringUnit":{"state":"translated","value":"자동 압축"}},"nb":{"stringUnit":{"state":"translated","value":"Automatisk komprimering"}},"pl":{"stringUnit":{"state":"translated","value":"Automatyczne kompaktowanie"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Compactação automática"}},"ru":{"stringUnit":{"state":"translated","value":"Автосжатие"}},"th":{"stringUnit":{"state":"translated","value":"บีบอัดอัตโนมัติ"}},"tr":{"stringUnit":{"state":"translated","value":"Otomatik sıkıştırma"}},"uk":{"stringUnit":{"state":"translated","value":"Автостиснення"}},"vi":{"stringUnit":{"state":"translated","value":"Tự động nén"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"自动压缩"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"自動壓縮"}}}}, + "chatInspector.usage.noCap": {"comment":"Agent pane: Session Info's Usage","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"No cap"}},"ar":{"stringUnit":{"state":"translated","value":"بلا حد"}},"bs":{"stringUnit":{"state":"translated","value":"Bez ograničenja"}},"da":{"stringUnit":{"state":"translated","value":"Ingen grænse"}},"de":{"stringUnit":{"state":"translated","value":"Keine Grenze"}},"es":{"stringUnit":{"state":"translated","value":"Sin límite"}},"fr":{"stringUnit":{"state":"translated","value":"Sans limite"}},"it":{"stringUnit":{"state":"translated","value":"Nessun limite"}},"ja":{"stringUnit":{"state":"translated","value":"上限なし"}},"km":{"stringUnit":{"state":"translated","value":"គ្មានកំណត់"}},"ko":{"stringUnit":{"state":"translated","value":"제한 없음"}},"nb":{"stringUnit":{"state":"translated","value":"Ingen grense"}},"pl":{"stringUnit":{"state":"translated","value":"Bez limitu"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Sem limite"}},"ru":{"stringUnit":{"state":"translated","value":"Без ограничения"}},"th":{"stringUnit":{"state":"translated","value":"ไม่มีขีดจำกัด"}},"tr":{"stringUnit":{"state":"translated","value":"Sınır yok"}},"uk":{"stringUnit":{"state":"translated","value":"Без обмеження"}},"vi":{"stringUnit":{"state":"translated","value":"Không giới hạn"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"无上限"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"無上限"}}}}, + "chatInspector.usage.compactions24h": {"comment":"Agent pane: Session Info's Usage","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Compactions, last 24h"}},"ar":{"stringUnit":{"state":"translated","value":"عمليات الضغط، آخر 24 ساعة"}},"bs":{"stringUnit":{"state":"translated","value":"Sažimanja, zadnja 24 h"}},"da":{"stringUnit":{"state":"translated","value":"Komprimeringer, seneste 24 t"}},"de":{"stringUnit":{"state":"translated","value":"Komprimierungen, letzte 24 Std."}},"es":{"stringUnit":{"state":"translated","value":"Compactaciones, últimas 24 h"}},"fr":{"stringUnit":{"state":"translated","value":"Compactions, dernières 24 h"}},"it":{"stringUnit":{"state":"translated","value":"Compattazioni, ultime 24 h"}},"ja":{"stringUnit":{"state":"translated","value":"圧縮回数(過去24時間)"}},"km":{"stringUnit":{"state":"translated","value":"ការបង្រួម ២៤ ម៉ោងចុងក្រោយ"}},"ko":{"stringUnit":{"state":"translated","value":"압축(최근 24시간)"}},"nb":{"stringUnit":{"state":"translated","value":"Komprimeringer, siste 24 t"}},"pl":{"stringUnit":{"state":"translated","value":"Kompaktowania, ostatnie 24 godz."}},"pt-BR":{"stringUnit":{"state":"translated","value":"Compactações, últimas 24 h"}},"ru":{"stringUnit":{"state":"translated","value":"Сжатия за 24 ч"}},"th":{"stringUnit":{"state":"translated","value":"การบีบอัด 24 ชม. ล่าสุด"}},"tr":{"stringUnit":{"state":"translated","value":"Sıkıştırmalar, son 24 sa"}},"uk":{"stringUnit":{"state":"translated","value":"Стиснення за 24 год"}},"vi":{"stringUnit":{"state":"translated","value":"Số lần nén 24 giờ qua"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"压缩次数(最近 24 小时)"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"壓縮次數(最近 24 小時)"}}}}, + "chatInspector.usage.subagents": {"comment":"Agent pane: Session Info's Usage","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Subagents"}},"ar":{"stringUnit":{"state":"translated","value":"الوكلاء الفرعيون"}},"bs":{"stringUnit":{"state":"translated","value":"Podagenti"}},"da":{"stringUnit":{"state":"translated","value":"Underagenter"}},"de":{"stringUnit":{"state":"translated","value":"Subagenten"}},"es":{"stringUnit":{"state":"translated","value":"Subagentes"}},"fr":{"stringUnit":{"state":"translated","value":"Sous-agents"}},"it":{"stringUnit":{"state":"translated","value":"Subagenti"}},"ja":{"stringUnit":{"state":"translated","value":"サブエージェント"}},"km":{"stringUnit":{"state":"translated","value":"ភ្នាក់ងាររង"}},"ko":{"stringUnit":{"state":"translated","value":"하위 에이전트"}},"nb":{"stringUnit":{"state":"translated","value":"Underagenter"}},"pl":{"stringUnit":{"state":"translated","value":"Podagenci"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Subagentes"}},"ru":{"stringUnit":{"state":"translated","value":"Субагенты"}},"th":{"stringUnit":{"state":"translated","value":"เอเจนต์ย่อย"}},"tr":{"stringUnit":{"state":"translated","value":"Alt ajanlar"}},"uk":{"stringUnit":{"state":"translated","value":"Субагенти"}},"vi":{"stringUnit":{"state":"translated","value":"Tác tử con"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"子代理"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"子代理"}}}}, + "chatInspector.usage.subagentsValue": {"comment":"Agent pane: Session Info's Usage","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"{live} live · {hour} in 1h · {day} in 24h"}},"ar":{"stringUnit":{"state":"translated","value":"{live} نشط · {hour} في ساعة · {day} في 24 س"}},"bs":{"stringUnit":{"state":"translated","value":"{live} aktivnih · {hour} za 1 h · {day} za 24 h"}},"da":{"stringUnit":{"state":"translated","value":"{live} aktive · {hour} på 1 t · {day} på 24 t"}},"de":{"stringUnit":{"state":"translated","value":"{live} aktiv · {hour} in 1 Std. · {day} in 24 Std."}},"es":{"stringUnit":{"state":"translated","value":"{live} activos · {hour} en 1 h · {day} en 24 h"}},"fr":{"stringUnit":{"state":"translated","value":"{live} actifs · {hour} en 1 h · {day} en 24 h"}},"it":{"stringUnit":{"state":"translated","value":"{live} attivi · {hour} in 1 h · {day} in 24 h"}},"ja":{"stringUnit":{"state":"translated","value":"稼働中 {live} · 1時間で {hour} · 24時間で {day}"}},"km":{"stringUnit":{"state":"translated","value":"{live} កំពុងដំណើរការ · {hour} ក្នុង 1 ម៉ោង · {day} ក្នុង 24 ម៉ោង"}},"ko":{"stringUnit":{"state":"translated","value":"실행 중 {live} · 1시간 {hour} · 24시간 {day}"}},"nb":{"stringUnit":{"state":"translated","value":"{live} aktive · {hour} på 1 t · {day} på 24 t"}},"pl":{"stringUnit":{"state":"translated","value":"{live} aktywnych · {hour} w 1 godz. · {day} w 24 godz."}},"pt-BR":{"stringUnit":{"state":"translated","value":"{live} ativos · {hour} em 1 h · {day} em 24 h"}},"ru":{"stringUnit":{"state":"translated","value":"{live} активно · {hour} за 1 ч · {day} за 24 ч"}},"th":{"stringUnit":{"state":"translated","value":"ทำงานอยู่ {live} · {hour} ใน 1 ชม. · {day} ใน 24 ชม."}},"tr":{"stringUnit":{"state":"translated","value":"{live} etkin · 1 sa içinde {hour} · 24 sa içinde {day}"}},"uk":{"stringUnit":{"state":"translated","value":"{live} активних · {hour} за 1 год · {day} за 24 год"}},"vi":{"stringUnit":{"state":"translated","value":"{live} đang chạy · {hour} trong 1 giờ · {day} trong 24 giờ"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"运行中 {live} · 1 小时 {hour} · 24 小时 {day}"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"執行中 {live} · 1 小時 {hour} · 24 小時 {day}"}}}}, + "chatInspector.usage.launcher": {"comment":"Agent pane: Session Info's Usage","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Launcher"}},"ar":{"stringUnit":{"state":"translated","value":"المُشغِّل"}},"bs":{"stringUnit":{"state":"translated","value":"Pokretač"}},"da":{"stringUnit":{"state":"translated","value":"Starter"}},"de":{"stringUnit":{"state":"translated","value":"Starter"}},"es":{"stringUnit":{"state":"translated","value":"Lanzador"}},"fr":{"stringUnit":{"state":"translated","value":"Lanceur"}},"it":{"stringUnit":{"state":"translated","value":"Avvio"}},"ja":{"stringUnit":{"state":"translated","value":"起動コマンド"}},"km":{"stringUnit":{"state":"translated","value":"កម្មវិធីចាប់ផ្ដើម"}},"ko":{"stringUnit":{"state":"translated","value":"실행기"}},"nb":{"stringUnit":{"state":"translated","value":"Starter"}},"pl":{"stringUnit":{"state":"translated","value":"Program startowy"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Inicializador"}},"ru":{"stringUnit":{"state":"translated","value":"Запуск"}},"th":{"stringUnit":{"state":"translated","value":"ตัวเรียกใช้"}},"tr":{"stringUnit":{"state":"translated","value":"Başlatıcı"}},"uk":{"stringUnit":{"state":"translated","value":"Запуск"}},"vi":{"stringUnit":{"state":"translated","value":"Trình khởi chạy"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"启动器"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"啟動器"}}}}, + "chatInspector.usage.allSessions": {"comment":"Agent pane: Session Info's Usage","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"All sessions"}},"ar":{"stringUnit":{"state":"translated","value":"كل الجلسات"}},"bs":{"stringUnit":{"state":"translated","value":"Sve sesije"}},"da":{"stringUnit":{"state":"translated","value":"Alle sessioner"}},"de":{"stringUnit":{"state":"translated","value":"Alle Sitzungen"}},"es":{"stringUnit":{"state":"translated","value":"Todas las sesiones"}},"fr":{"stringUnit":{"state":"translated","value":"Toutes les sessions"}},"it":{"stringUnit":{"state":"translated","value":"Tutte le sessioni"}},"ja":{"stringUnit":{"state":"translated","value":"すべてのセッション"}},"km":{"stringUnit":{"state":"translated","value":"វគ្គទាំងអស់"}},"ko":{"stringUnit":{"state":"translated","value":"모든 세션"}},"nb":{"stringUnit":{"state":"translated","value":"Alle økter"}},"pl":{"stringUnit":{"state":"translated","value":"Wszystkie sesje"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Todas as sessões"}},"ru":{"stringUnit":{"state":"translated","value":"Все сеансы"}},"th":{"stringUnit":{"state":"translated","value":"เซสชันทั้งหมด"}},"tr":{"stringUnit":{"state":"translated","value":"Tüm oturumlar"}},"uk":{"stringUnit":{"state":"translated","value":"Усі сеанси"}},"vi":{"stringUnit":{"state":"translated","value":"Tất cả phiên"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"所有会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"所有工作階段"}}}}, + "chatInspector.usage.sortBy": {"comment":"Agent pane: Session Info's Usage","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Sort sessions by"}},"ar":{"stringUnit":{"state":"translated","value":"فرز الجلسات حسب"}},"bs":{"stringUnit":{"state":"translated","value":"Sortiraj sesije po"}},"da":{"stringUnit":{"state":"translated","value":"Sortér sessioner efter"}},"de":{"stringUnit":{"state":"translated","value":"Sitzungen sortieren nach"}},"es":{"stringUnit":{"state":"translated","value":"Ordenar sesiones por"}},"fr":{"stringUnit":{"state":"translated","value":"Trier les sessions par"}},"it":{"stringUnit":{"state":"translated","value":"Ordina sessioni per"}},"ja":{"stringUnit":{"state":"translated","value":"セッションの並べ替え"}},"km":{"stringUnit":{"state":"translated","value":"តម្រៀបវគ្គតាម"}},"ko":{"stringUnit":{"state":"translated","value":"세션 정렬 기준"}},"nb":{"stringUnit":{"state":"translated","value":"Sorter økter etter"}},"pl":{"stringUnit":{"state":"translated","value":"Sortuj sesje według"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Ordenar sessões por"}},"ru":{"stringUnit":{"state":"translated","value":"Сортировать сеансы по"}},"th":{"stringUnit":{"state":"translated","value":"เรียงเซสชันตาม"}},"tr":{"stringUnit":{"state":"translated","value":"Oturumları sırala"}},"uk":{"stringUnit":{"state":"translated","value":"Сортувати сеанси за"}},"vi":{"stringUnit":{"state":"translated","value":"Sắp xếp phiên theo"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"会话排序方式"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"工作階段排序方式"}}}}, + "chatInspector.usage.sortCost": {"comment":"Agent pane: Session Info's Usage","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"24h cost"}},"ar":{"stringUnit":{"state":"translated","value":"تكلفة 24 س"}},"bs":{"stringUnit":{"state":"translated","value":"Trošak 24 h"}},"da":{"stringUnit":{"state":"translated","value":"Pris 24 t"}},"de":{"stringUnit":{"state":"translated","value":"Kosten 24 Std."}},"es":{"stringUnit":{"state":"translated","value":"Coste 24 h"}},"fr":{"stringUnit":{"state":"translated","value":"Coût 24 h"}},"it":{"stringUnit":{"state":"translated","value":"Costo 24 h"}},"ja":{"stringUnit":{"state":"translated","value":"24時間の費用"}},"km":{"stringUnit":{"state":"translated","value":"ថ្លៃ 24 ម៉ោង"}},"ko":{"stringUnit":{"state":"translated","value":"24시간 비용"}},"nb":{"stringUnit":{"state":"translated","value":"Kostnad 24 t"}},"pl":{"stringUnit":{"state":"translated","value":"Koszt 24 godz."}},"pt-BR":{"stringUnit":{"state":"translated","value":"Custo 24 h"}},"ru":{"stringUnit":{"state":"translated","value":"Стоимость 24 ч"}},"th":{"stringUnit":{"state":"translated","value":"ค่าใช้จ่าย 24 ชม."}},"tr":{"stringUnit":{"state":"translated","value":"24 sa maliyet"}},"uk":{"stringUnit":{"state":"translated","value":"Вартість 24 год"}},"vi":{"stringUnit":{"state":"translated","value":"Chi phí 24 giờ"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"24 小时费用"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"24 小時費用"}}}}, + "chatInspector.usage.sortContext": {"comment":"Agent pane: Session Info's Usage","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Context"}},"ar":{"stringUnit":{"state":"translated","value":"السياق"}},"bs":{"stringUnit":{"state":"translated","value":"Kontekst"}},"da":{"stringUnit":{"state":"translated","value":"Kontekst"}},"de":{"stringUnit":{"state":"translated","value":"Kontext"}},"es":{"stringUnit":{"state":"translated","value":"Contexto"}},"fr":{"stringUnit":{"state":"translated","value":"Contexte"}},"it":{"stringUnit":{"state":"translated","value":"Contesto"}},"ja":{"stringUnit":{"state":"translated","value":"コンテキスト"}},"km":{"stringUnit":{"state":"translated","value":"បរិបទ"}},"ko":{"stringUnit":{"state":"translated","value":"컨텍스트"}},"nb":{"stringUnit":{"state":"translated","value":"Kontekst"}},"pl":{"stringUnit":{"state":"translated","value":"Kontekst"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Contexto"}},"ru":{"stringUnit":{"state":"translated","value":"Контекст"}},"th":{"stringUnit":{"state":"translated","value":"บริบท"}},"tr":{"stringUnit":{"state":"translated","value":"Bağlam"}},"uk":{"stringUnit":{"state":"translated","value":"Контекст"}},"vi":{"stringUnit":{"state":"translated","value":"Ngữ cảnh"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"上下文"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"上下文"}}}}, "chatMenu.forkTo": {"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":"分支至…"}},"bs":{"stringUnit":{"state":"translated","value":"Napravi granu u…"}},"da":{"stringUnit":{"state":"translated","value":"Forgren til…"}},"it":{"stringUnit":{"state":"translated","value":"Crea un ramo in…"}},"km":{"stringUnit":{"state":"translated","value":"បំបែកសាខាទៅ…"}},"nb":{"stringUnit":{"state":"translated","value":"Forgren til…"}},"pl":{"stringUnit":{"state":"translated","value":"Utwórz odgałęzienie w…"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Criar ramificação em…"}},"ru":{"stringUnit":{"state":"translated","value":"Создать ветку в…"}},"th":{"stringUnit":{"state":"translated","value":"แยกสาขาไปยัง…"}},"tr":{"stringUnit":{"state":"translated","value":"Şuraya dallandır…"}},"uk":{"stringUnit":{"state":"translated","value":"Створити гілку в…"}},"vi":{"stringUnit":{"state":"translated","value":"Tạo nhánh tại…"}}}}, "composer.connectPhone": {"comment":"Agent pane: the environment picker's row under New environment that opens the Mobile Pairing page, so cmux on iPhone reaches this Mac (EnvironmentPicker.tsx)","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Connect Phone…"}},"ar":{"stringUnit":{"state":"translated","value":"ربط الهاتف…"}},"bs":{"stringUnit":{"state":"translated","value":"Poveži telefon…"}},"da":{"stringUnit":{"state":"translated","value":"Forbind telefon…"}},"de":{"stringUnit":{"state":"translated","value":"Telefon verbinden…"}},"es":{"stringUnit":{"state":"translated","value":"Conectar teléfono…"}},"fr":{"stringUnit":{"state":"translated","value":"Connecter un téléphone…"}},"it":{"stringUnit":{"state":"translated","value":"Collega telefono…"}},"ja":{"stringUnit":{"state":"translated","value":"電話を接続…"}},"km":{"stringUnit":{"state":"translated","value":"ភ្ជាប់ទូរសព្ទ…"}},"ko":{"stringUnit":{"state":"translated","value":"휴대폰 연결…"}},"nb":{"stringUnit":{"state":"translated","value":"Koble til telefon…"}},"pl":{"stringUnit":{"state":"translated","value":"Połącz telefon…"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Conectar celular…"}},"ru":{"stringUnit":{"state":"translated","value":"Подключить телефон…"}},"th":{"stringUnit":{"state":"translated","value":"เชื่อมต่อโทรศัพท์…"}},"tr":{"stringUnit":{"state":"translated","value":"Telefon Bağla…"}},"uk":{"stringUnit":{"state":"translated","value":"Підключити телефон…"}},"vi":{"stringUnit":{"state":"translated","value":"Kết nối điện thoại…"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"连接手机…"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"連接手機…"}}}}, "retry.running": {"comment":"Agent pane: short note after a failed subagent's or sub-session's line when acpmux refused a retry because it already runs again (subagents/SubagentDoneLine.tsx)","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Already running again"}},"ar":{"stringUnit":{"state":"needs_review","value":"يعمل مرة أخرى بالفعل"}},"bs":{"stringUnit":{"state":"needs_review","value":"Već ponovo radi"}},"da":{"stringUnit":{"state":"needs_review","value":"Kører allerede igen"}},"de":{"stringUnit":{"state":"needs_review","value":"Läuft bereits wieder"}},"es":{"stringUnit":{"state":"needs_review","value":"Ya se está ejecutando de nuevo"}},"fr":{"stringUnit":{"state":"needs_review","value":"Déjà relancé"}},"it":{"stringUnit":{"state":"needs_review","value":"Già di nuovo in esecuzione"}},"ja":{"stringUnit":{"state":"needs_review","value":"すでに再実行中です"}},"km":{"stringUnit":{"state":"needs_review","value":"កំពុងដំណើរការម្តងទៀតរួចហើយ"}},"ko":{"stringUnit":{"state":"needs_review","value":"이미 다시 실행 중"}},"nb":{"stringUnit":{"state":"needs_review","value":"Kjører allerede igjen"}},"pl":{"stringUnit":{"state":"needs_review","value":"Już działa ponownie"}},"pt-BR":{"stringUnit":{"state":"needs_review","value":"Já está em execução novamente"}},"ru":{"stringUnit":{"state":"needs_review","value":"Уже снова выполняется"}},"th":{"stringUnit":{"state":"needs_review","value":"กำลังทำงานอีกครั้งแล้ว"}},"tr":{"stringUnit":{"state":"needs_review","value":"Zaten yeniden çalışıyor"}},"uk":{"stringUnit":{"state":"needs_review","value":"Уже знову виконується"}},"vi":{"stringUnit":{"state":"needs_review","value":"Đã chạy lại rồi"}},"zh-Hans":{"stringUnit":{"state":"needs_review","value":"已在重新运行"}},"zh-Hant":{"stringUnit":{"state":"needs_review","value":"已在重新執行"}}}}, diff --git a/webviews/src/agent-session/acpmux/direct.ts b/webviews/src/agent-session/acpmux/direct.ts index d3597d9f726a..22e231c5b0ca 100644 --- a/webviews/src/agent-session/acpmux/direct.ts +++ b/webviews/src/agent-session/acpmux/direct.ts @@ -42,6 +42,7 @@ import { peerConnectedFromStatus, peerKindsFromStatus, peerStatesFromStatus } fr import { peerResourcesFromStatus } from "./machineResources"; import { type ChatUnavailable, chatUnavailable, type PeerStatus, peerStatusFromStatus } from "./chatUnavailable"; import { hostKind, sessionEntry, summaryActivityAt, text, type AcpmuxSessionEntry } from "./sessionList"; +import { sameUsageStats, type SessionUsageStats } from "./usageStats"; import { clampRunning, subsessionLimits } from "./subsessionLimits"; import { agentName } from "./agents"; import { FORK_OP, servesOperation } from "./operations"; @@ -1248,6 +1249,11 @@ export class AcpmuxDirectClient { private sessionRereadResyncsAsks = false; /// Sidebar entries by acpmux session object. A changed session arrives as a new object, so unchanged rows keep their entry and skip rendering. private sessionEntries = new WeakMap(); + /// The snapshot's `sessions` for the list it was cut from: the same array until the list + /// changes, so a snapshot per token gives the pane's session stores nothing new to read. + private entriesOf?: { sessions: readonly Session[]; entries: AcpmuxSessionEntry[] }; + /// The snapshot summary's `usageStats`, kept while acpmux's numbers are the same (sameUsageStats). + private summaryUsage?: SessionUsageStats; private selectedSessionId?: string; /** The session a link named that the daemon does not have (`sessionMustExist`). */ private missingSession?: string; @@ -5179,6 +5185,21 @@ export class AcpmuxDirectClient { acpmuxPerf.timed("client.emit", () => this.emitNow(connection)); } + /// Each listed session's entry, reusing the entry of a session that did not change. + private listedEntries(): AcpmuxSessionEntry[] { + if (this.entriesOf?.sessions === this.sessions) return this.entriesOf.entries; + const entries = this.sessions.map((session) => { + let entry = this.sessionEntries.get(session); + if (!entry) { + entry = sessionEntry(session); + this.sessionEntries.set(session, entry); + } + return entry; + }); + this.entriesOf = { sessions: this.sessions, entries }; + return entries; + } + private emitNow(connection: string): void { if (this.closed && !this.replayMode) return; if (this.snapshotHold) { @@ -5211,14 +5232,7 @@ export class AcpmuxDirectClient { protocolVersion: 1, rows: this.rows.sorted(), subagentViews: this.subagents.views(), - sessions: this.sessions.map((session) => { - let entry = this.sessionEntries.get(session); - if (!entry) { - entry = sessionEntry(session); - this.sessionEntries.set(session, entry); - } - return entry; - }), + sessions: this.listedEntries(), peers: this.peers, peerKinds: this.peerKinds, peerConnected: this.peerConnected, @@ -5232,6 +5246,7 @@ export class AcpmuxDirectClient { turnCount: summary.turnCount, usage: this.usage, contextBreakdown: contextBreakdownOf(summary.contextBreakdown), + usageStats: (this.summaryUsage = sameUsageStats(summary.usageStats, this.summaryUsage)), host: text(summary.host), peer: text(summary.peer), hostKind: hostKind(summary.hostKind), diff --git a/webviews/src/agent-session/acpmux/inspector/ChatInspector.gallery.tsx b/webviews/src/agent-session/acpmux/inspector/ChatInspector.gallery.tsx index e698762cb246..35ac26252b39 100644 --- a/webviews/src/agent-session/acpmux/inspector/ChatInspector.gallery.tsx +++ b/webviews/src/agent-session/acpmux/inspector/ChatInspector.gallery.tsx @@ -28,6 +28,8 @@ import { SubagentBanner, SubagentTitle, type SubagentHeaderVariant } from "./Sub import { subagentSummary, type SubagentScope } from "./subagentScope"; import { outputArtifacts } from "./OutputsSection"; import { DEFAULT_SECTIONS } from "./inspectorState"; +import { sessionEntry, type AcpmuxSessionEntry } from "../sessionList"; +import type { SessionUsageStats } from "../usageStats"; type Props = ComponentProps & { placement?: InspectorPlacement; @@ -268,9 +270,35 @@ const transcriptTasks: NonNullable = [ { id: "call-gen", command: "bun scripts/pages/gen-strings.mjs", state: "completed", startedAt: started - 700_000 }, ]; +/// What acpmux reports a session spent (usageStats.ts), for Usage. +const spent = (fields: Partial): SessionUsageStats => ({ + launcher: "claude", + autocompact: "none", + compactions24h: 0, + subagentsLive: 0, + subagents1h: 0, + subagents24h: 0, + tokens24h: 0, + updatedAt: Date.UTC(2026, 9, 30, 3), + ...fields, +}); +const ownUsage = spent({ + launcher: "claude-sr2", + autocompact: "capped", + autocompactCap: 200_000, + contextTokens: 94_000, + contextWindow: 200_000, + compactions24h: 2, + subagentsLive: 1, + subagents1h: 3, + subagents24h: 11, + tokens24h: 38_400_000, + costUsd24h: 61.2, +}); const summary = { ...local.summary!, usage: { used: 94_000, size: 200_000 }, + usageStats: ownUsage, // At most 16 of the chats it started run at once (acpmux's default). subsessionLimits: { maxRunning: 16, maxRunningSource: "default" as const }, branch: "feat/next-inspector", @@ -288,7 +316,7 @@ const checkTopBlockAlignment: Play = async (ctx) => { const environment = '[data-section="environment"] .acpmux-chat-inspector-row__name'; await ctx.waitFor(() => ctx.document.querySelector(environment)); const labelLeft = rect(environment).left; - for (const section of ["context", "connectors"]) { + for (const section of ["context", "usage", "connectors"]) { const title = rect(`[data-section="${section}"] .acpmux-chat-inspector-section__title`); if (Math.abs(title.left - labelLeft) > 0.5) throw new Error(`${section} label starts ${title.left - labelLeft}px from the Environment text column`); @@ -299,8 +327,56 @@ const checkTopBlockAlignment: Play = async (ctx) => { throw new Error("The heading-only Changes section moved from the icon column"); }; +/// Every session acpmux lists, this Mac's and a peer's: the usage audit's deputies on a build box (no +/// autocompact cap, 290-330k per call, 67-90 subagents a day) above this chat. +const fleet: AcpmuxSessionEntry[] = [ + sessionEntry({ + sessionId: local.summary!.sessionId, + name: "inspector-polish", + harness: "claude", + usageStats: ownUsage, + }), + ...( + [ + ["dep-fleet", 1_420.5, 312_000, 90], + ["dep-land", 1_288, 331_000, 84], + ["dep-asks", 1_204.75, 296_000, 71], + ["self-audit", 1_190, 290_000, 67], + ] as const + ).map(([name, costUsd24h, contextTokens, subagents24h]) => + sessionEntry({ + sessionId: `build-box-${name}`, + name: `build-box/${name}`, + peer: "build-box", + harness: "claude", + usageStats: spent({ costUsd24h, contextTokens, contextWindow: 1_000_000, subagents24h, tokens24h: 900_000_000 }), + }), + ), + sessionEntry({ + sessionId: "gcp-review", + name: "lane-vm/review-27744", + peer: "lane-vm", + harness: "codex", + // acpmux has not read a whole day yet: the 24h counts are unknown, never 0. + usageStats: { + launcher: "codex-acp", + autocompact: "unknown", + contextTokens: 41_000, + updatedAt: Date.UTC(2026, 9, 30, 3), + }, + }), + sessionEntry({ + sessionId: "docs-pass", + name: "docs-pass", + harness: "claude", + usageStats: spent({ autocompact: "capped", costUsd24h: 4.1, contextTokens: 22_000, tokens24h: 1_300_000 }), + }), +]; + const full: Props = { summary, + allSessions: fleet, + onOpenChat: () => undefined, machineName: "Studio", repository: "manaflow-ai/cmux", made: { @@ -583,6 +659,7 @@ export default componentEntry({ "agent-session/acpmux/inspector/SubagentScopeHeader.tsx", "agent-session/acpmux/inspector/subagentScope.ts", "agent-session/acpmux/inspector/TasksSection.tsx", + "agent-session/acpmux/inspector/UsageSection.tsx", "agent-session/acpmux/inspector/WorkflowTasks.tsx", "agent-session/acpmux/summary/foldWorkflows.ts", "agent-session/acpmux/subagents/AgentLine.tsx", @@ -632,6 +709,71 @@ export default componentEntry({ props: workflowProps("running", "detail", 260, "anthropic/claude-sonnet-5-5"), play: assertNarrowWorkflowLayout, }, + usage: { + note: "Usage open (Leo, 2026-10-30, on the deputies nobody could see spending): this chat's last 24 hours (cost, tokens, the context each call carries with Compact beside it, its autocompact cap, compactions, subagents with Show, the launcher), then every session acpmux lists on this Mac and its peers, highest 24h cost first; five show, then View all. Folded, it is one row after Context: this chat's 24h cost.", + props: { + ...full, + sections: { + ...DEFAULT_SECTIONS, + environment: false, + usage: true, + changes: false, + subagents: false, + tasks: false, + }, + }, + }, + "usage-no-cap": { + note: "Usage for a chat acpmux says runs with no autocompact cap: Autocompact reads No cap, with Restart with cap beside it, disabled until acpmux can restart a session with a cap. A cap acpmux could not read reads as an em dash and offers nothing.", + props: { + ...full, + summary: { ...summary, usageStats: { ...ownUsage, autocompact: "none" as const, autocompactCap: undefined } }, + sections: { + ...DEFAULT_SECTIONS, + environment: false, + usage: true, + changes: false, + subagents: false, + tasks: false, + }, + }, + }, + "usage-context": { + note: "Usage sorted by Context: the sessions carrying the most context per call first, the uncapped deputies on build-box at 290-330k on top.", + props: { + ...full, + sections: { + ...DEFAULT_SECTIONS, + environment: false, + usage: true, + changes: false, + subagents: false, + tasks: false, + }, + }, + play: async (ctx) => { + await ctx.click({ selector: ".acpmux-chat-inspector-usage__sort-option:nth-child(2)" }); + await ctx.waitFor(() => ctx.document.querySelector('[data-sort="context"]') !== null); + }, + }, + "usage-all": { + note: "Usage with View all open: every session with usage, the lane VM's Codex review, whose 24h numbers acpmux has not read yet, shows an em dash, never $0.00.", + props: { + ...full, + sections: { + ...DEFAULT_SECTIONS, + environment: false, + usage: true, + changes: false, + subagents: false, + tasks: false, + }, + }, + play: async (ctx) => { + await ctx.click({ selector: ".acpmux-chat-inspector-usage-fold" }); + await ctx.waitFor(() => ctx.document.querySelectorAll("[data-sort] > li").length > 5); + }, + }, "layout-glance": { note: "Chosen (Leo, 2026-10-22: \"this session rail is tmi and too many boxes?\"). Eight cards became three blocks with a hairline between them and no box: the Environment's rows with no title, Context and Connectors one row each; Changes and Outputs; Subagents and Background tasks, the Servers card's row now heading Background tasks. Least ink of the three while every section and click stays; the rows line up under the panel's title.", props: full, diff --git a/webviews/src/agent-session/acpmux/inspector/ChatInspector.tsx b/webviews/src/agent-session/acpmux/inspector/ChatInspector.tsx index 90c68ec56cc1..820508fe9520 100644 --- a/webviews/src/agent-session/acpmux/inspector/ChatInspector.tsx +++ b/webviews/src/agent-session/acpmux/inspector/ChatInspector.tsx @@ -92,6 +92,8 @@ import { SubagentLimitStepper } from "./SubagentLimitStepper"; import { SubagentsSection, subagentCounts } from "./SubagentsSection"; import { TasksSection } from "./TasksSection"; import { WorkflowTasks, type WorkflowAgentRowVariant } from "./WorkflowTasks"; +import { UsageSection, usageMeta } from "./UsageSection"; +import type { AcpmuxSessionEntry } from "../sessionList"; import type { WorkflowAgent, WorkflowSummary } from "../summary/workflow"; import type { Subagent } from "../subagents/subagentFold"; import { subagentProgress } from "../subagents/subagentProgress"; @@ -307,6 +309,8 @@ export function useChatInspector({ beside, wide = false, onReconnectMcp, + onOpenChat, + onCompact, }: { snapshot: AcpmuxSnapshot; /// A view has the pane in the chat's place (App.tsx: a turn's diff in place, the event Inspector): @@ -368,6 +372,10 @@ export function useChatInspector({ subagent?: SubagentScope; /// Reconnects one of the chat's failed MCP servers by name (`_acpmux/mcp/reconnect`). onReconnectMcp?: (server: string) => Promise; + /// Opens any chat acpmux lists, this Mac's or a peer's (Usage's rows). + onOpenChat?: (sessionId: string) => void; + /// Sends the chat's agent its `/compact` (Usage's context row); unset where Compact cannot reach. + onCompact?: () => void; }): { on: boolean; /// The panel shows (on, open, not stepped aside): a card under it keeps no room of its own beside @@ -500,6 +508,8 @@ export function useChatInspector({ onOpenFolder={onOpenFolder} onSaveEnvironment={onSaveEnvironment} onReconnectMcp={onReconnectMcp} + onOpenChat={onOpenChat} + onCompact={onCompact} onOpenDocument={onOpenDocument} onShowCall={onShowCall} scope={subagent} @@ -549,6 +559,8 @@ function InspectorPresence({ onOpenFolder, onSaveEnvironment, onReconnectMcp, + onOpenChat, + onCompact, onOpenDocument, onShowCall, scope, @@ -594,6 +606,8 @@ function InspectorPresence({ onOpenFolder?: (path: string) => void; onSaveEnvironment?: () => Promise; onReconnectMcp?: (server: string) => Promise; + onOpenChat?: (sessionId: string) => void; + onCompact?: () => void; onOpenDocument?: (artifact: Artifact) => void; onShowCall?: (toolCallId: string) => boolean; /// The subagent it lists, while the pane shows one's own view. @@ -763,6 +777,10 @@ function InspectorPresence({ onStopWorkflow={workflows?.onStop} onOpenWorkflowAgent={workflows?.onOpenAgent} onOpenSession={sessions?.onOpen} + // Usage lists every chat acpmux lists; a subagent's own panel has no Usage. + allSessions={scope ? undefined : snapshot.sessions} + onOpenChat={onOpenChat} + onCompact={scope ? undefined : onCompact} onSetRunningLimit={onSetRunningLimit} revealed={revealed} onRevealed={onRevealed} @@ -963,6 +981,9 @@ export function ChatInspector({ onReconnectMcp, onOpenFolder, onShowCall, + allSessions, + onOpenChat, + onCompact, banner, agent, layout = RAIL_LAYOUT, @@ -1023,6 +1044,13 @@ export function ChatInspector({ onSaveEnvironment?: () => Promise | void; /// Reconnects one of the chat's failed MCP servers by name (`_acpmux/mcp/reconnect`). onReconnectMcp?: (server: string) => Promise; + /// Every chat acpmux lists, this Mac's and its peers', for Usage (UsageSection.tsx); unset in a + /// subagent's own panel. + allSessions?: readonly AcpmuxSessionEntry[]; + /// Opens one of them. + onOpenChat?: (sessionId: string) => void; + /// Sends the chat's agent its `/compact`: Usage's next step for its context (UsageSection.tsx). + onCompact?: () => void; /// Opens the chat's project folder in the Files tree beside the chat. onOpenFolder?: (path: string) => void; /// Shows the Bash call that started a background command in the transcript: what its row opens @@ -1067,23 +1095,35 @@ export function ChatInspector({ // in view still shows it answered (the header's Environment line, Leo 2026-10-21: no dead clicks). // Not the reveal effect's cleanup: `onRevealed` clears the reveal at once, which would put it out. const lit = useRef<{ section: HTMLElement; timer: number }>(undefined); - const unlight = () => { + const unlight = useCallback(() => { if (!lit.current) return; window.clearTimeout(lit.current.timer); delete lit.current.section.dataset.revealed; lit.current = undefined; - }; - useEffect(() => unlight, []); + }, []); + useEffect(() => unlight, [unlight]); + const light = useCallback( + (id: InspectorSectionId) => { + const section = body.current?.querySelector(`[data-section="${id}"]`); + section?.scrollIntoView?.({ block: "nearest" }); + if (!section) return; + unlight(); + section.dataset.revealed = ""; + lit.current = { section, timer: window.setTimeout(unlight, REVEAL_LIT_MS) }; + }, + [unlight], + ); useEffect(() => { if (!revealed) return; - const section = body.current?.querySelector(`[data-section="${revealed.section}"]`); - section?.scrollIntoView?.({ block: "nearest" }); + light(revealed.section); onRevealed?.(); - if (!section) return; - unlight(); - section.dataset.revealed = ""; - lit.current = { section, timer: window.setTimeout(unlight, REVEAL_LIT_MS) }; - }, [revealed, onRevealed]); + }, [revealed, onRevealed, light]); + // Usage's Show: Subagents opens, comes into view and lights, as a reveal does. + const showSubagents = useCallback(() => { + (onSection ?? setInspectorSection)("subagents", true); + light("subagents"); + }, [onSection, light]); + const anyUsage = useMemo(() => !!allSessions?.some((entry) => entry.usageStats), [allSessions]); const scheduled = made?.scheduled ?? []; const pullRequests = visibleChanges(made?.pullRequests ?? [], statuses); const sources = made?.sources ?? []; @@ -1276,11 +1316,34 @@ export function ChatInspector({ )} ); + // Usage shows once acpmux reports what this chat or any listed chat spends. + const ownUsage = summary?.usageStats; + const usageValue = usageMeta(t, ownUsage, language); + const usageSection = (ownUsage || anyUsage) && ( + + + + ); return (
{banner} {/* Where it runs. */} - + {/* What it made. */} {(changes || sourcesSection || outputsSection) && ( diff --git a/webviews/src/agent-session/acpmux/inspector/UsageSection.css b/webviews/src/agent-session/acpmux/inspector/UsageSection.css new file mode 100644 index 000000000000..0106f87471e3 --- /dev/null +++ b/webviews/src/agent-session/acpmux/inspector/UsageSection.css @@ -0,0 +1,74 @@ +/* Session Info's Usage, open (UsageSection.tsx): this chat's rows, then every session's, sorted by a + two-option toggle. The rows are the panel's own (`acpmux-chat-inspector-row`); a pressed option + fills only its own button, never the row around it. */ +.acpmux-chat-inspector-usage { + min-width: 0; + padding-bottom: 4px; +} +.acpmux-chat-inspector-usage__value { + color: var(--agent-text); +} +.acpmux-chat-inspector-usage__head { + display: flex; + align-items: center; + gap: 8px; + height: 28px; + padding: 0 8px; +} +.acpmux-chat-inspector-usage__title { + color: var(--agent-muted); + font-weight: 500; +} +.acpmux-chat-inspector-usage__sort { + display: flex; + gap: 4px; + min-width: 0; + margin: 0 0 0 auto; + padding: 0; + border: 0; +} +.acpmux-chat-inspector-usage__sort-option { + height: 20px; + padding: 0 8px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--agent-muted); + font: inherit; + font-size: var(--text-detail); + letter-spacing: var(--text-detail--letter-spacing); +} +.acpmux-chat-inspector-usage__sort-option:hover:not(:disabled) { + color: var(--agent-text); +} +.acpmux-chat-inspector-usage__sort-option[aria-pressed="true"] { + background: color-mix(in srgb, var(--agent-text) 8%, transparent); + color: var(--agent-text); +} +.acpmux-chat-inspector-usage__sort-option:focus-visible { + outline: 1.5px solid var(--agent-text); + outline-offset: -1.5px; +} +.acpmux-chat-inspector-usage__action { + flex: none; + height: 20px; + padding: 0 8px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--agent-muted); + font: inherit; + font-size: var(--text-detail); + letter-spacing: var(--text-detail--letter-spacing); +} +.acpmux-chat-inspector-usage__action:hover:not(:disabled) { + background: color-mix(in srgb, var(--agent-text) 7%, transparent); + color: var(--agent-text); +} +.acpmux-chat-inspector-usage__action:disabled { + opacity: 0.5; +} +.acpmux-chat-inspector-usage__action:focus-visible { + outline: 1.5px solid var(--agent-text); + outline-offset: -1.5px; +} diff --git a/webviews/src/agent-session/acpmux/inspector/UsageSection.tsx b/webviews/src/agent-session/acpmux/inspector/UsageSection.tsx new file mode 100644 index 000000000000..e913bd97b2b4 --- /dev/null +++ b/webviews/src/agent-session/acpmux/inspector/UsageSection.tsx @@ -0,0 +1,254 @@ +// Session Info's Usage (ChatInspector.tsx), one row in the top block like Context: what this chat +// spent in the last 24 hours, and every session acpmux lists, this Mac's and its peers', sorted by +// 24h cost or by context, so the top spenders are one glance away. Leo, 2026-10-30: "that +// visibility into huge-blue guys was (and is?) pretty bad i feel like we can make more improvements +// to acpmux/cmux-next". The numbers are acpmux's `usageStats` (usageStats.ts), refreshed on the +// session list acpmux already sends; the list is sorted only while the section is open, and only +// when the list or the sort changes. +import { memo, useMemo, useState } from "react"; +import { useT, type Translate } from "../i18n"; +import { AgentMark } from "../../shared/AgentMark"; +import { agentTone } from "../subagents/agentTones"; +import { TonedIcon } from "../subagents/TonedIcon"; +import { rowIconSize } from "../icons/iconSize"; +import type { AcpmuxSessionEntry } from "../sessionList"; +import { byUsage, type SessionUsageStats, UNKNOWN, type UsageSort, usageCost, usageTokens } from "../usageStats"; +import "./UsageSection.css"; + +const ROW_ICON = rowIconSize(12); +/// Rows the list shows before View all. +export const USAGE_SHOWN = 5; + +/// The section's one-line value: this chat's 24h cost, else its 24h tokens. +export function usageMeta(t: Translate, stats: SessionUsageStats | undefined, locale?: string): string | undefined { + if (!stats) return undefined; + if (stats.costUsd24h !== undefined) + return t("chatInspector.usage.cost24hValue", { cost: usageCost(stats.costUsd24h, locale) }); + if (stats.tokens24h !== undefined) + return t("chatInspector.usage.tokens24hValue", { tokens: usageTokens(stats.tokens24h, locale) }); + return undefined; +} + +/// What a row of this chat's numbers offers next (Leo's rule via acpmux-owner: every number has a +/// next step): Compact for the context, the Subagents section for its subagents, and a Restart +/// with an autocompact cap for an uncapped chat, shown disabled until acpmux can do it. +type OwnAction = "compact" | "subagents" | "cap"; +type OwnRow = { id: string; name: string; value: string; action?: OwnAction }; + +/// `value` formatted, or the unknown mark: never a synthesized 0 or $0.00. +const known = (value: T | undefined, format: (value: T) => string) => + value === undefined ? UNKNOWN : format(value); + +/// This chat's numbers, as name and value rows. +function ownRows(t: Translate, stats: SessionUsageStats, locale?: string): OwnRow[] { + const tokens = (value: number) => usageTokens(value, locale); + const subagents = [stats.subagentsLive, stats.subagents1h, stats.subagents24h]; + const rows: OwnRow[] = [ + { + id: "cost", + name: t("chatInspector.usage.cost24h"), + value: known(stats.costUsd24h, (cost) => usageCost(cost, locale)), + }, + { id: "tokens", name: t("chatInspector.usage.tokens24h"), value: known(stats.tokens24h, tokens) }, + { + id: "context", + name: t("chatInspector.usage.perCall"), + value: + stats.contextTokens !== undefined && stats.contextWindow + ? `${tokens(stats.contextTokens)} / ${tokens(stats.contextWindow)}` + : known(stats.contextTokens, tokens), + action: "compact", + }, + { + id: "cap", + name: t("chatInspector.usage.autocompact"), + // Only acpmux's `none` is no cap; a cap it could not read is unknown, never "No cap". + value: + stats.autocompact === "none" + ? t("chatInspector.usage.noCap") + : stats.autocompact === "capped" + ? known(stats.autocompactCap, tokens) + : UNKNOWN, + ...(stats.autocompact === "none" && { action: "cap" as const }), + }, + { id: "compactions", name: t("chatInspector.usage.compactions24h"), value: known(stats.compactions24h, String) }, + { + id: "subagents", + name: t("chatInspector.usage.subagents"), + value: subagents.every((count) => count === undefined) + ? UNKNOWN + : t("chatInspector.usage.subagentsValue", { + live: known(stats.subagentsLive, String), + hour: known(stats.subagents1h, String), + day: known(stats.subagents24h, String), + }), + action: "subagents", + }, + ]; + if (stats.launcher) rows.push({ id: "launcher", name: t("chatInspector.usage.launcher"), value: stats.launcher }); + return rows; +} + +/// What a list row's end says for `sort`; unknown is the unknown mark. +function sortValue(stats: SessionUsageStats, sort: UsageSort, locale?: string): string { + if (sort === "cost") + return stats.costUsd24h === undefined + ? known(stats.tokens24h, (tokens) => usageTokens(tokens, locale)) + : usageCost(stats.costUsd24h, locale); + return known(stats.contextTokens, (tokens) => usageTokens(tokens, locale)); +} + +/// Memoized: its props are the same objects until acpmux's numbers or list change (direct.ts keeps +/// both), so a snapshot per token draws none of its rows again. +export const UsageSection = memo(function UsageSection({ + own, + sessions, + chatId, + onOpen, + onCompact, + onShowSubagents, + locale, + sort: initialSort = "cost", + expanded: initialExpanded = false, +}: { + /// This chat's (`summary.usageStats`). + own?: SessionUsageStats; + /// Every session acpmux lists, this Mac's and its peers' (the snapshot's `sessions`). + sessions: readonly AcpmuxSessionEntry[]; + chatId?: string; + /// Opens a listed chat, this Mac's or a peer's; without it the rows are plain text. + onOpen?: (sessionId: string) => void; + /// Sends this chat's agent its `/compact` (App.tsx, as a context-full failure's Compact); without + /// it (an agent that serves no /compact, a View Only chat) Compact is not offered. + onCompact?: () => void; + /// Opens and shows the Subagents section; without it (no Subagents section) nothing is offered. + onShowSubagents?: () => void; + locale?: string; + /// The gallery's starting states. + sort?: UsageSort; + expanded?: boolean; +}) { + const t = useT(); + const [sort, setSort] = useState(initialSort); + const [expanded, setExpanded] = useState(initialExpanded); + const ranked = useMemo(() => byUsage(sessions, sort), [sessions, sort]); + const folds = ranked.length > USAGE_SHOWN; + const shown = folds && !expanded ? ranked.slice(0, USAGE_SHOWN) : ranked; + return ( +
+ {own && ( +
    + {ownRows(t, own, locale).map(({ id, name, value, action }) => ( +
  • + {name} + {value} + {action === "compact" && onCompact && ( + + )} + {action === "subagents" && onShowSubagents && ( + + )} + {/* acpmux cannot restart a session with a cap yet: the step is named, not invented. */} + {action === "cap" && ( + + )} +
  • + ))} +
+ )} + {ranked.length > 0 && ( + <> +
+ {t("chatInspector.usage.allSessions")} +
+ {(["cost", "context"] as const).map((option) => ( + + ))} +
+
+
    + {shown.map((entry) => { + const stats = entry.usageStats!; + const place = entry.peer ?? entry.host; + // A peer's chat is named `/` (acpmux hub/peers.rs); the row names the peer + // after the title, so the title drops it. + const named = entry.displayTitle || entry.title || entry.name || entry.sessionId; + const title = + entry.peer && named.startsWith(`${entry.peer}/`) ? named.slice(entry.peer.length + 1) : named; + const mark = ( + + {entry.harness ? ( + } + /> + ) : ( + + )} + + ); + const body = ( + <> + {mark} + {title} + {place && {place}} + + {sortValue(stats, sort, locale)} + + + ); + const current = entry.sessionId === chatId || undefined; + return ( +
  • + {onOpen && !current ? ( + + ) : ( +
    {body}
    + )} +
  • + ); + })} +
+ {folds && ( + + )} + + )} +
+ ); +}); diff --git a/webviews/src/agent-session/acpmux/inspector/inspectorState.ts b/webviews/src/agent-session/acpmux/inspector/inspectorState.ts index 14b8f7cef863..f78753e7cd24 100644 --- a/webviews/src/agent-session/acpmux/inspector/inspectorState.ts +++ b/webviews/src/agent-session/acpmux/inspector/inspectorState.ts @@ -12,6 +12,7 @@ import { useSyncExternalStore } from "react"; export type InspectorSectionId = | "environment" | "context" + | "usage" | "connectors" | "changes" | "sources" @@ -24,11 +25,12 @@ export type InspectorSections = Readonly>; /// The width a drag can give it, in px, and the width it opens at the first time. export const INSPECTOR_WIDTH = { min: 260, max: 560, fallback: 320 } as const; -/// Context and Connectors start folded to their one-line values (as in Claude Code desktop); the -/// rest start open. +/// Context, Usage and Connectors start folded to their one-line values (as in Claude Code desktop); +/// the rest start open. export const DEFAULT_SECTIONS: InspectorSections = { environment: true, context: false, + usage: false, connectors: false, changes: true, sources: true, diff --git a/webviews/src/agent-session/acpmux/inspector/sectionOrder.ts b/webviews/src/agent-session/acpmux/inspector/sectionOrder.ts index d3442d61d5f7..9c84d9131e1e 100644 --- a/webviews/src/agent-session/acpmux/inspector/sectionOrder.ts +++ b/webviews/src/agent-session/acpmux/inspector/sectionOrder.ts @@ -12,7 +12,7 @@ export type InspectorGroupId = "about" | "made" | "running"; /// Each block's sections in their default order: where it runs, what it made, what it runs. export const GROUP_SECTIONS: Readonly> = { - about: ["environment", "context", "connectors"], + about: ["environment", "context", "usage", "connectors"], made: ["changes", "sources", "outputs"], running: ["subagents", "tasks"], }; diff --git a/webviews/src/agent-session/acpmux/model.ts b/webviews/src/agent-session/acpmux/model.ts index 4fcfd3858123..082a1d3cdedd 100644 --- a/webviews/src/agent-session/acpmux/model.ts +++ b/webviews/src/agent-session/acpmux/model.ts @@ -9,6 +9,7 @@ import type { AgentQuestion } from "./question/model"; import type { WatchedAsk } from "./needsYou/model"; import type { PermissionDecision } from "./permissions/record"; import type { HandoffClientState } from "./handoff/client"; +import type { SessionUsageStats } from "./usageStats"; import type { Enforcement } from "./handoff/protocol"; import type { SlashCommand } from "./slashCommands"; import type { SummaryCheckpoint } from "./changes/turnCheckpointSource"; @@ -665,6 +666,9 @@ export type AcpmuxSnapshot = { /// (acpmux `contextBreakdown`); unset for other harnesses. Each list holds at most 50 rows, /// the largest. contextBreakdown?: AcpmuxContextBreakdown; + /// What the session spends (acpmux `usageStats`, usageStats.ts): 24h tokens and cost, its + /// autocompact cap, its subagents. Unset while the harness has no readable transcript. + usageStats?: SessionUsageStats; host?: string; peer?: string; hostKind?: "local" | "cloud"; diff --git a/webviews/src/agent-session/acpmux/sessionList.ts b/webviews/src/agent-session/acpmux/sessionList.ts index 8b91b0d0bb5f..0e412645c009 100644 --- a/webviews/src/agent-session/acpmux/sessionList.ts +++ b/webviews/src/agent-session/acpmux/sessionList.ts @@ -3,6 +3,7 @@ // so a session reads the same in the terminal and in the pane. import { type Translate, translate } from "./i18n"; import { readRetry, type RetryState } from "./subagents/retryState"; +import { type SessionUsageStats, usageStatsOf } from "./usageStats"; /** A session as the sidebar sees it, cut from acpmux's session summary. */ export type AcpmuxSessionEntry = { @@ -99,6 +100,8 @@ export type AcpmuxSessionEntry = { /** Why its failed or lost turn failed, as acpmux's lane says it (`lane.error`, secrets masked): * a retry acpmux refused before its turn started leaves it failed with this. */ laneError?: string; + /** What it spends: context, 24h tokens and cost, subagents (acpmux `usageStats`, usageStats.ts). */ + usageStats?: SessionUsageStats; }; export type LifecycleReceipt = { @@ -260,6 +263,7 @@ export function sessionEntry(session: Record & { sessionId: string retry: readRetry(session.retry) ?? undefined, ...(session.retryDeciding === true && { retryDeciding: true as const }), laneError: text(session.lane?.error), + usageStats: usageStatsOf(session.usageStats), }; } diff --git a/webviews/src/agent-session/acpmux/usageStats.ts b/webviews/src/agent-session/acpmux/usageStats.ts new file mode 100644 index 000000000000..4396046e00cc --- /dev/null +++ b/webviews/src/agent-session/acpmux/usageStats.ts @@ -0,0 +1,112 @@ +// What a session spends, as acpmux reads it from the harness's transcript (`usageStats` on every +// session summary, `ls --json` row and peer row). Leo, 2026-10-30: "that visibility into +// huge-blue guys was (and is?) pretty bad i feel like we can make more improvements to +// acpmux/cmux-next". Four deputies ran for hours with no autocompact cap at 290-330k of context +// and 67-90 subagents each, and nothing in the app showed it. Session Info, the session hover +// card and the Sessions by usage list read these numbers; acpmux recomputes them on each list +// read and at turn end, so the pane adds no timer and no per-token work for them. + +/** acpmux's `usageStats` (ao-vis-data's contract). Absent while the harness has no readable + * transcript; a number acpmux could not read is absent here too. */ +export type SessionUsageStats = { + /** The harness executable as launched (`claude-sr2`, `claude`, `codex-acp`). */ + launcher?: string; + /** Whether the session compacts on its own (acpmux `autocompact`): `capped`, `none` (no + * --autocompact or CLAUDE_CODE_AUTO_COMPACT_WINDOW), or `unknown` (also when acpmux sent none). + * Only `none` is a session to warn about. */ + autocompact: "capped" | "none" | "unknown"; + /** The autocompact window in tokens, when acpmux read it. */ + autocompactCap?: number; + /** Input and cache tokens of the last model call: what each call carries now. */ + contextTokens?: number; + contextWindow?: number; + /** Each count is absent when acpmux did not send it: unknown, never a synthesized zero. */ + compactions24h?: number; + subagentsLive?: number; + subagents1h?: number; + /** Subagents spawned in the last 24 hours. */ + subagents24h?: number; + /** Input, output, cache read and cache write tokens of the last 24 hours. */ + tokens24h?: number; + costUsd24h?: number; + updatedAt: number; +}; + +const count = (value: unknown) => + typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; +const label = (value: unknown) => (typeof value === "string" && value ? value : undefined); + +/** acpmux's `usageStats`, or undefined when it sent none or sent something else. */ +export function usageStatsOf(value: unknown): SessionUsageStats | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + const raw = value as Record; + const updatedAt = count(raw.updatedAt); + if (updatedAt === undefined) return undefined; + const optional = (key: K, v: V | undefined) => + (v === undefined ? {} : { [key]: v }) as { [P in K]?: V }; + return { + ...optional("launcher", label(raw.launcher)), + autocompact: raw.autocompact === "capped" || raw.autocompact === "none" ? raw.autocompact : "unknown", + // A cap of 0 is no window: acpmux never sends one. + ...optional("autocompactCap", count(raw.autocompactCap) || undefined), + ...optional("contextTokens", count(raw.contextTokens)), + ...optional("contextWindow", count(raw.contextWindow) || undefined), + ...optional("compactions24h", count(raw.compactions24h)), + ...optional("subagentsLive", count(raw.subagentsLive)), + ...optional("subagents1h", count(raw.subagents1h)), + ...optional("subagents24h", count(raw.subagents24h)), + ...optional("tokens24h", count(raw.tokens24h)), + ...optional("costUsd24h", count(raw.costUsd24h)), + updatedAt, + }; +} + +/** `usageStatsOf(value)`, or `previous` when that reads the same: a snapshot per token then hands + * Usage the same object, so it draws nothing again until acpmux's numbers change. */ +export function sameUsageStats(value: unknown, previous: SessionUsageStats | undefined): SessionUsageStats | undefined { + const next = usageStatsOf(value); + if (!next || !previous) return next; + const keys = new Set([...Object.keys(next), ...Object.keys(previous)]) as Set; + for (const key of keys) if (next[key] !== previous[key]) return next; + return previous; +} + +/** "$1,240" from 1240.4, "$12.40", "$0.08": whole dollars from $100, cents below. */ +export function usageCost(usd: number, locale?: string): string { + return new Intl.NumberFormat(locale, { + style: "currency", + currency: "USD", + maximumFractionDigits: usd >= 100 ? 0 : 2, + minimumFractionDigits: usd >= 100 ? 0 : 2, + }).format(usd); +} + +/** What an unknown number shows: never a synthesized 0 or $0.00. */ +export const UNKNOWN = "—"; + +/** "312K", "1.2M": the compact token count the Context section uses. */ +export function usageTokens(tokens: number, locale?: string): string { + return new Intl.NumberFormat(locale, { notation: "compact", maximumFractionDigits: 1 }).format(tokens); +} + +/** How the Sessions by usage list orders its rows. */ +export type UsageSort = "cost" | "context"; + +/** The sessions with usage, highest first by `sort`; ties by the other measure, then by id so a + * refresh never reorders equal rows. Sessions without `usageStats` are left out. */ +export function byUsage( + sessions: readonly T[], + sort: UsageSort, +): T[] { + const cost = (entry: T) => entry.usageStats?.costUsd24h ?? -1; + const context = (entry: T) => entry.usageStats?.contextTokens ?? -1; + const [first, second] = sort === "cost" ? [cost, context] : [context, cost]; + return sessions + .filter((entry) => entry.usageStats) + .sort( + (a, b) => + first(b) - first(a) || + second(b) - second(a) || + (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0), + ); +} diff --git a/webviews/test/inspector-section-order.test.tsx b/webviews/test/inspector-section-order.test.tsx index 00db44170fc3..16ae8b8f75ea 100644 --- a/webviews/test/inspector-section-order.test.tsx +++ b/webviews/test/inspector-section-order.test.tsx @@ -114,7 +114,7 @@ const pointer = (type: string, clientY: number) => test("the default layout: three blocks and their sections, in order", async () => { const { container, root } = await mount(); expect(layout(container)).toEqual([ - { group: "about", sections: ["environment", "context", "connectors"] }, + { group: "about", sections: ["environment", "context", "usage", "connectors"] }, { group: "made", sections: ["changes", "sources", "outputs"] }, { group: "running", sections: ["subagents", "tasks"] }, ]); @@ -133,18 +133,18 @@ test("the default layout: three blocks and their sections, in order", async () = test("dragging a handle past a neighbour's middle moves the section there, and the order stays", async () => { const { container, root } = await mount(); - stack(container, { environment: 100, context: 28, connectors: 28 }); + stack(container, { environment: 100, context: 28, usage: 28, connectors: 28 }); const grip = handle(container, "environment"); await act(async () => grip.dispatchEvent(pointer("pointerdown", 10))); expect(container.querySelector('section[data-section="environment"]')!.hasAttribute("data-dragging")).toBe(true); // Past Context's middle (114) only. await act(async () => dom.window.dispatchEvent(pointer("pointermove", 120))); - expect(layout(container)[0]!.sections).toEqual(["context", "environment", "connectors"]); - // Past Connectors' middle too. - stack(container, { context: 28, environment: 100, connectors: 28 }); - await act(async () => dom.window.dispatchEvent(pointer("pointermove", 150))); - expect(layout(container)[0]!.sections).toEqual(["context", "connectors", "environment"]); - await act(async () => dom.window.dispatchEvent(pointer("pointerup", 150))); + expect(layout(container)[0]!.sections).toEqual(["context", "environment", "usage", "connectors"]); + // Past Usage's (142) and Connectors' (170) middles too. + stack(container, { context: 28, environment: 100, usage: 28, connectors: 28 }); + await act(async () => dom.window.dispatchEvent(pointer("pointermove", 180))); + expect(layout(container)[0]!.sections).toEqual(["context", "usage", "connectors", "environment"]); + await act(async () => dom.window.dispatchEvent(pointer("pointerup", 180))); expect(container.querySelector("[data-dragging]")).toBeNull(); // The other blocks never moved. expect(layout(container).slice(1)).toEqual([ @@ -156,7 +156,7 @@ test("dragging a handle past a neighbour's middle moves the section there, and t // A fresh page reads the saved order. resetSectionOrderForTests(); const again = await mount(); - expect(layout(again.container)[0]!.sections).toEqual(["context", "connectors", "environment"]); + expect(layout(again.container)[0]!.sections).toEqual(["context", "usage", "connectors", "environment"]); await act(async () => again.root.unmount()); }); @@ -226,8 +226,9 @@ test("a saved order with unknown or missing sections keeps every section once", ); const { container, root } = await mount(); expect(layout(container).map((block) => block.sections)).toEqual([ - // Connectors, missing, takes its place after Context, the section it follows by default. - ["context", "connectors", "environment"], + // Usage and Connectors, missing, take their places after the sections they follow by default: + // Usage after Context, Connectors after Usage. + ["context", "usage", "connectors", "environment"], ["changes", "sources", "outputs"], ["subagents", "tasks"], ]); diff --git a/webviews/test/session-usage.test.tsx b/webviews/test/session-usage.test.tsx new file mode 100644 index 000000000000..41da0f158ada --- /dev/null +++ b/webviews/test/session-usage.test.tsx @@ -0,0 +1,306 @@ +// l10n-allow-file: test fixture chat titles and hosts. +// Session Info's Usage (inspector/UsageSection.tsx): what a chat spent in the last 24 hours, from +// acpmux's `usageStats` on the session summary, and every session acpmux lists, this Mac's and its +// peers', sorted by 24h cost or by context. Leo, 2026-10-30: "that visibility into huge-blue guys +// was (and is?) pretty bad i feel like we can make more improvements to acpmux/cmux-next". +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { ChatInspector } from "../src/agent-session/acpmux/inspector/ChatInspector"; +import { DEFAULT_SECTIONS, resetInspectorState } from "../src/agent-session/acpmux/inspector/inspectorState"; +import { groupOrder } from "../src/agent-session/acpmux/inspector/sectionOrder"; +import { sessionEntry } from "../src/agent-session/acpmux/sessionList"; +import { byUsage, sameUsageStats, usageCost, usageStatsOf } from "../src/agent-session/acpmux/usageStats"; + +const scope = globalThis as Record; +const saved = new Map(); +let dom: JSDOM; +let root: Root | undefined; +let host: HTMLElement; + +beforeEach(() => { + dom = new JSDOM("
", { url: "http://localhost/", pretendToBeVisual: true }); + const replacements: Record = { + window: dom.window, + document: dom.window.document, + navigator: dom.window.navigator, + Node: dom.window.Node, + HTMLElement: dom.window.HTMLElement, + Element: dom.window.Element, + MutationObserver: dom.window.MutationObserver, + localStorage: dom.window.localStorage, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), + cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window), + IS_REACT_ACT_ENVIRONMENT: true, + }; + for (const key of Object.keys(replacements)) saved.set(key, scope[key]); + Object.assign(scope, replacements); + resetInspectorState(); + host = dom.window.document.getElementById("root")!; +}); + +afterEach(async () => { + if (root) await act(async () => root!.unmount()); + root = undefined; + resetInspectorState(); + dom.window.close(); + for (const [key, value] of saved) { + if (value === undefined) delete scope[key]; + else scope[key] = value; + } + saved.clear(); +}); + +async function render(node: ReactNode) { + await act(async () => { + root ??= createRoot(host); + root.render(node); + }); +} + +async function click(element: Element | null | undefined) { + expect(element).toBeTruthy(); + await act(async () => { + element!.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); + }); +} + +const stats = (fields: Record) => ({ + launcher: "claude", + autocompact: "none", + autocompactCap: null, + contextTokens: null, + contextWindow: null, + compactions24h: 0, + subagentsLive: 0, + subagents1h: 0, + subagents24h: 0, + tokens24h: 0, + costUsd24h: null, + updatedAt: 1_761_800_000_000, + ...fields, +}); + +/// The build box deputies the usage audit found, beside a quiet chat on this Mac. +const SESSIONS = [ + sessionEntry({ + sessionId: "this-chat", + name: "acpmux-owner", + harness: "claude", + usageStats: stats({ costUsd24h: 12.4, contextTokens: 80_000, tokens24h: 4_000_000 }), + }), + sessionEntry({ + sessionId: "dep-fleet", + name: "build-box/dep-fleet", + peer: "build-box", + harness: "claude", + usageStats: stats({ costUsd24h: 1_420.5, contextTokens: 290_000, subagents24h: 67 }), + }), + sessionEntry({ + sessionId: "dep-land", + name: "build-box/dep-land", + peer: "build-box", + harness: "claude", + usageStats: stats({ costUsd24h: 1_100, contextTokens: 330_000, subagents24h: 90 }), + }), + // No readable transcript yet: never listed by usage. + sessionEntry({ sessionId: "fresh", name: "fresh", harness: "codex" }), +]; + +test("usageStatsOf reads acpmux's usageStats; null numbers are unknown, and only autocompact none is no cap", () => { + const parsed = usageStatsOf( + stats({ autocompactCap: null, costUsd24h: 3.5, contextTokens: 310_000, subagents24h: 90 }), + ); + expect(parsed).toEqual({ + launcher: "claude", + autocompact: "none", + compactions24h: 0, + subagentsLive: 0, + subagents1h: 0, + subagents24h: 90, + tokens24h: 0, + contextTokens: 310_000, + costUsd24h: 3.5, + updatedAt: 1_761_800_000_000, + }); + expect(parsed?.autocompactCap).toBeUndefined(); + expect(usageStatsOf(stats({ autocompactCap: 200_000 }))?.autocompactCap).toBe(200_000); + expect(usageStatsOf(null)).toBeUndefined(); + expect(usageStatsOf({ tokens24h: 5 })).toBeUndefined(); + // Null and nonsense counts stay unknown: never a synthesized 0. + const unread = usageStatsOf(stats({ tokens24h: null, compactions24h: -1, subagents24h: null })); + expect(unread?.tokens24h).toBeUndefined(); + expect(unread?.compactions24h).toBeUndefined(); + expect(unread?.subagents24h).toBeUndefined(); + expect(usageStatsOf(stats({ autocompact: undefined }))?.autocompact).toBe("unknown"); + expect(usageStatsOf(stats({ autocompact: "capped", autocompactCap: 200_000 }))?.autocompact).toBe("capped"); + expect(usageStatsOf(stats({ costUsd24h: "12" }))?.costUsd24h).toBeUndefined(); + // The session list carries it on every entry, a peer's included. + expect(SESSIONS[1]!.usageStats?.costUsd24h).toBe(1_420.5); + expect(SESSIONS[3]!.usageStats).toBeUndefined(); +}); + +test("sameUsageStats keeps the previous object while acpmux's numbers are the same, so a snapshot per token redraws no Usage row", () => { + const first = sameUsageStats(stats({ costUsd24h: 3.5 }), undefined); + // A new raw object from the next session_changed, the same numbers: the same decoded object. + expect(sameUsageStats(stats({ costUsd24h: 3.5 }), first)).toBe(first); + const moved = sameUsageStats(stats({ costUsd24h: 3.6 }), first); + expect(moved).not.toBe(first); + expect(moved?.costUsd24h).toBe(3.6); + expect(sameUsageStats(null, first)).toBeUndefined(); +}); + +test("byUsage ranks by 24h cost or by context, highest first, and leaves out sessions with no usage", () => { + expect(byUsage(SESSIONS, "cost").map((entry) => entry.sessionId)).toEqual(["dep-fleet", "dep-land", "this-chat"]); + expect(byUsage(SESSIONS, "context").map((entry) => entry.sessionId)).toEqual(["dep-land", "dep-fleet", "this-chat"]); + expect(usageCost(1_420.5, "en")).toBe("$1,421"); + expect(usageCost(12.4, "en")).toBe("$12.40"); +}); + +test("Usage is a folded row in the top block, after Context, valued at this chat's 24h cost", async () => { + expect(groupOrder("about", {})).toEqual(["environment", "context", "usage", "connectors"]); + expect(DEFAULT_SECTIONS.usage).toBe(false); + await render( + , + ); + const usage = host.querySelector('section[data-section="usage"]'); + expect(usage?.querySelector(".acpmux-chat-inspector-section__title")?.textContent).toBe("Usage"); + expect(usage?.querySelector(".acpmux-chat-inspector-section__meta")?.textContent).toBe("$12.40 · 24h"); + expect(usage?.querySelector(".acpmux-chat-inspector-usage")).toBeNull(); +}); + +test("open, Usage lists this chat's numbers, then every host's sessions by 24h cost or context; a row opens that chat", async () => { + const opened: string[] = []; + await render( + opened.push(id)} + onCompact={() => opened.push("compact")} + />, + ); + const usage = host.querySelector(".acpmux-chat-inspector-usage")!; + const own = [...usage.querySelectorAll("li[data-usage]")].map((row) => [ + row.dataset.usage, + row.querySelector(".acpmux-chat-inspector-row__end")?.textContent, + ]); + expect(own).toEqual([ + ["cost", "$12.40"], + ["tokens", "4M"], + // acpmux sent no context for it: unknown, never 0. + ["context", "—"], + ["cap", "No cap"], + ["compactions", "0"], + ["subagents", "2 live · 5 in 1h · 14 in 24h"], + ["launcher", "claude"], + ]); + // Each number has its next step: Compact for the context, Show for the subagents (no Subagents + // section here, so none), and Restart with cap for no cap, disabled until acpmux can do it. + const actions = (id: string) => + [...usage.querySelectorAll(`li[data-usage="${id}"] button`)].map((button) => [ + button.textContent, + button.disabled, + ]); + expect(actions("context")).toEqual([["Compact", false]]); + expect(actions("cap")).toEqual([["Restart with cap", true]]); + expect(actions("subagents")).toEqual([]); + await click(usage.querySelector('li[data-usage="context"] button')); + expect(opened).toEqual(["compact"]); + opened.length = 0; + const listed = () => + [...usage.querySelectorAll("ul[data-sort] > li")].map((row) => [ + row.querySelector(".acpmux-chat-inspector-row__name")?.textContent, + row.querySelector(".acpmux-chat-inspector-row__detail")?.textContent ?? null, + row.querySelector(".acpmux-chat-inspector-row__end")?.textContent, + ]); + expect(listed()).toEqual([ + ["dep-fleet", "build-box", "$1,421"], + ["dep-land", "build-box", "$1,100"], + ["acpmux-owner", null, "$12.40"], + ]); + const sortBy = (label: string) => + [...usage.querySelectorAll(".acpmux-chat-inspector-usage__sort-option")].find( + (option) => option.textContent === label, + ); + expect(sortBy("24h cost")?.getAttribute("aria-pressed")).toBe("true"); + // The pressed option is the order shown, so it takes no click (inspector-dead-clicks.test.tsx). + expect([sortBy("24h cost")?.disabled, sortBy("Context")?.disabled]).toEqual([true, false]); + await click(sortBy("Context")); + expect(sortBy("Context")?.getAttribute("aria-pressed")).toBe("true"); + expect([sortBy("24h cost")?.disabled, sortBy("Context")?.disabled]).toEqual([false, true]); + expect(listed()).toEqual([ + ["dep-land", "build-box", "330K"], + ["dep-fleet", "build-box", "290K"], + ["acpmux-owner", null, "80K"], + ]); + // A peer's row opens its chat; this chat's own row is plain text (no dead click, nothing to open). + const rows = [...usage.querySelectorAll("ul[data-sort] > li")]; + await click(rows[0]!.querySelector("button")); + expect(opened).toEqual(["dep-land"]); + expect(rows[2]!.querySelector("button")).toBeNull(); +}); + +test("unknown numbers show an em dash, never 0 or $0.00, and an unread cap offers no restart", async () => { + await render( + , + ); + const values = Object.fromEntries( + [...host.querySelectorAll("li[data-usage]")].map((row) => [ + row.dataset.usage, + row.querySelector(".acpmux-chat-inspector-row__end")?.textContent, + ]), + ); + expect(values).toEqual({ cost: "—", tokens: "—", context: "—", cap: "—", compactions: "—", subagents: "—" }); + expect(host.querySelectorAll("li[data-usage] button").length).toBe(0); + // Folded, the section names no value rather than a made-up one. + expect(host.querySelector('section[data-section="usage"] .acpmux-chat-inspector-section__meta')).toBeNull(); +}); + +test("a chat with no usage of its own still shows Usage while another session reports some; none at all shows none", async () => { + await render(); + const usage = host.querySelector('section[data-section="usage"]'); + expect(usage).not.toBeNull(); + expect(usage?.querySelector(".acpmux-chat-inspector-section__meta")).toBeNull(); + await render(); + expect(host.querySelector('section[data-section="usage"]')).toBeNull(); +}); + +test("more than five sessions fold behind View all", async () => { + const many = Array.from({ length: 7 }, (_, at) => + sessionEntry({ sessionId: `s${at}`, name: `lane-${at}`, usageStats: stats({ costUsd24h: at }) }), + ); + await render( + , + ); + const rows = () => host.querySelectorAll("ul[data-sort] > li").length; + expect(rows()).toBe(5); + const fold = host.querySelector(".acpmux-chat-inspector-usage-fold"); + expect(fold?.textContent).toBe("View all 7"); + await click(fold); + expect(rows()).toBe(7); +});