mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
feat(chat): surface v0.13 compression count + bracket-aware slash hint (WS-8)
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 `<hint>`. 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
// <name>` argument exposed by `hasNewWithSessionName`).
|
||||
// Avoid double-wrapping — bracketed hints pass through
|
||||
// verbatim while older `guidance`-style hints (no
|
||||
// brackets) still render as `<guidance>`.
|
||||
let display = hint.hasPrefix("<") || hint.hasPrefix("[")
|
||||
? hint
|
||||
: "<\(hint)>"
|
||||
Text(display)
|
||||
.font(ScarfFont.monoSmall)
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user