feat(chat): density preferences for tool cards, reasoning, font (#47, #48)

Three Scarf-local @AppStorage-backed preferences in
Settings → Display → Chat density. All defaults match today's UI;
existing users see no change until they opt in.

- Tool calls: Full card (today) / Compact chip / Hidden
  - Compact: one-line tappable chip per call (icon + name + status
    dot). Tap focuses the call so the right-pane inspector opens
    with full args + result, same as today's inline expand.
  - Hidden: per-call rows skipped entirely. The MessageGroupView
    toolSummary pill ("Used 5 tools (3 read, 2 edit)") becomes
    the only chrome AND becomes tappable — clicking focuses the
    first call so per-call duration / exit code remain reachable
    via the inspector. Pill is now shown for any call count > 0
    in hidden mode (was > 1) so the inspector path is always
    available. Issue #47.
- Reasoning: Disclosure box (today) / Inline (italic) / Hidden
  - Inline: italic foregroundFaint caption inline above the reply
    with a 9pt brain prefix. No box, no border. Same data, far
    less vertical space.
  - Hidden: reasoning text not rendered. Per-message tokenCount
    (which the disclosure label was duplicating) stays in the
    metadataFooter so token telemetry isn't lost. Issue #48.
- Chat font size: 85%–130% slider (5% step) applied via
  .environment(\.dynamicTypeSize, ...) on RichChatView's root,
  scaling message list / input bar / session info bar / inspector
  pane together. Reset button restores 100%. Issue #48.

Telemetry preservation (the user-stated constraint):
- Per-turn stopwatch, per-message tokenCount, finish reason, and
  message timestamp remain in the bubble metadataFooter in every
  mode.
- SessionInfoBar input/output/reasoning tokens, cost USD, model,
  project, git branch, and started-at relative time are unchanged
  by every density setting.
- Per-call duration + exit code stay reachable via the inspector
  pane in compact and hidden modes.

Out of scope (called out in the plan):
- Context-fill widget — Hermes v0.11 doesn't expose context_used
  / context_total per session. Approximating from messages.tokenCount
  + a static window table would be wrong-on-purpose; defer until
  Hermes ships the canonical field.
- iOS — ScarfGo already renders both surfaces compactly. Both
  issues reference Mac.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-27 12:37:33 +02:00
parent 558970a09a
commit 051f3bf80c
5 changed files with 409 additions and 9 deletions
@@ -0,0 +1,102 @@
import SwiftUI
/// Scarf-local chat rendering preferences (issues #47 / #48).
///
/// **Scope vs. Hermes config.** These three keys control how Scarf
/// *renders* the chat transcript on screen they do not affect what
/// Hermes emits over ACP. The companion Hermes flags (`display.compact`,
/// `showReasoning`, `showCost`) live on the Settings Display tab's
/// "Output" section and gate emission. Two separate concerns; both can
/// be on at once.
///
/// **Defaults match today's UI exactly.** Existing users see no change
/// until they opt in via Settings Display Chat density.
enum ChatDensityKeys {
static let toolCardStyle = "scarf.chat.toolCardStyle"
static let reasoningStyle = "scarf.chat.reasoningStyle"
static let fontScale = "scarf.chat.fontScale"
}
/// How `RichMessageBubble` renders the per-call tool widgets.
enum ToolCardStyle: String, CaseIterable, Identifiable {
/// Today's behavior: full expandable card per call with arguments
/// preview and inline result.
case full
/// Single-line chip per call (icon + name + status dot). Tap opens
/// the right-pane inspector with the same details the inline expand
/// shows. Saves significant vertical space when the assistant
/// chains many tool calls.
case compact
/// No per-call rows. The `MessageGroupView.toolSummary` pill stays
/// visible (showing aggregate counts) and is tappable clicking it
/// opens the inspector on the first call so per-call telemetry
/// (duration, exit code) remains reachable.
case hidden
var id: String { rawValue }
var displayName: String {
switch self {
case .full: return "Full card"
case .compact: return "Compact chip"
case .hidden: return "Hidden"
}
}
}
/// How `RichMessageBubble` renders the assistant's reasoning channel.
enum ReasoningStyle: String, CaseIterable, Identifiable {
/// Today's behavior: yellow tinted DisclosureGroup with a brain
/// icon, "REASONING" label, and reasoning-token chip in the label.
case disclosure
/// Italic foregroundFaint caption inline above the reply, with a
/// 9pt brain prefix. No box, no border, no toggle just the text.
/// Reasoning token count moves into the bubble's metadataFooter
/// (`· N reasoning tok`) so it isn't lost.
case inline
/// Reasoning is not rendered. Token count still appears in the
/// metadataFooter so user retains visibility into reasoning cost.
case hidden
var id: String { rawValue }
var displayName: String {
switch self {
case .disclosure: return "Disclosure box"
case .inline: return "Inline (italic)"
case .hidden: return "Hidden"
}
}
}
/// Convenience helpers for translating the user's chat font scale into
/// SwiftUI's `DynamicTypeSize`. Applied once at the `RichChatView` root
/// so all of message list / input bar / session info bar scale together.
enum ChatFontScale {
static let min: Double = 0.85
static let max: Double = 1.30
static let step: Double = 0.05
static let `default`: Double = 1.0
/// Map the slider value to the closest `DynamicTypeSize`. We avoid
/// the accessibility sizes deliberately the Mac chat layout has
/// fixed-width side panes and accessibility-XXL would push tool
/// chips into truncation. Users who need larger text should also
/// resize the window.
static func dynamicTypeSize(for scale: Double) -> DynamicTypeSize {
switch scale {
case ..<0.92: return .xSmall
case ..<1.00: return .small
case ..<1.08: return .medium
case ..<1.18: return .large
case ..<1.25: return .xLarge
default: return .xxLarge
}
}
/// Display percentage for the slider's value chip.
static func percentLabel(for scale: Double) -> String {
let pct = Int((scale * 100).rounded())
return "\(pct)%"
}
}
@@ -145,6 +145,16 @@ struct MessageGroupView: View, Equatable {
/// that haven't been updated yet still compile.
var turnDurations: [Int: TimeInterval] = [:]
@Environment(ChatViewModel.self) private var chatViewModel
/// Read here so the toolSummary pill knows whether to render as
/// always-visible (today's behavior) or as a tappable inspector
/// shortcut when per-call tool cards are hidden (issue #47).
@AppStorage(ChatDensityKeys.toolCardStyle)
private var toolCardStyleRaw: String = ToolCardStyle.full.rawValue
private var toolCardStyle: ToolCardStyle {
ToolCardStyle(rawValue: toolCardStyleRaw) ?? .full
}
/// Equatable short-circuit for SwiftUI: when the trailing group's
/// streaming bubble grows, only that group's `==` returns false.
/// All earlier groups skip body re-evaluation, dropping per-chunk
@@ -207,7 +217,16 @@ struct MessageGroupView: View, Equatable {
.equatable()
}
if group.toolCallCount > 1 {
// When per-call tool cards are visible, the summary pill
// is informational only. When tool cards are hidden
// (issue #47), this pill becomes the only chrome surfacing
// tool activity AND the only path back into the inspector
// pane render it on every group with calls (not just >1)
// and make it tappable to focus the first call.
let showSummary = (toolCardStyle == .hidden)
? group.toolCallCount > 0
: group.toolCallCount > 1
if showSummary {
toolSummary
}
}
@@ -217,16 +236,42 @@ struct MessageGroupView: View, Equatable {
private var toolSummary: some View {
let kinds = group.toolKindCounts
if !kinds.isEmpty {
let firstCallId = group.assistantMessages
.flatMap(\.toolCalls)
.first?.callId
let isInteractive = (toolCardStyle == .hidden) && firstCallId != nil
Group {
if isInteractive, let firstCallId {
Button {
chatViewModel.focusedToolCallId = firstCallId
} label: {
toolSummaryPill(kinds, interactive: true)
}
.buttonStyle(.plain)
.help("Click to inspect tool calls")
} else {
toolSummaryPill(kinds, interactive: false)
}
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 2)
}
}
@ViewBuilder
private func toolSummaryPill(_ kinds: [ToolKind: Int], interactive: Bool) -> some View {
HStack(spacing: 4) {
Image(systemName: "wrench")
.font(.caption2)
Text(summaryText(kinds))
.font(.caption2)
if interactive {
Image(systemName: "arrow.up.right.square")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.foregroundStyle(.tertiary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 2)
}
}
private func summaryText(_ kinds: [ToolKind: Int]) -> String {
@@ -22,6 +22,13 @@ struct RichChatView: View {
@Environment(HermesFileWatcher.self) private var fileWatcher
@Environment(ChatViewModel.self) private var chatViewModel
/// User-controlled font scale for the chat surface (issue #48).
/// Applied via `.environment(\.dynamicTypeSize, ...)` so message
/// list, input bar, session info bar, and the inspector pane all
/// scale together. Default 1.0 = today's UI.
@AppStorage(ChatDensityKeys.fontScale)
private var fontScale: Double = ChatFontScale.default
/// In ACP mode, events drive updates directly no DB polling needed.
private var isACPMode: Bool { chatViewModel.isACPConnected }
@@ -42,6 +49,7 @@ struct RichChatView: View {
.frame(width: 320)
}
.frame(minHeight: 0, idealHeight: 500, maxHeight: .infinity)
.environment(\.dynamicTypeSize, ChatFontScale.dynamicTypeSize(for: fontScale))
// DB polling fallback for terminal mode only never overwrite ACP messages
.onChange(of: fileWatcher.lastChangeDate) {
if !isACPMode, !richChat.hasMessages, richChat.sessionId != nil {
@@ -14,6 +14,21 @@ struct RichMessageBubble: View, Equatable {
@Environment(ChatViewModel.self) private var chatViewModel
/// Scarf-local chat density preferences (issues #47 / #48). All
/// three default to today's UI. Read here so the reasoning + tool-
/// call switches don't have to thread the values through every
/// layer; the AppStorage seam is one line per dependency.
@AppStorage(ChatDensityKeys.toolCardStyle)
private var toolCardStyleRaw: String = ToolCardStyle.full.rawValue
@AppStorage(ChatDensityKeys.reasoningStyle)
private var reasoningStyleRaw: String = ReasoningStyle.disclosure.rawValue
private var toolCardStyle: ToolCardStyle {
ToolCardStyle(rawValue: toolCardStyleRaw) ?? .full
}
private var reasoningStyle: ReasoningStyle {
ReasoningStyle(rawValue: reasoningStyleRaw) ?? .disclosure
}
/// SwiftUI body short-circuit (issue #46). Settled bubbles
/// (`message.id != 0`) are immutable id equality plus a couple
/// of cheap stored-field comparisons is sufficient. The streaming
@@ -102,13 +117,13 @@ struct RichMessageBubble: View, Equatable {
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
if message.hasReasoning {
if message.hasReasoning, reasoningStyle != .hidden {
reasoningSection
}
if !message.content.isEmpty {
contentView
}
if !message.toolCalls.isEmpty {
if !message.toolCalls.isEmpty, toolCardStyle != .hidden {
toolCallsSection
}
}
@@ -148,7 +163,24 @@ struct RichMessageBubble: View, Equatable {
// MARK: - Reasoning
/// Reasoning is rendered in one of three styles, controlled by
/// `Settings Display Chat density Reasoning` (issue #48).
/// Token count for the reasoning-bearing message is kept in the
/// metadataFooter (always-visible), so collapsing or hiding the
/// box doesn't drop telemetry.
@ViewBuilder
private var reasoningSection: some View {
switch reasoningStyle {
case .disclosure:
reasoningDisclosure
case .inline:
reasoningInline
case .hidden:
EmptyView()
}
}
private var reasoningDisclosure: some View {
DisclosureGroup {
Text(message.preferredReasoning ?? "")
.font(ScarfFont.monoSmall)
@@ -181,9 +213,44 @@ struct RichMessageBubble: View, Equatable {
)
}
/// Inline reasoning: italic foregroundFaint caption with a 9pt
/// brain prefix, no box / border / disclosure. Same data, far less
/// vertical space addresses the #48 complaint.
private var reasoningInline: some View {
HStack(alignment: .firstTextBaseline, spacing: 5) {
Image(systemName: "brain")
.font(.system(size: 9))
.foregroundStyle(ScarfColor.warning)
Text(message.preferredReasoning ?? "")
.font(ScarfFont.caption)
.italic()
.foregroundStyle(ScarfColor.foregroundFaint)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
// MARK: - Tool Calls
/// Tool calls render in one of three styles, controlled by
/// `Settings Display Chat density Tool calls` (issue #47).
/// `.hidden` is handled by the caller (skips this view entirely)
/// AND by the parent `MessageGroupView`, which makes its
/// always-visible toolSummary pill tappable so the inspector pane
/// remains reachable in both compact and hidden modes.
@ViewBuilder
private var toolCallsSection: some View {
switch toolCardStyle {
case .full:
toolCallsFull
case .compact:
toolCallsCompact
case .hidden:
EmptyView()
}
}
private var toolCallsFull: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(message.toolCalls) { call in
ToolCallCard(
@@ -196,6 +263,78 @@ struct RichMessageBubble: View, Equatable {
}
}
/// One-line tappable chip per call. Click sets focus so the right-
/// pane inspector opens with the same data the inline expand
/// shows. Status dot mirrors the full-card status icon: in-flight
/// progress / success check / non-zero exit code danger.
private var toolCallsCompact: some View {
VStack(alignment: .leading, spacing: 3) {
ForEach(message.toolCalls) { call in
let result = toolResults[call.callId]
let isFocused = chatViewModel.focusedToolCallId == call.callId
let color = compactToolColor(for: call.toolKind)
Button {
chatViewModel.focusedToolCallId = call.callId
} label: {
HStack(spacing: 6) {
Image(systemName: call.toolKind.icon)
.font(.system(size: 10))
.foregroundStyle(color)
Text(call.functionName)
.font(ScarfFont.monoSmall)
.fontWeight(.medium)
.foregroundStyle(ScarfColor.foregroundPrimary)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 6)
compactStatusIcon(call: call, result: result)
}
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(color.opacity(isFocused ? 0.16 : 0.08))
.overlay(
RoundedRectangle(cornerRadius: 5)
.strokeBorder(
color.opacity(isFocused ? 0.45 : 0.20),
lineWidth: isFocused ? 1.2 : 1
)
)
)
}
.buttonStyle(.plain)
.help("Click to inspect this tool call")
}
}
}
@ViewBuilder
private func compactStatusIcon(call: HermesToolCall, result: HermesMessage?) -> some View {
if let exit = call.exitCode {
Image(systemName: exit == 0 ? "checkmark.circle.fill" : "xmark.circle.fill")
.font(.system(size: 10))
.foregroundStyle(exit == 0 ? ScarfColor.success : ScarfColor.danger)
} else if result != nil {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 10))
.foregroundStyle(ScarfColor.success)
} else {
ProgressView().controlSize(.mini)
}
}
private func compactToolColor(for kind: ToolKind) -> Color {
switch kind {
case .read: return ScarfColor.success
case .edit: return ScarfColor.info
case .execute: return ScarfColor.warning
case .fetch: return ScarfColor.Tool.web
case .browser: return ScarfColor.Tool.search
case .other: return ScarfColor.foregroundMuted
}
}
// MARK: - Metadata Footer
private var metadataFooter: some View {
@@ -1,11 +1,38 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// Display tab streaming, reasoning, cost, skin, compact mode, inline diffs, bell, etc.
struct DisplayTab: View {
@Bindable var viewModel: SettingsViewModel
/// Scarf-local chat density preferences (issues #47 / #48).
/// Independent of the Hermes config flags rendered in the
/// "Output" section below those control what Hermes EMITS,
/// these control how Scarf RENDERS what was emitted.
@AppStorage(ChatDensityKeys.toolCardStyle)
private var toolCardStyle: String = ToolCardStyle.full.rawValue
@AppStorage(ChatDensityKeys.reasoningStyle)
private var reasoningStyle: String = ReasoningStyle.disclosure.rawValue
@AppStorage(ChatDensityKeys.fontScale)
private var fontScale: Double = ChatFontScale.default
var body: some View {
SettingsSection(title: "Chat density", icon: "rectangle.compress.vertical") {
DensityPickerRow(
label: "Tool calls",
selection: $toolCardStyle,
options: ToolCardStyle.allCases.map { ($0.rawValue, $0.displayName) }
)
DensityPickerRow(
label: "Reasoning",
selection: $reasoningStyle,
options: ReasoningStyle.allCases.map { ($0.rawValue, $0.displayName) }
)
FontScaleRow(scale: $fontScale)
DensityFootnote()
}
SettingsSection(title: "Output", icon: "doc.plaintext") {
ToggleRow(label: "Streaming", isOn: viewModel.config.streaming) { viewModel.setStreaming($0) }
ToggleRow(label: "Show Reasoning", isOn: viewModel.config.showReasoning) { viewModel.setShowReasoning($0) }
@@ -32,3 +59,82 @@ struct DisplayTab: View {
}
}
}
// MARK: - Density-section primitives
/// Segmented picker over (rawValue, displayName) tuples keeps the
/// existing `PickerRow` simple-string contract while still letting us
/// render distinct user-facing labels for each density enum case.
/// Cannot reuse the generic `PickerRow` in `SettingsComponents.swift`:
/// that one is `.menu` style and doesn't accept a separate display
/// name per option.
private struct DensityPickerRow: View {
let label: String
@Binding var selection: String
let options: [(rawValue: String, displayName: String)]
var body: some View {
HStack {
Text(label)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
.frame(width: 160, alignment: .trailing)
Picker("", selection: $selection) {
ForEach(options, id: \.rawValue) { option in
Text(option.displayName).tag(option.rawValue)
}
}
.pickerStyle(.segmented)
.labelsHidden()
.frame(maxWidth: 320)
Spacer()
}
.padding(.horizontal, ScarfSpace.s3)
.padding(.vertical, 6)
.background(ScarfColor.backgroundTertiary.opacity(0.5))
}
}
private struct FontScaleRow: View {
@Binding var scale: Double
var body: some View {
HStack {
Text("Chat font size")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
.frame(width: 160, alignment: .trailing)
Slider(
value: $scale,
in: ChatFontScale.min...ChatFontScale.max,
step: ChatFontScale.step
)
.frame(maxWidth: 240)
Text(ChatFontScale.percentLabel(for: scale))
.font(ScarfFont.monoSmall)
.foregroundStyle(ScarfColor.foregroundMuted)
.frame(width: 48, alignment: .leading)
Button("Reset") {
scale = ChatFontScale.default
}
.buttonStyle(.borderless)
.controlSize(.small)
.disabled(abs(scale - ChatFontScale.default) < 0.001)
Spacer()
}
.padding(.horizontal, ScarfSpace.s3)
.padding(.vertical, 6)
.background(ScarfColor.backgroundTertiary.opacity(0.5))
}
}
private struct DensityFootnote: View {
var body: some View {
Text("Controls how Scarf renders the chat. Use Output → Show Reasoning to control what Hermes sends.")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundFaint)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, ScarfSpace.s3)
.padding(.vertical, 6)
}
}