From f19f19cd56a5b9ac85c8abd51c1e318f6141b271 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Sat, 9 May 2026 18:58:58 +0200 Subject: [PATCH] feat(chat): surface v0.13 compression count + bracket-aware slash hint (WS-8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small chat-surface additions tracking Hermes v0.13: - Plumb a `compressionCount` field through `ACPPromptResult` and `RichChatViewModel.acpCompressionCount` so `SessionInfoBar` can render a `🗜 ×N` chip next to the token counter when the agent has performed context compactions. Capability-gated on `HermesCapabilities.hasContextCompressionCount` and `count > 0` so pre-v0.13 hosts (which always emit 0) and fresh sessions never see the chip. Wire decode tolerates camelCase + snake_case; `TODO(WS-8-Q1)` flags the assumption that the field rides on `usage` — if v0.13 emits via a separate `session/update` notification the bigger fix is described in the WS-8 plan. - Slash-menu argument hint is now bracket-aware: hints starting with `<` or `[` pass through verbatim, others wrap as ``. v0.13's `/new [name]` ships through unchanged without rendering as `<[name]>`. No flag check at the renderer — agent payload is the source of truth. Coordination with WS-2: both WSes touch `SessionInfoBar`. WS-2 owns the queue chip on the left half; this WS owns the compression chip on the right half. The added `capabilities` parameter is shared — kept additive so WS-2's later merge produces no file-level conflict. Tests: extends `M0dViewModelsTests` (compression count tracking + reset semantics) and `ScarfCoreSmokeTests` (decode default + explicit v0.13 init path). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Sources/ScarfCore/ACP/ACPClient.swift | 11 ++++- .../ScarfCore/Models/ACPMessages.swift | 15 ++++++- .../ViewModels/RichChatViewModel.swift | 14 +++++++ .../ScarfCoreTests/M0dViewModelsTests.swift | 41 +++++++++++++++++++ .../ScarfCoreTests/ScarfCoreSmokeTests.swift | 9 ++++ .../Chat/Views/ChatTranscriptPane.swift | 5 ++- .../Features/Chat/Views/SessionInfoBar.swift | 25 +++++++++++ .../Chat/Views/SlashCommandMenu.swift | 11 ++++- 8 files changed, 127 insertions(+), 4 deletions(-) diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ACPClient.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ACPClient.swift index 7278fbf..b096efd 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ACPClient.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ACPClient.swift @@ -311,6 +311,14 @@ public actor ACPClient { let result = try await sendRequest(method: "session/prompt", params: params) let dict = result?.dictValue ?? [:] let usage = dict["usage"] as? [String: Any] ?? [:] + // TODO(WS-8-Q1): Confirm wire field name once v0.13 Hermes is + // available. We tolerate camelCase + snake_case to match the rest + // of the ACP payload's mixed conventions; if Hermes routes the + // count through a `session/update` notification instead, this + // decode is a no-op and the ACPEvent path takes over. + let compression = (usage["compressionCount"] as? Int) + ?? (usage["compression_count"] as? Int) + ?? 0 statusMessage = "Ready" return ACPPromptResult( @@ -318,7 +326,8 @@ public actor ACPClient { inputTokens: usage["inputTokens"] as? Int ?? 0, outputTokens: usage["outputTokens"] as? Int ?? 0, thoughtTokens: usage["thoughtTokens"] as? Int ?? 0, - cachedReadTokens: usage["cachedReadTokens"] as? Int ?? 0 + cachedReadTokens: usage["cachedReadTokens"] as? Int ?? 0, + compressionCount: compression ) } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ACPMessages.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ACPMessages.swift index 8bd602f..54ccbb4 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ACPMessages.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ACPMessages.swift @@ -243,19 +243,32 @@ public struct ACPPromptResult: Sendable { public let outputTokens: Int public let thoughtTokens: Int public let cachedReadTokens: Int + /// Number of automatic context compactions Hermes has performed on this + /// session so far. v0.13+ — older Hermes hosts always return 0, which + /// the chat status bar treats as "hide chip". Optional in the wire + /// payload; folded into a non-optional `Int` here with a 0 default so + /// the rest of the pipeline doesn't need to nil-check. + // TODO(WS-8-Q1): Verify that v0.13 Hermes emits the count on + // `session/prompt`'s `usage` blob (assumed here). If it lands on a + // separate `session/update` notification instead, this becomes a new + // ACPEvent case + a branch in RichChatViewModel.handleACPEvent — wire + // shape is documented in the WS-8 plan as the bigger fix path. + public let compressionCount: Int public init( stopReason: String, inputTokens: Int, outputTokens: Int, thoughtTokens: Int, - cachedReadTokens: Int + cachedReadTokens: Int, + compressionCount: Int = 0 ) { self.stopReason = stopReason self.inputTokens = inputTokens self.outputTokens = outputTokens self.thoughtTokens = thoughtTokens self.cachedReadTokens = cachedReadTokens + self.compressionCount = compressionCount } } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift index 5debd36..4742902 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift @@ -229,6 +229,12 @@ public final class RichChatViewModel { public private(set) var acpOutputTokens = 0 public private(set) var acpThoughtTokens = 0 public private(set) var acpCachedReadTokens = 0 + /// Running count of context compactions Hermes has performed on this + /// session. Surfaced as the `🗜 ×N` chip in `SessionInfoBar` when > 0 + /// and `HermesCapabilities.hasContextCompressionCount` is true. Each + /// `session/prompt` response carries the latest server-side total, so + /// we replace (with a `max` guard) rather than accumulate. + public private(set) var acpCompressionCount = 0 /// Slash commands advertised by the ACP server via `available_commands_update`. public private(set) var acpCommands: [HermesSlashCommand] = [] @@ -468,6 +474,7 @@ public final class RichChatViewModel { acpErrorHint = nil acpErrorDetails = nil acpCachedReadTokens = 0 + acpCompressionCount = 0 acpCommands = [] projectScopedCommands = [] currentTurnStart = nil @@ -811,6 +818,13 @@ public final class RichChatViewModel { acpOutputTokens += response.outputTokens acpThoughtTokens += response.thoughtTokens acpCachedReadTokens += response.cachedReadTokens + // Compression count is a session-wide running total emitted by + // Hermes; each prompt response carries the latest value, so we + // replace rather than accumulate. The `max` guard tolerates + // pre-v0.13 hosts (which emit 0) being upgraded server-side + // mid-session — once a real number lands the count resumes from + // there rather than snapping back to 0. + acpCompressionCount = max(acpCompressionCount, response.compressionCount) isAgentWorking = false buildMessageGroups() // Final position after the prompt settles. Catches fast responses diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0dViewModelsTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0dViewModelsTests.swift index 3648370..4a08304 100644 --- a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0dViewModelsTests.swift +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0dViewModelsTests.swift @@ -162,6 +162,47 @@ import Foundation // start → false. #expect(vm.supportsCompress == false) #expect(vm.hasBroaderCommandMenu == false) + // v0.13: compression count starts at 0 so the SessionInfoBar chip + // stays hidden on fresh sessions. + #expect(vm.acpCompressionCount == 0) + } + + @Test @MainActor func richChatTracksCompressionCountFromPromptResults() { + let vm = RichChatViewModel(context: .local) + let response = ACPPromptResult( + stopReason: "end_turn", + inputTokens: 100, outputTokens: 50, + thoughtTokens: 20, cachedReadTokens: 10, + compressionCount: 3 + ) + vm.handleACPEvent(.promptComplete(sessionId: "s", response: response)) + #expect(vm.acpCompressionCount == 3) + + // Subsequent prompts overwrite (with a max guard) — the server + // emits a session-wide running total, not a per-prompt delta. + let next = ACPPromptResult( + stopReason: "end_turn", + inputTokens: 0, outputTokens: 0, + thoughtTokens: 0, cachedReadTokens: 0, + compressionCount: 5 + ) + vm.handleACPEvent(.promptComplete(sessionId: "s", response: next)) + #expect(vm.acpCompressionCount == 5) + + // A pre-v0.13 host mid-session emits 0; the max-guard keeps the + // last real value rather than snapping back. + let stale = ACPPromptResult( + stopReason: "end_turn", + inputTokens: 0, outputTokens: 0, + thoughtTokens: 0, cachedReadTokens: 0, + compressionCount: 0 + ) + vm.handleACPEvent(.promptComplete(sessionId: "s", response: stale)) + #expect(vm.acpCompressionCount == 5) + + // reset() clears the counter so a fresh session starts clean. + vm.reset() + #expect(vm.acpCompressionCount == 0) } @Test @MainActor func messageGroupDerivedProperties() { diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/ScarfCoreSmokeTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/ScarfCoreSmokeTests.swift index d943eba..d2de257 100644 --- a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/ScarfCoreSmokeTests.swift +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/ScarfCoreSmokeTests.swift @@ -242,6 +242,15 @@ import Foundation thoughtTokens: 20, cachedReadTokens: 10 ) #expect(prompt.stopReason == "end_turn") + // v0.13: compressionCount has a 0 default for legacy callers. + #expect(prompt.compressionCount == 0) + + let v013Prompt = ACPPromptResult( + stopReason: "end_turn", inputTokens: 0, outputTokens: 0, + thoughtTokens: 0, cachedReadTokens: 0, + compressionCount: 7 + ) + #expect(v013Prompt.compressionCount == 7) } @Test func projectDashboardInitChain() { diff --git a/scarf/scarf/Features/Chat/Views/ChatTranscriptPane.swift b/scarf/scarf/Features/Chat/Views/ChatTranscriptPane.swift index eaa8294..eb20766 100644 --- a/scarf/scarf/Features/Chat/Views/ChatTranscriptPane.swift +++ b/scarf/scarf/Features/Chat/Views/ChatTranscriptPane.swift @@ -11,6 +11,7 @@ struct ChatTranscriptPane: View { @Bindable var chatViewModel: ChatViewModel var onSend: (String, [ChatImageAttachment]) -> Void var isEnabled: Bool + @Environment(\.hermesCapabilities) private var capabilitiesStore var body: some View { VStack(spacing: 0) { @@ -20,8 +21,10 @@ struct ChatTranscriptPane: View { acpInputTokens: richChat.acpInputTokens, acpOutputTokens: richChat.acpOutputTokens, acpThoughtTokens: richChat.acpThoughtTokens, + acpCompressionCount: richChat.acpCompressionCount, projectName: chatViewModel.currentProjectName, - gitBranch: chatViewModel.currentGitBranch + gitBranch: chatViewModel.currentGitBranch, + capabilities: capabilitiesStore?.capabilities ?? .empty ) Divider() diff --git a/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift b/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift index 33a2d5e..7b83c79 100644 --- a/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift +++ b/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift @@ -9,6 +9,11 @@ struct SessionInfoBar: View { var acpInputTokens: Int = 0 var acpOutputTokens: Int = 0 var acpThoughtTokens: Int = 0 + /// Number of context compactions Hermes has run on this session. v0.13+ + /// surface — capability-gated by the bar so pre-v0.13 hosts never see + /// the chip even if a stale value somehow trickles through. Defaults + /// to 0 so existing callers and previews don't need to be updated. + var acpCompressionCount: Int = 0 /// Name of the Scarf project this session is attributed to, when /// applicable. Nil for plain global chats. Drives the folder-chip /// indicator rendered before the session title. Resolved by @@ -20,6 +25,11 @@ struct SessionInfoBar: View { /// name. Nil for non-project chats and for projects that aren't /// git repos. var gitBranch: String? = nil + /// Capability snapshot for v0.13+ surfaces. Defaulted so previews and + /// pre-v0.13 hosts render the v2.7.5 layout unchanged. Coordinated + /// with WS-2 — both WSes add `capabilities` to this view; whichever + /// lands first establishes the prop. + var capabilities: HermesCapabilities = .empty /// Active Hermes profile name (issue #50). Resolved on each body /// re-evaluation; the resolver caches for 5s so this is cheap. @@ -96,6 +106,21 @@ struct SessionInfoBar: View { Label("\(formatTokens(reasonToks)) reasoning", systemImage: "brain") } + // v0.13: Hermes surfaces a running count of automatic + // context compactions. Render only when the host is on + // v0.13+ AND the count is non-zero, so a pre-v0.13 host + // (which always reports 0) sees no chip, and a v0.13 host + // sees the chip the first time the agent compacts. + if capabilities.hasContextCompressionCount && acpCompressionCount > 0 { + Label( + "×\(acpCompressionCount)", + systemImage: "arrow.down.right.and.arrow.up.left" + ) + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundMuted) + .help("Hermes auto-compacted this session's context \(acpCompressionCount) time\(acpCompressionCount == 1 ? "" : "s")") + } + if let cost = session.displayCostUSD { let formattedCost = cost.formatted(.currency(code: "USD").precision(.fractionLength(4))) Label(session.costIsActual ? formattedCost : "\(formattedCost) est.", systemImage: "dollarsign.circle") diff --git a/scarf/scarf/Features/Chat/Views/SlashCommandMenu.swift b/scarf/scarf/Features/Chat/Views/SlashCommandMenu.swift index 22eb33f..ea6fc8c 100644 --- a/scarf/scarf/Features/Chat/Views/SlashCommandMenu.swift +++ b/scarf/scarf/Features/Chat/Views/SlashCommandMenu.swift @@ -87,7 +87,16 @@ private struct SlashCommandRow: View { .fontWeight(.semibold) .foregroundStyle(isSelected ? ScarfColor.accentActive : ScarfColor.foregroundPrimary) if let hint = command.argumentHint { - Text("<\(hint)>") + // v0.13: Hermes may emit hints already wrapped in + // brackets (e.g. `[name]` for the optional `/new + // ` argument exposed by `hasNewWithSessionName`). + // Avoid double-wrapping — bracketed hints pass through + // verbatim while older `guidance`-style hints (no + // brackets) still render as ``. + let display = hint.hasPrefix("<") || hint.hasPrefix("[") + ? hint + : "<\(hint)>" + Text(display) .font(ScarfFont.monoSmall) .foregroundStyle(ScarfColor.foregroundFaint) }