diff --git a/scarf/scarf/Features/Chat/ChatDensitySettings.swift b/scarf/scarf/Features/Chat/ChatDensitySettings.swift new file mode 100644 index 0000000..8e54dd2 --- /dev/null +++ b/scarf/scarf/Features/Chat/ChatDensitySettings.swift @@ -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)%" + } +} diff --git a/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift b/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift index 1f3e269..a4a21e6 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift @@ -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 }) diff --git a/scarf/scarf/Features/Chat/Views/RichChatView.swift b/scarf/scarf/Features/Chat/Views/RichChatView.swift index 484a49a..ae684cd 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatView.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatView.swift @@ -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 { diff --git a/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift b/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift index d66f0b4..8d8bb23 100644 --- a/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift +++ b/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift @@ -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 { diff --git a/scarf/scarf/Features/Settings/Views/Tabs/DisplayTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/DisplayTab.swift index 4696dbb..4d5d6e9 100644 --- a/scarf/scarf/Features/Settings/Views/Tabs/DisplayTab.swift +++ b/scarf/scarf/Features/Settings/Views/Tabs/DisplayTab.swift @@ -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) + } +}