mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 02:26:37 +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.
|
||||
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,18 +236,44 @@ struct MessageGroupView: View, Equatable {
|
||||
private var toolSummary: some View {
|
||||
let kinds = group.toolKindCounts
|
||||
if !kinds.isEmpty {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "wrench")
|
||||
.font(.caption2)
|
||||
Text(summaryText(kinds))
|
||||
.font(.caption2)
|
||||
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)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.tertiary)
|
||||
.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)
|
||||
}
|
||||
|
||||
private func summaryText(_ kinds: [ToolKind: Int]) -> String {
|
||||
let total = kinds.values.reduce(0, +)
|
||||
let parts = kinds.sorted(by: { $0.value > $1.value })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user