mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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:
@@ -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.
|
/// that haven't been updated yet still compile.
|
||||||
var turnDurations: [Int: TimeInterval] = [:]
|
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
|
/// Equatable short-circuit for SwiftUI: when the trailing group's
|
||||||
/// streaming bubble grows, only that group's `==` returns false.
|
/// streaming bubble grows, only that group's `==` returns false.
|
||||||
/// All earlier groups skip body re-evaluation, dropping per-chunk
|
/// All earlier groups skip body re-evaluation, dropping per-chunk
|
||||||
@@ -207,7 +217,16 @@ struct MessageGroupView: View, Equatable {
|
|||||||
.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
|
toolSummary
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,18 +236,44 @@ struct MessageGroupView: View, Equatable {
|
|||||||
private var toolSummary: some View {
|
private var toolSummary: some View {
|
||||||
let kinds = group.toolKindCounts
|
let kinds = group.toolKindCounts
|
||||||
if !kinds.isEmpty {
|
if !kinds.isEmpty {
|
||||||
HStack(spacing: 4) {
|
let firstCallId = group.assistantMessages
|
||||||
Image(systemName: "wrench")
|
.flatMap(\.toolCalls)
|
||||||
.font(.caption2)
|
.first?.callId
|
||||||
Text(summaryText(kinds))
|
let isInteractive = (toolCardStyle == .hidden) && firstCallId != nil
|
||||||
.font(.caption2)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.padding(.vertical, 2)
|
.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)
|
||||||
|
}
|
||||||
|
|
||||||
private func summaryText(_ kinds: [ToolKind: Int]) -> String {
|
private func summaryText(_ kinds: [ToolKind: Int]) -> String {
|
||||||
let total = kinds.values.reduce(0, +)
|
let total = kinds.values.reduce(0, +)
|
||||||
let parts = kinds.sorted(by: { $0.value > $1.value })
|
let parts = kinds.sorted(by: { $0.value > $1.value })
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ struct RichChatView: View {
|
|||||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||||
@Environment(ChatViewModel.self) private var chatViewModel
|
@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.
|
/// In ACP mode, events drive updates directly — no DB polling needed.
|
||||||
private var isACPMode: Bool { chatViewModel.isACPConnected }
|
private var isACPMode: Bool { chatViewModel.isACPConnected }
|
||||||
|
|
||||||
@@ -42,6 +49,7 @@ struct RichChatView: View {
|
|||||||
.frame(width: 320)
|
.frame(width: 320)
|
||||||
}
|
}
|
||||||
.frame(minHeight: 0, idealHeight: 500, maxHeight: .infinity)
|
.frame(minHeight: 0, idealHeight: 500, maxHeight: .infinity)
|
||||||
|
.environment(\.dynamicTypeSize, ChatFontScale.dynamicTypeSize(for: fontScale))
|
||||||
// DB polling fallback for terminal mode only — never overwrite ACP messages
|
// DB polling fallback for terminal mode only — never overwrite ACP messages
|
||||||
.onChange(of: fileWatcher.lastChangeDate) {
|
.onChange(of: fileWatcher.lastChangeDate) {
|
||||||
if !isACPMode, !richChat.hasMessages, richChat.sessionId != nil {
|
if !isACPMode, !richChat.hasMessages, richChat.sessionId != nil {
|
||||||
|
|||||||
@@ -14,6 +14,21 @@ struct RichMessageBubble: View, Equatable {
|
|||||||
|
|
||||||
@Environment(ChatViewModel.self) private var chatViewModel
|
@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
|
/// SwiftUI body short-circuit (issue #46). Settled bubbles
|
||||||
/// (`message.id != 0`) are immutable — id equality plus a couple
|
/// (`message.id != 0`) are immutable — id equality plus a couple
|
||||||
/// of cheap stored-field comparisons is sufficient. The streaming
|
/// 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: 4) {
|
||||||
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||||
if message.hasReasoning {
|
if message.hasReasoning, reasoningStyle != .hidden {
|
||||||
reasoningSection
|
reasoningSection
|
||||||
}
|
}
|
||||||
if !message.content.isEmpty {
|
if !message.content.isEmpty {
|
||||||
contentView
|
contentView
|
||||||
}
|
}
|
||||||
if !message.toolCalls.isEmpty {
|
if !message.toolCalls.isEmpty, toolCardStyle != .hidden {
|
||||||
toolCallsSection
|
toolCallsSection
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,7 +163,24 @@ struct RichMessageBubble: View, Equatable {
|
|||||||
|
|
||||||
// MARK: - Reasoning
|
// 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 {
|
private var reasoningSection: some View {
|
||||||
|
switch reasoningStyle {
|
||||||
|
case .disclosure:
|
||||||
|
reasoningDisclosure
|
||||||
|
case .inline:
|
||||||
|
reasoningInline
|
||||||
|
case .hidden:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var reasoningDisclosure: some View {
|
||||||
DisclosureGroup {
|
DisclosureGroup {
|
||||||
Text(message.preferredReasoning ?? "")
|
Text(message.preferredReasoning ?? "")
|
||||||
.font(ScarfFont.monoSmall)
|
.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
|
// 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 {
|
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) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
ForEach(message.toolCalls) { call in
|
ForEach(message.toolCalls) { call in
|
||||||
ToolCallCard(
|
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
|
// MARK: - Metadata Footer
|
||||||
|
|
||||||
private var metadataFooter: some View {
|
private var metadataFooter: some View {
|
||||||
|
|||||||
@@ -1,11 +1,38 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ScarfCore
|
import ScarfCore
|
||||||
|
import ScarfDesign
|
||||||
|
|
||||||
/// Display tab — streaming, reasoning, cost, skin, compact mode, inline diffs, bell, etc.
|
/// Display tab — streaming, reasoning, cost, skin, compact mode, inline diffs, bell, etc.
|
||||||
struct DisplayTab: View {
|
struct DisplayTab: View {
|
||||||
@Bindable var viewModel: SettingsViewModel
|
@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 {
|
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") {
|
SettingsSection(title: "Output", icon: "doc.plaintext") {
|
||||||
ToggleRow(label: "Streaming", isOn: viewModel.config.streaming) { viewModel.setStreaming($0) }
|
ToggleRow(label: "Streaming", isOn: viewModel.config.streaming) { viewModel.setStreaming($0) }
|
||||||
ToggleRow(label: "Show Reasoning", isOn: viewModel.config.showReasoning) { viewModel.setShowReasoning($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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user