diff --git a/scarf/Scarf iOS/Chat/ChatView.swift b/scarf/Scarf iOS/Chat/ChatView.swift index 15cee78..42789ae 100644 --- a/scarf/Scarf iOS/Chat/ChatView.swift +++ b/scarf/Scarf iOS/Chat/ChatView.swift @@ -1068,32 +1068,53 @@ private struct MessageBubble: View { if message.isUser { Text(message.content) .font(.body) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .foregroundStyle(.white) - .background(Color.accentColor) - .clipShape(RoundedRectangle(cornerRadius: 14)) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .foregroundStyle(ScarfColor.onAccent) + .background( + UnevenRoundedRectangle(cornerRadii: + .init(topLeading: 14, bottomLeading: 14, bottomTrailing: 4, topTrailing: 14)) + .fill(ScarfColor.accent) + ) .textSelection(.enabled) .contextMenu { messageContextMenu } } else { - VStack(alignment: .leading, spacing: 8) { - ForEach(Array(ChatContentFormatter.segments(for: message.content).enumerated()), id: \.offset) { _, segment in - switch segment { - case .text(let body): - Self.markdownText(body) - .font(.body) - .textSelection(.enabled) - case .code(let lang, let body): - CodeBlockView(language: lang, body: body) + HStack(alignment: .top, spacing: 8) { + // Assistant avatar — rust gradient sparkles tile, + // matches the Mac side and the ScarfChatView reference. + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(ScarfGradient.brand) + .frame(width: 24, height: 24) + .overlay( + Image(systemName: "sparkles") + .foregroundStyle(.white) + .font(.system(size: 10, weight: .semibold)) + ) + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(ChatContentFormatter.segments(for: message.content).enumerated()), id: \.offset) { _, segment in + switch segment { + case .text(let body): + Self.markdownText(body) + .font(.body) + .textSelection(.enabled) + case .code(let lang, let body): + CodeBlockView(language: lang, body: body) + } } } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .foregroundStyle(ScarfColor.foregroundPrimary) + .background( + RoundedRectangle(cornerRadius: ScarfRadius.xl, style: .continuous) + .fill(ScarfColor.backgroundSecondary) + ) + .overlay( + RoundedRectangle(cornerRadius: ScarfRadius.xl, style: .continuous) + .strokeBorder(ScarfColor.border, lineWidth: 1) + ) + .contextMenu { messageContextMenu } } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .foregroundStyle(Color.primary) - .background(ScarfColor.backgroundSecondary) - .clipShape(RoundedRectangle(cornerRadius: 14)) - .contextMenu { messageContextMenu } } } @@ -1206,64 +1227,118 @@ private struct ReasoningDisclosure: View { .textSelection(.enabled) .padding(.top, 4) } label: { - Label("Thinking…", systemImage: "brain") - .font(.caption) - .foregroundStyle(ScarfColor.foregroundMuted) + HStack(spacing: 5) { + Image(systemName: "brain") + .font(.caption) + Text("REASONING") + .font(.caption2) + .fontWeight(.semibold) + .tracking(0.5) + } + .foregroundStyle(ScarfColor.warning) } - .padding(.horizontal, 6) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 7) + .fill(ScarfColor.warning.opacity(0.10)) + .overlay( + RoundedRectangle(cornerRadius: 7) + .strokeBorder(ScarfColor.warning.opacity(0.30), lineWidth: 1) + ) + ) } } -/// Expanding card for a single `HermesToolCall` — shows function name -/// + summary collapsed; full JSON arguments expanded. +/// Expanding card for a single `HermesToolCall` — kind-tinted with +/// uppercase tracked label, matches the Mac ToolCallCard treatment. private struct ToolCallCard: View { let call: HermesToolCall @State private var isExpanded = false var body: some View { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 6) { Button { withAnimation(.easeInOut(duration: 0.15)) { isExpanded.toggle() } } label: { - HStack(spacing: 6) { - Image(systemName: iconName) - .foregroundStyle(.tint) + HStack(spacing: 8) { + HStack(spacing: 4) { + Image(systemName: call.toolKind.icon) + .foregroundStyle(toolColor) + .font(.caption2) + Text(toolLabel) + .font(.caption2) + .fontWeight(.semibold) + .tracking(0.4) + .foregroundStyle(toolColor) + } Text(call.functionName) .font(.caption.monospaced()) - .foregroundStyle(.primary) + .fontWeight(.semibold) + .foregroundStyle(ScarfColor.foregroundPrimary) Text(call.argumentsSummary.prefix(60)) - .font(.caption) + .font(.caption.monospaced()) .foregroundStyle(ScarfColor.foregroundMuted) .lineLimit(1) - Spacer() + .truncationMode(.middle) + Spacer(minLength: 4) Image(systemName: isExpanded ? "chevron.up" : "chevron.down") .font(.caption2) - .foregroundStyle(.tertiary) + .foregroundStyle(ScarfColor.foregroundFaint) } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 7) + .fill(toolColor.opacity(0.10)) + .overlay( + RoundedRectangle(cornerRadius: 7) + .strokeBorder(toolColor.opacity(0.30), lineWidth: 1) + ) + ) } .buttonStyle(.plain) if isExpanded { Text(call.arguments) .font(.caption2.monospaced()) - .foregroundStyle(ScarfColor.foregroundMuted) + .foregroundStyle(ScarfColor.foregroundPrimary) .textSelection(.enabled) - .padding(.top, 2) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 7) + .fill(ScarfColor.backgroundSecondary) + .overlay( + RoundedRectangle(cornerRadius: 7) + .strokeBorder(ScarfColor.border, lineWidth: 1) + ) + ) + .padding(.leading, 4) } } - .padding(8) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(Color(.tertiarySystemBackground)) - ) - .overlay( - RoundedRectangle(cornerRadius: 8) - .strokeBorder(Color(.separator), lineWidth: 0.5) - ) } - private var iconName: String { - call.toolKind.icon + private var toolLabel: String { + switch call.toolKind { + case .read: return "READ" + case .edit: return "EDIT" + case .execute: return "EXECUTE" + case .fetch: return "FETCH" + case .browser: return "BROWSER" + case .other: return "TOOL" + } + } + + private var toolColor: Color { + switch call.toolKind { + 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 + } } } diff --git a/scarf/Scarf iOS/Cron/CronListView.swift b/scarf/Scarf iOS/Cron/CronListView.swift index 682128f..df14d3d 100644 --- a/scarf/Scarf iOS/Cron/CronListView.swift +++ b/scarf/Scarf iOS/Cron/CronListView.swift @@ -26,7 +26,7 @@ struct CronListView: View { if let err = vm.lastError { Section { Label(err, systemImage: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) + .foregroundStyle(ScarfColor.warning) } } @@ -62,6 +62,8 @@ struct CronListView: View { } } .scarfGoListDensity() + .scrollContentBackground(.hidden) + .background(ScarfColor.backgroundPrimary) .navigationTitle("Cron jobs") .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/scarf/Scarf iOS/Dashboard/DashboardView.swift b/scarf/Scarf iOS/Dashboard/DashboardView.swift index f1aaf2c..39894ae 100644 --- a/scarf/Scarf iOS/Dashboard/DashboardView.swift +++ b/scarf/Scarf iOS/Dashboard/DashboardView.swift @@ -3,10 +3,10 @@ import ScarfCore import ScarfIOS import ScarfDesign -/// iOS Dashboard — shows session count, token usage, cost, and the -/// last 5 sessions pulled from the remote Hermes SQLite snapshot. -/// Every data source routes through `ServerContext → CitadelServerTransport` -/// so the same services that drive the Mac Dashboard power this one. +/// iOS Dashboard — adopts the Mac-style card layout (status row + +/// stats grid + recent-sessions card) instead of a native iOS list. +/// Sessions sub-tab keeps a List view for scrolling density but +/// renders against the rust page background. struct DashboardView: View { let config: IOSServerConfig let key: SSHKeyBundle @@ -16,17 +16,8 @@ struct DashboardView: View { @State private var selectedSection: Section = .overview @State private var sessionProjectFilter: String? = nil - /// Two top-level surfaces in the Dashboard. Overview = stats + - /// 5 most-recent sessions for glance. Sessions = the 25-session - /// deeper list with a project filter. Split added in pass-2 per - /// user feedback — the old single-List layout grew too busy - /// once we started adding project badges, and users wanted a - /// way to slice by project. enum Section: Hashable { case overview, sessions } - /// Stable ID used when building the `ServerContext` — tied to the - /// config's host+user tuple so re-launching the app without reset - /// yields the same ID (important for the snapshot cache dir). private static let contextID: ServerID = ServerID( uuidString: "00000000-0000-0000-0000-0000000000A1" )! @@ -48,18 +39,18 @@ struct DashboardView: View { Text("Sessions").tag(Section.sessions) } .pickerStyle(.segmented) - .padding(.horizontal, 16) - .padding(.top, 8) - .padding(.bottom, 4) + .padding(.horizontal, ScarfSpace.s4) + .padding(.top, ScarfSpace.s2) + .padding(.bottom, ScarfSpace.s1) Group { switch selectedSection { - case .overview: overviewList + case .overview: overviewContent case .sessions: sessionsList } } } - .scarfGoListDensity() + .background(ScarfColor.backgroundPrimary.ignoresSafeArea()) .navigationTitle(config.displayName) .navigationBarTitleDisplayMode(.large) .refreshable { await vm.refresh() } @@ -68,62 +59,159 @@ struct DashboardView: View { ProgressView("Loading dashboard…") .padding() .background(.regularMaterial) - .clipShape(RoundedRectangle(cornerRadius: 10)) + .clipShape(RoundedRectangle(cornerRadius: ScarfRadius.lg)) } } .task { await vm.load() } } - // MARK: - Overview + // MARK: - Overview (Mac-style cards) @ViewBuilder - private var overviewList: some View { - List { - if let err = vm.lastError { - SwiftUI.Section { - VStack(alignment: .leading, spacing: 8) { - Label("Connection issue", systemImage: "exclamationmark.triangle.fill") - .foregroundStyle(ScarfColor.warning) - .font(.headline) - Text(err) - .font(.callout) - .foregroundStyle(ScarfColor.foregroundMuted) - Button("Retry") { - Task { await vm.refresh() } - } - .buttonStyle(.bordered) - } - .padding(.vertical, 4) + private var overviewContent: some View { + ScrollView { + VStack(alignment: .leading, spacing: ScarfSpace.s5) { + if let err = vm.lastError { + errorBanner(err) + } + + statsSection + + if !vm.recentSessions.isEmpty { + recentSessionsSection } } + .padding(.horizontal, ScarfSpace.s4) + .padding(.vertical, ScarfSpace.s4) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .background(ScarfColor.backgroundPrimary) + } - SwiftUI.Section("Activity") { - statRow("Total sessions", value: "\(vm.stats.totalSessions)") - statRow("Total messages", value: "\(vm.stats.totalMessages)") - statRow("Tool calls", value: "\(vm.stats.totalToolCalls)") + private func errorBanner(_ err: String) -> some View { + HStack(alignment: .top, spacing: ScarfSpace.s2) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(ScarfColor.warning) + VStack(alignment: .leading, spacing: 4) { + Text("Connection issue") + .font(.headline) + .foregroundStyle(ScarfColor.foregroundPrimary) + Text(err) + .font(.callout) + .foregroundStyle(ScarfColor.foregroundMuted) + .fixedSize(horizontal: false, vertical: true) + Button("Retry") { + Task { await vm.refresh() } + } + .buttonStyle(.bordered) + .padding(.top, 4) } + Spacer(minLength: 0) + } + .padding(ScarfSpace.s3) + .background( + RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous) + .fill(ScarfColor.warning.opacity(0.10)) + .overlay( + RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous) + .strokeBorder(ScarfColor.warning.opacity(0.30), lineWidth: 1) + ) + ) + } - SwiftUI.Section("Tokens") { - statRow("Input", value: formatTokens(vm.stats.totalInputTokens)) - statRow("Output", value: formatTokens(vm.stats.totalOutputTokens)) - statRow("Reasoning", value: formatTokens(vm.stats.totalReasoningTokens)) + private var statsSection: some View { + VStack(alignment: .leading, spacing: ScarfSpace.s2) { + Text("Activity") + .font(.headline) + .foregroundStyle(ScarfColor.foregroundPrimary) + + LazyVGrid( + columns: [GridItem(.flexible(), spacing: ScarfSpace.s3), + GridItem(.flexible(), spacing: ScarfSpace.s3)], + spacing: ScarfSpace.s3 + ) { + statCard(label: "Sessions", value: "\(vm.stats.totalSessions)") + statCard(label: "Messages", value: "\(vm.stats.totalMessages)") + statCard(label: "Tool Calls", value: "\(vm.stats.totalToolCalls)") + statCard( + label: "Tokens", + value: formatTokens(vm.stats.totalInputTokens + vm.stats.totalOutputTokens), + sub: tokenSub + ) } + } + } - if !vm.recentSessions.isEmpty { - SwiftUI.Section { - ForEach(vm.recentSessions) { session in - sessionRow(session) - } - } header: { - HStack { - Text("Recent sessions") - Spacer() - Button("See all") { selectedSection = .sessions } - .font(.caption) - .textCase(nil) + private var tokenSub: String? { + let inT = vm.stats.totalInputTokens + let outT = vm.stats.totalOutputTokens + guard inT + outT > 0 else { return nil } + return "\(formatTokens(inT)) in · \(formatTokens(outT)) out" + } + + private func statCard(label: String, value: String, sub: String? = nil) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(label) + .font(.caption) + .fontWeight(.semibold) + .textCase(.uppercase) + .tracking(0.5) + .foregroundStyle(ScarfColor.foregroundMuted) + Text(value) + .font(.system(size: 22, weight: .semibold, design: .monospaced)) + .foregroundStyle(ScarfColor.foregroundPrimary) + .lineLimit(1) + .minimumScaleFactor(0.7) + if let sub { + Text(sub) + .font(.caption2) + .foregroundStyle(ScarfColor.foregroundFaint) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(ScarfSpace.s3) + .background( + RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous) + .fill(ScarfColor.backgroundSecondary) + ) + .overlay( + RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous) + .strokeBorder(ScarfColor.border, lineWidth: 1) + ) + } + + private var recentSessionsSection: some View { + VStack(alignment: .leading, spacing: ScarfSpace.s2) { + HStack { + Text("Recent sessions") + .font(.headline) + .foregroundStyle(ScarfColor.foregroundPrimary) + Spacer() + Button("See all") { selectedSection = .sessions } + .font(.caption) + .foregroundStyle(ScarfColor.accent) + .buttonStyle(.plain) + } + VStack(spacing: 0) { + ForEach(Array(vm.recentSessions.enumerated()), id: \.element.id) { idx, session in + sessionRow(session) + .padding(.horizontal, ScarfSpace.s3) + .padding(.vertical, ScarfSpace.s2 + 2) + if idx < vm.recentSessions.count - 1 { + Rectangle() + .fill(ScarfColor.border) + .frame(height: 1) } } } + .background( + RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous) + .fill(ScarfColor.backgroundSecondary) + ) + .overlay( + RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous) + .strokeBorder(ScarfColor.border, lineWidth: 1) + ) } } @@ -134,8 +222,8 @@ struct DashboardView: View { VStack(spacing: 0) { if !vm.allProjects.isEmpty { filterBar - .padding(.horizontal, 12) - .padding(.bottom, 8) + .padding(.horizontal, ScarfSpace.s3) + .padding(.vertical, ScarfSpace.s2) } List { @@ -149,20 +237,20 @@ struct DashboardView: View { : "No sessions for that project yet. Try another filter or start a chat in that project.") ) .listRowSeparator(.hidden) + .listRowBackground(Color.clear) } else { ForEach(filtered) { session in sessionRow(session) + .listRowBackground(ScarfColor.backgroundSecondary) } } } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(ScarfColor.backgroundPrimary) } } - /// Project filter control rendered above the Sessions list. Uses - /// a Menu instead of a segmented Picker because there can be many - /// projects — segments don't scale past 3–4 options on a phone. - /// Shows the active filter as the button label (tappable to - /// change); an explicit "All projects" entry clears the filter. @ViewBuilder private var filterBar: some View { HStack { @@ -191,16 +279,16 @@ struct DashboardView: View { .font(.caption2) } .font(.caption) - .foregroundStyle(.tint) + .foregroundStyle(ScarfColor.accent) .padding(.horizontal, 10) .padding(.vertical, 6) - .background(.tint.opacity(0.1), in: Capsule()) + .background(ScarfColor.accentTint, in: Capsule()) } Spacer() } } - // MARK: - Row helpers + // MARK: - Row helper @ViewBuilder private func sessionRow(_ session: HermesSession) -> some View { @@ -211,7 +299,7 @@ struct DashboardView: View { Text(session.displayTitle) .font(.body) .lineLimit(2) - .foregroundStyle(.primary) + .foregroundStyle(ScarfColor.foregroundPrimary) HStack(spacing: 12) { Label(session.source, systemImage: session.sourceIcon) .font(.caption) @@ -230,11 +318,11 @@ struct DashboardView: View { if let projectName = vm.projectName(for: session) { Label(projectName, systemImage: "folder.fill") .font(.caption2) - .foregroundStyle(.tint) + .foregroundStyle(ScarfColor.accent) .labelStyle(.titleAndIcon) .padding(.vertical, 2) .padding(.horizontal, 6) - .background(.tint.opacity(0.12), in: Capsule()) + .background(ScarfColor.accentTint, in: Capsule()) } } .padding(.vertical, 2) @@ -244,18 +332,6 @@ struct DashboardView: View { .buttonStyle(.plain) } - @ViewBuilder - private func statRow(_ label: String, value: String) -> some View { - LabeledContent(label) { - Text(value) - .monospacedDigit() - .foregroundStyle(ScarfColor.foregroundMuted) - } - } - - /// Mirror of `ScarfCore.formatTokens` — inlined here rather than - /// exported from ScarfCore because it's currently wrapped in - /// `#if canImport(SQLite3)` (from the M0d InsightsViewModel move). private func formatTokens(_ count: Int) -> String { if count >= 1_000_000 { return String(format: "%.1fM", Double(count) / 1_000_000) diff --git a/scarf/Scarf iOS/Memory/MemoryListView.swift b/scarf/Scarf iOS/Memory/MemoryListView.swift index 672d05d..caaf0f0 100644 --- a/scarf/Scarf iOS/Memory/MemoryListView.swift +++ b/scarf/Scarf iOS/Memory/MemoryListView.swift @@ -24,16 +24,22 @@ struct MemoryListView: View { Section { memoryRow(.memory, context: ctx) .scarfGoCompactListRow() + .listRowBackground(ScarfColor.backgroundSecondary) memoryRow(.user, context: ctx) .scarfGoCompactListRow() + .listRowBackground(ScarfColor.backgroundSecondary) memoryRow(.soul, context: ctx) .scarfGoCompactListRow() + .listRowBackground(ScarfColor.backgroundSecondary) } footer: { Text("MEMORY.md and USER.md live under `~/.hermes/memories/`. SOUL.md lives at `~/.hermes/SOUL.md`.") .font(.caption) + .foregroundStyle(ScarfColor.foregroundMuted) } } .scarfGoListDensity() + .scrollContentBackground(.hidden) + .background(ScarfColor.backgroundPrimary) .navigationTitle("Memory") .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/scarf/Scarf iOS/Projects/ProjectsListView.swift b/scarf/Scarf iOS/Projects/ProjectsListView.swift index 3f68e19..84640d7 100644 --- a/scarf/Scarf iOS/Projects/ProjectsListView.swift +++ b/scarf/Scarf iOS/Projects/ProjectsListView.swift @@ -67,6 +67,7 @@ struct ProjectsListView: View { Section { ForEach(topLevel) { project in projectRow(project) + .listRowBackground(ScarfColor.backgroundSecondary) } } } @@ -74,11 +75,14 @@ struct ProjectsListView: View { Section(folder) { ForEach(visibleProjects.filter { $0.folder == folder }) { project in projectRow(project) + .listRowBackground(ScarfColor.backgroundSecondary) } } } } .scarfGoListDensity() + .scrollContentBackground(.hidden) + .background(ScarfColor.backgroundPrimary) } private func projectRow(_ project: ProjectEntry) -> some View { diff --git a/scarf/Scarf iOS/Servers/ServerListView.swift b/scarf/Scarf iOS/Servers/ServerListView.swift index 6527999..ac0ed39 100644 --- a/scarf/Scarf iOS/Servers/ServerListView.swift +++ b/scarf/Scarf iOS/Servers/ServerListView.swift @@ -24,7 +24,7 @@ struct ServerListView: View { Section { VStack(alignment: .leading, spacing: 8) { Label("Something went wrong", systemImage: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) + .foregroundStyle(ScarfColor.warning) .font(.headline) Text(err) .font(.callout) @@ -60,6 +60,8 @@ struct ServerListView: View { } } .scarfGoListDensity() + .scrollContentBackground(.hidden) + .background(ScarfColor.backgroundPrimary) .navigationTitle("Servers") .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/scarf/Scarf iOS/Settings/SettingsView.swift b/scarf/Scarf iOS/Settings/SettingsView.swift index e84a9b5..8fb647f 100644 --- a/scarf/Scarf iOS/Settings/SettingsView.swift +++ b/scarf/Scarf iOS/Settings/SettingsView.swift @@ -29,7 +29,7 @@ struct SettingsView: View { if let err = vm.lastError { Section { Label(err, systemImage: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) + .foregroundStyle(ScarfColor.warning) } } @@ -49,6 +49,8 @@ struct SettingsView: View { } } .scarfGoListDensity() + .scrollContentBackground(.hidden) + .background(ScarfColor.backgroundPrimary) .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) .refreshable { await vm.load() } diff --git a/scarf/Scarf iOS/Skills/Installed/InstalledSkillsListView.swift b/scarf/Scarf iOS/Skills/Installed/InstalledSkillsListView.swift index 4971228..d6e9971 100644 --- a/scarf/Scarf iOS/Skills/Installed/InstalledSkillsListView.swift +++ b/scarf/Scarf iOS/Skills/Installed/InstalledSkillsListView.swift @@ -45,11 +45,14 @@ struct InstalledSkillsListView: View { } } .scarfGoCompactListRow() + .listRowBackground(ScarfColor.backgroundSecondary) } } } } .scarfGoListDensity() + .scrollContentBackground(.hidden) + .background(ScarfColor.backgroundPrimary) .searchable(text: $vm.searchText, placement: .navigationBarDrawer(displayMode: .always)) } }