mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +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 result = try await sendRequest(method: "session/prompt", params: params)
|
||||||
let dict = result?.dictValue ?? [:]
|
let dict = result?.dictValue ?? [:]
|
||||||
let usage = dict["usage"] as? [String: Any] ?? [:]
|
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"
|
statusMessage = "Ready"
|
||||||
return ACPPromptResult(
|
return ACPPromptResult(
|
||||||
@@ -318,7 +326,8 @@ public actor ACPClient {
|
|||||||
inputTokens: usage["inputTokens"] as? Int ?? 0,
|
inputTokens: usage["inputTokens"] as? Int ?? 0,
|
||||||
outputTokens: usage["outputTokens"] as? Int ?? 0,
|
outputTokens: usage["outputTokens"] as? Int ?? 0,
|
||||||
thoughtTokens: usage["thoughtTokens"] 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 outputTokens: Int
|
||||||
public let thoughtTokens: Int
|
public let thoughtTokens: Int
|
||||||
public let cachedReadTokens: 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(
|
public init(
|
||||||
stopReason: String,
|
stopReason: String,
|
||||||
inputTokens: Int,
|
inputTokens: Int,
|
||||||
outputTokens: Int,
|
outputTokens: Int,
|
||||||
thoughtTokens: Int,
|
thoughtTokens: Int,
|
||||||
cachedReadTokens: Int
|
cachedReadTokens: Int,
|
||||||
|
compressionCount: Int = 0
|
||||||
) {
|
) {
|
||||||
self.stopReason = stopReason
|
self.stopReason = stopReason
|
||||||
self.inputTokens = inputTokens
|
self.inputTokens = inputTokens
|
||||||
self.outputTokens = outputTokens
|
self.outputTokens = outputTokens
|
||||||
self.thoughtTokens = thoughtTokens
|
self.thoughtTokens = thoughtTokens
|
||||||
self.cachedReadTokens = cachedReadTokens
|
self.cachedReadTokens = cachedReadTokens
|
||||||
|
self.compressionCount = compressionCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -229,6 +229,12 @@ public final class RichChatViewModel {
|
|||||||
public private(set) var acpOutputTokens = 0
|
public private(set) var acpOutputTokens = 0
|
||||||
public private(set) var acpThoughtTokens = 0
|
public private(set) var acpThoughtTokens = 0
|
||||||
public private(set) var acpCachedReadTokens = 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`.
|
/// Slash commands advertised by the ACP server via `available_commands_update`.
|
||||||
public private(set) var acpCommands: [HermesSlashCommand] = []
|
public private(set) var acpCommands: [HermesSlashCommand] = []
|
||||||
@@ -468,6 +474,7 @@ public final class RichChatViewModel {
|
|||||||
acpErrorHint = nil
|
acpErrorHint = nil
|
||||||
acpErrorDetails = nil
|
acpErrorDetails = nil
|
||||||
acpCachedReadTokens = 0
|
acpCachedReadTokens = 0
|
||||||
|
acpCompressionCount = 0
|
||||||
acpCommands = []
|
acpCommands = []
|
||||||
projectScopedCommands = []
|
projectScopedCommands = []
|
||||||
currentTurnStart = nil
|
currentTurnStart = nil
|
||||||
@@ -811,6 +818,13 @@ public final class RichChatViewModel {
|
|||||||
acpOutputTokens += response.outputTokens
|
acpOutputTokens += response.outputTokens
|
||||||
acpThoughtTokens += response.thoughtTokens
|
acpThoughtTokens += response.thoughtTokens
|
||||||
acpCachedReadTokens += response.cachedReadTokens
|
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
|
isAgentWorking = false
|
||||||
buildMessageGroups()
|
buildMessageGroups()
|
||||||
// Final position after the prompt settles. Catches fast responses
|
// Final position after the prompt settles. Catches fast responses
|
||||||
|
|||||||
@@ -162,6 +162,47 @@ import Foundation
|
|||||||
// start → false.
|
// start → false.
|
||||||
#expect(vm.supportsCompress == false)
|
#expect(vm.supportsCompress == false)
|
||||||
#expect(vm.hasBroaderCommandMenu == 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() {
|
@Test @MainActor func messageGroupDerivedProperties() {
|
||||||
|
|||||||
@@ -242,6 +242,15 @@ import Foundation
|
|||||||
thoughtTokens: 20, cachedReadTokens: 10
|
thoughtTokens: 20, cachedReadTokens: 10
|
||||||
)
|
)
|
||||||
#expect(prompt.stopReason == "end_turn")
|
#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() {
|
@Test func projectDashboardInitChain() {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ struct ChatTranscriptPane: View {
|
|||||||
@Bindable var chatViewModel: ChatViewModel
|
@Bindable var chatViewModel: ChatViewModel
|
||||||
var onSend: (String, [ChatImageAttachment]) -> Void
|
var onSend: (String, [ChatImageAttachment]) -> Void
|
||||||
var isEnabled: Bool
|
var isEnabled: Bool
|
||||||
|
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -20,8 +21,10 @@ struct ChatTranscriptPane: View {
|
|||||||
acpInputTokens: richChat.acpInputTokens,
|
acpInputTokens: richChat.acpInputTokens,
|
||||||
acpOutputTokens: richChat.acpOutputTokens,
|
acpOutputTokens: richChat.acpOutputTokens,
|
||||||
acpThoughtTokens: richChat.acpThoughtTokens,
|
acpThoughtTokens: richChat.acpThoughtTokens,
|
||||||
|
acpCompressionCount: richChat.acpCompressionCount,
|
||||||
projectName: chatViewModel.currentProjectName,
|
projectName: chatViewModel.currentProjectName,
|
||||||
gitBranch: chatViewModel.currentGitBranch
|
gitBranch: chatViewModel.currentGitBranch,
|
||||||
|
capabilities: capabilitiesStore?.capabilities ?? .empty
|
||||||
)
|
)
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ struct SessionInfoBar: View {
|
|||||||
var acpInputTokens: Int = 0
|
var acpInputTokens: Int = 0
|
||||||
var acpOutputTokens: Int = 0
|
var acpOutputTokens: Int = 0
|
||||||
var acpThoughtTokens: 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
|
/// Name of the Scarf project this session is attributed to, when
|
||||||
/// applicable. Nil for plain global chats. Drives the folder-chip
|
/// applicable. Nil for plain global chats. Drives the folder-chip
|
||||||
/// indicator rendered before the session title. Resolved by
|
/// 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
|
/// name. Nil for non-project chats and for projects that aren't
|
||||||
/// git repos.
|
/// git repos.
|
||||||
var gitBranch: String? = nil
|
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
|
/// Active Hermes profile name (issue #50). Resolved on each body
|
||||||
/// re-evaluation; the resolver caches for 5s so this is cheap.
|
/// re-evaluation; the resolver caches for 5s so this is cheap.
|
||||||
@@ -96,6 +106,21 @@ struct SessionInfoBar: View {
|
|||||||
Label("\(formatTokens(reasonToks)) reasoning", systemImage: "brain")
|
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 {
|
if let cost = session.displayCostUSD {
|
||||||
let formattedCost = cost.formatted(.currency(code: "USD").precision(.fractionLength(4)))
|
let formattedCost = cost.formatted(.currency(code: "USD").precision(.fractionLength(4)))
|
||||||
Label(session.costIsActual ? formattedCost : "\(formattedCost) est.", systemImage: "dollarsign.circle")
|
Label(session.costIsActual ? formattedCost : "\(formattedCost) est.", systemImage: "dollarsign.circle")
|
||||||
|
|||||||
@@ -87,7 +87,16 @@ private struct SlashCommandRow: View {
|
|||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
.foregroundStyle(isSelected ? ScarfColor.accentActive : ScarfColor.foregroundPrimary)
|
.foregroundStyle(isSelected ? ScarfColor.accentActive : ScarfColor.foregroundPrimary)
|
||||||
if let hint = command.argumentHint {
|
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)
|
.font(ScarfFont.monoSmall)
|
||||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user