Initial commit

This commit is contained in:
2026-02-11 22:22:55 +01:00
commit 42f54954c1
58 changed files with 10639 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
//
// AboutView.swift
// oAI
//
// About modal with app icon and version info
//
import SwiftUI
struct AboutView: View {
@Environment(\.dismiss) var dismiss
private var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
}
private var buildNumber: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
}
var body: some View {
VStack(spacing: 16) {
Spacer().frame(height: 8)
Image("AppLogo")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 128, height: 128)
.clipShape(RoundedRectangle(cornerRadius: 24))
.shadow(color: .cyan.opacity(0.3), radius: 12)
Text("oAI")
.font(.system(size: 28, weight: .bold))
Text("Version \(appVersion) (\(buildNumber))")
.font(.callout)
.foregroundStyle(.secondary)
Text("Multi-provider AI chat client")
.font(.subheadline)
.foregroundStyle(.secondary)
Divider()
.padding(.horizontal, 40)
VStack(spacing: 4) {
Text("© 2026 [Rune Olsen](https://blog.rune.pm)")
.font(.caption)
.foregroundStyle(.secondary)
Text("Built with SwiftUI")
.font(.caption)
.foregroundStyle(.tertiary)
}
Spacer().frame(height: 4)
Button("OK") { dismiss() }
.keyboardShortcut(.return, modifiers: [])
.keyboardShortcut(.escape, modifiers: [])
.buttonStyle(.borderedProminent)
.controlSize(.regular)
Spacer().frame(height: 20)
}
.frame(width: 320, height: 370)
}
}
#Preview {
AboutView()
}

View File

@@ -0,0 +1,189 @@
//
// ConversationListView.swift
// oAI
//
// Saved conversations list
//
import os
import SwiftUI
struct ConversationListView: View {
@Environment(\.dismiss) var dismiss
@State private var searchText = ""
@State private var conversations: [Conversation] = []
var onLoad: ((Conversation) -> Void)?
private var filteredConversations: [Conversation] {
if searchText.isEmpty {
return conversations
}
return conversations.filter {
$0.name.lowercased().contains(searchText.lowercased())
}
}
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Conversations")
.font(.system(size: 18, weight: .bold))
Spacer()
Button { dismiss() } label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.keyboardShortcut(.escape, modifiers: [])
}
.padding(.horizontal, 24)
.padding(.top, 20)
.padding(.bottom, 12)
// Search bar
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search conversations...", text: $searchText)
.textFieldStyle(.plain)
if !searchText.isEmpty {
Button { searchText = "" } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.tertiary)
}
.buttonStyle(.plain)
}
}
.padding(10)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
.padding(.horizontal, 24)
.padding(.bottom, 12)
Divider()
// Content
if filteredConversations.isEmpty {
Spacer()
VStack(spacing: 8) {
Image(systemName: searchText.isEmpty ? "tray" : "magnifyingglass")
.font(.largeTitle)
.foregroundStyle(.tertiary)
Text(searchText.isEmpty ? "No Saved Conversations" : "No Matches")
.font(.headline)
.foregroundStyle(.secondary)
Text(searchText.isEmpty ? "Conversations you save will appear here" : "Try a different search term")
.font(.caption)
.foregroundStyle(.tertiary)
}
Spacer()
} else {
List {
ForEach(filteredConversations) { conversation in
ConversationRow(conversation: conversation)
.contentShape(Rectangle())
.onTapGesture {
onLoad?(conversation)
dismiss()
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
deleteConversation(conversation)
} label: {
Label("Delete", systemImage: "trash")
}
Button {
exportConversation(conversation)
} label: {
Label("Export", systemImage: "square.and.arrow.up")
}
.tint(.blue)
}
}
}
.listStyle(.plain)
}
Divider()
// Bottom bar
HStack {
Spacer()
Button("Done") { dismiss() }
.keyboardShortcut(.return, modifiers: [])
.buttonStyle(.borderedProminent)
.controlSize(.regular)
Spacer()
}
.padding(.horizontal, 24)
.padding(.vertical, 12)
}
.onAppear {
loadConversations()
}
.frame(minWidth: 500, minHeight: 400)
}
private func loadConversations() {
do {
conversations = try DatabaseService.shared.listConversations()
} catch {
Log.db.error("Failed to load conversations: \(error.localizedDescription)")
conversations = []
}
}
private func deleteConversation(_ conversation: Conversation) {
do {
let _ = try DatabaseService.shared.deleteConversation(id: conversation.id)
withAnimation {
conversations.removeAll { $0.id == conversation.id }
}
} catch {
Log.db.error("Failed to delete conversation: \(error.localizedDescription)")
}
}
private func exportConversation(_ conversation: Conversation) {
guard let (_, loadedMessages) = try? DatabaseService.shared.loadConversation(id: conversation.id),
!loadedMessages.isEmpty else {
return
}
let content = loadedMessages.map { msg in
let header = msg.role == .user ? "**User**" : "**Assistant**"
return "\(header)\n\n\(msg.content)"
}.joined(separator: "\n\n---\n\n")
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
let filename = conversation.name.replacingOccurrences(of: " ", with: "_") + ".md"
let fileURL = downloads.appendingPathComponent(filename)
try? content.write(to: fileURL, atomically: true, encoding: .utf8)
}
}
struct ConversationRow: View {
let conversation: Conversation
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(conversation.name)
.font(.headline)
HStack(spacing: 8) {
Label("\(conversation.messageCount)", systemImage: "message")
Text("\u{2022}")
Text(conversation.updatedAt, style: .relative)
}
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
}
}
#Preview {
ConversationListView()
}

View File

@@ -0,0 +1,160 @@
//
// CreditsView.swift
// oAI
//
// Account credits and balance
//
import SwiftUI
struct CreditsView: View {
let provider: Settings.Provider
@Environment(\.dismiss) var dismiss
@State private var credits: Credits?
@State private var isLoading = false
@State private var errorMessage: String?
var body: some View {
NavigationStack {
VStack(spacing: 24) {
// Provider icon
Image(systemName: provider.iconName)
.font(.system(size: 60))
.foregroundColor(Color.providerColor(provider))
Text(provider.displayName)
.font(.title2)
.fontWeight(.semibold)
Divider()
// Credits info based on provider
VStack(spacing: 16) {
switch provider {
case .openrouter:
openRouterCreditsView
case .anthropic:
Text("Anthropic Balance")
.font(.headline)
Text("Check your balance at:")
.font(.caption)
.foregroundColor(.secondary)
Link("console.anthropic.com", destination: URL(string: "https://console.anthropic.com")!)
.font(.body)
case .openai:
Text("OpenAI Balance")
.font(.headline)
Text("Check your usage at:")
.font(.caption)
.foregroundColor(.secondary)
Link("platform.openai.com", destination: URL(string: "https://platform.openai.com/usage")!)
.font(.body)
case .ollama:
Text("Ollama (Local)")
.font(.headline)
Text("Running locally — no credits needed!")
.font(.body)
.foregroundColor(.secondary)
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 40))
.foregroundColor(.green)
.padding(.top)
}
}
Spacer()
}
.padding()
.navigationTitle("Credits")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
}
.task {
await fetchCredits()
}
}
// MARK: - OpenRouter Credits
@ViewBuilder
private var openRouterCreditsView: some View {
Text("OpenRouter Credits")
.font(.headline)
if isLoading {
ProgressView("Loading...")
.padding()
} else if let error = errorMessage {
Text(error)
.font(.caption)
.foregroundColor(.red)
.padding()
Button("Retry") {
Task { await fetchCredits() }
}
} else if let credits = credits {
VStack(spacing: 12) {
CreditRow(label: "Remaining", value: credits.balanceDisplay, highlight: true)
Divider()
if let limit = credits.limit {
CreditRow(label: "Total Credits", value: String(format: "$%.2f", limit))
}
if let usage = credits.usage {
CreditRow(label: "Used", value: String(format: "$%.2f", usage))
}
}
} else {
Text("No credit data available")
.font(.caption)
.foregroundColor(.secondary)
}
}
private func fetchCredits() async {
guard provider == .openrouter else { return }
guard let apiProvider = ProviderRegistry.shared.getCurrentProvider() else {
errorMessage = "No API key configured"
return
}
isLoading = true
errorMessage = nil
do {
credits = try await apiProvider.getCredits()
isLoading = false
} catch {
errorMessage = error.localizedDescription
isLoading = false
}
}
}
struct CreditRow: View {
let label: String
let value: String
var highlight: Bool = false
var body: some View {
HStack {
Text(label)
.foregroundColor(highlight ? .primary : .secondary)
.fontWeight(highlight ? .semibold : .regular)
Spacer()
Text(value)
.font(highlight ? .title2.monospacedDigit() : .body.monospacedDigit())
.fontWeight(highlight ? .bold : .medium)
.foregroundColor(highlight ? .green : .primary)
}
}
}
#Preview {
CreditsView(provider: .openrouter)
}

View File

@@ -0,0 +1,456 @@
//
// HelpView.swift
// oAI
//
// Help and commands reference with expandable detail and search
//
import SwiftUI
// MARK: - Data Model
struct CommandDetail: Identifiable {
let id = UUID()
let command: String
let brief: String
let detail: String
let examples: [String]
}
struct CommandCategory: Identifiable {
let id = UUID()
let name: String
let icon: String
let commands: [CommandDetail]
}
// MARK: - Help Data
private let helpCategories: [CommandCategory] = [
CommandCategory(name: "Chat", icon: "bubble.left.and.bubble.right", commands: [
CommandDetail(
command: "/clear",
brief: "Clear chat history",
detail: "Removes all messages from the current session and resets the conversation. This does not delete saved conversations.",
examples: ["/clear"]
),
CommandDetail(
command: "/retry",
brief: "Retry last message",
detail: "Resends your last message to the AI. Useful when you get an unsatisfactory response or encounter an error.",
examples: ["/retry"]
),
CommandDetail(
command: "/memory on|off",
brief: "Toggle conversation memory",
detail: "When enabled, the AI remembers all previous messages in the session. When disabled, each message is treated independently — only your latest message is sent.",
examples: ["/memory on", "/memory off"]
),
CommandDetail(
command: "/online on|off",
brief: "Toggle web search",
detail: "Enables or disables online mode. When on, the AI can search the web to find current information before responding.",
examples: ["/online on", "/online off"]
),
]),
CommandCategory(name: "Model & Provider", icon: "cpu", commands: [
CommandDetail(
command: "/model",
brief: "Select AI model",
detail: "Opens the model selector to browse and choose from available models. The list depends on your active provider and API key.",
examples: ["/model"]
),
CommandDetail(
command: "/provider [name]",
brief: "Switch AI provider",
detail: "Without arguments, shows the current provider. With a provider name, switches to that provider. Available providers: openrouter, anthropic, openai, ollama.",
examples: ["/provider", "/provider anthropic", "/provider openai"]
),
CommandDetail(
command: "/info [model]",
brief: "Show model information",
detail: "Displays details about the currently selected model, or a specific model if provided. Shows context length, pricing, and capabilities.",
examples: ["/info", "/info anthropic/claude-sonnet-4"]
),
CommandDetail(
command: "/credits",
brief: "Check account credits",
detail: "Shows your current balance and usage for the active provider (where supported, e.g. OpenRouter).",
examples: ["/credits"]
),
]),
CommandCategory(name: "Conversations", icon: "tray.full", commands: [
CommandDetail(
command: "/save <name>",
brief: "Save current conversation",
detail: "Saves all messages in the current session under the given name. You can load it later to continue the conversation.",
examples: ["/save my-project-chat", "/save debug session"]
),
CommandDetail(
command: "/load",
brief: "Load saved conversation",
detail: "Opens the conversation list to browse and load a previously saved conversation. Replaces the current chat.",
examples: ["/load"]
),
CommandDetail(
command: "/list",
brief: "List saved conversations",
detail: "Opens the conversation list showing all saved conversations with their dates and message counts.",
examples: ["/list"]
),
CommandDetail(
command: "/delete <name>",
brief: "Delete a saved conversation",
detail: "Permanently deletes a saved conversation by name. This cannot be undone.",
examples: ["/delete old-chat", "/delete test"]
),
CommandDetail(
command: "/export md|json",
brief: "Export conversation",
detail: "Exports the current conversation to a file. Supports Markdown (.md) and JSON (.json) formats. Optionally provide a custom filename.",
examples: ["/export md", "/export json", "/export md my-chat.md"]
),
]),
CommandCategory(name: "MCP (File Access)", icon: "folder.badge.gearshape", commands: [
CommandDetail(
command: "/mcp on|off",
brief: "Toggle file access",
detail: "Enables or disables MCP (Model Context Protocol), which gives the AI access to read (and optionally write) files in your allowed folders.",
examples: ["/mcp on", "/mcp off"]
),
CommandDetail(
command: "/mcp add <path>",
brief: "Add folder for access",
detail: "Grants the AI access to a folder on your filesystem. The AI can then read, list, and search files within it. Use absolute paths or ~.",
examples: ["/mcp add ~/Projects/myapp", "/mcp add /Users/me/Documents"]
),
CommandDetail(
command: "/mcp remove <index|path>",
brief: "Remove an allowed folder",
detail: "Revokes AI access to a folder. Specify by index number (from /mcp list) or by path.",
examples: ["/mcp remove 0", "/mcp remove ~/Projects/myapp"]
),
CommandDetail(
command: "/mcp list",
brief: "List allowed folders",
detail: "Shows all folders the AI currently has access to, with their index numbers.",
examples: ["/mcp list"]
),
CommandDetail(
command: "/mcp status",
brief: "Show MCP status",
detail: "Displays whether MCP is enabled, the number of registered folders, active permissions (read/write/delete/move), and gitignore setting.",
examples: ["/mcp status"]
),
CommandDetail(
command: "/mcp write on|off",
brief: "Toggle write permissions",
detail: "Quickly enables or disables all write permissions (write, edit, delete, create directories, move, copy). Fine-grained control is available in Settings > MCP.",
examples: ["/mcp write on", "/mcp write off"]
),
]),
CommandCategory(name: "Settings & Stats", icon: "gearshape", commands: [
CommandDetail(
command: "/config",
brief: "Open settings",
detail: "Opens the settings panel where you can configure providers, API keys, MCP permissions, appearance, and more. Also available via /settings.",
examples: ["/config", "/settings"]
),
CommandDetail(
command: "/stats",
brief: "Show session statistics",
detail: "Displays statistics for the current session: message count, total tokens used, estimated cost, and session duration.",
examples: ["/stats"]
),
]),
]
private let keyboardShortcuts: [(key: String, description: String)] = [
("Return", "Send message"),
("Shift + Return", "New line"),
("\u{2318}M", "Model Selector"),
("\u{2318}K", "Clear Chat"),
("\u{21E7}\u{2318}S", "Statistics"),
("\u{2318},", "Settings"),
("\u{2318}/", "Help"),
("\u{2318}L", "Conversations"),
]
// MARK: - HelpView
struct HelpView: View {
@Environment(\.dismiss) var dismiss
@State private var searchText = ""
@State private var expandedCommandID: UUID?
private var filteredCategories: [CommandCategory] {
if searchText.isEmpty { return helpCategories }
let q = searchText.lowercased()
return helpCategories.compactMap { cat in
let matched = cat.commands.filter {
$0.command.lowercased().contains(q) ||
$0.brief.lowercased().contains(q) ||
$0.detail.lowercased().contains(q) ||
$0.examples.contains { $0.lowercased().contains(q) }
}
return matched.isEmpty ? nil : CommandCategory(name: cat.name, icon: cat.icon, commands: matched)
}
}
private var matchingShortcuts: [(key: String, description: String)] {
if searchText.isEmpty { return keyboardShortcuts }
let q = searchText.lowercased()
return keyboardShortcuts.filter {
$0.key.lowercased().contains(q) || $0.description.lowercased().contains(q)
}
}
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Help")
.font(.system(size: 18, weight: .bold))
Spacer()
Button { dismiss() } label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.keyboardShortcut(.escape, modifiers: [])
}
.padding(.horizontal, 24)
.padding(.top, 20)
.padding(.bottom, 12)
// Search bar
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search commands, shortcuts, features...", text: $searchText)
.textFieldStyle(.plain)
if !searchText.isEmpty {
Button {
searchText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.tertiary)
}
.buttonStyle(.plain)
}
}
.padding(10)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
.padding(.horizontal, 24)
.padding(.bottom, 12)
Divider()
// Content
ScrollView {
VStack(alignment: .leading, spacing: 20) {
// Quick tip (only when not searching)
if searchText.isEmpty {
HStack(spacing: 10) {
Image(systemName: "lightbulb.fill")
.foregroundStyle(.yellow)
.font(.title3)
VStack(alignment: .leading, spacing: 2) {
Text("Type / in the input to see command suggestions")
.font(.callout)
Text("Use @filename to attach files to your message")
.font(.callout)
.foregroundStyle(.secondary)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.blue.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
}
// Command categories
ForEach(filteredCategories) { category in
CategorySection(
category: category,
expandedCommandID: $expandedCommandID
)
}
// Keyboard shortcuts
#if os(macOS)
if !matchingShortcuts.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Label("Keyboard Shortcuts", systemImage: "keyboard")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
VStack(spacing: 0) {
ForEach(Array(matchingShortcuts.enumerated()), id: \.offset) { idx, shortcut in
HStack {
Text(shortcut.key)
.font(.system(size: 12, weight: .medium, design: .monospaced))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 5))
Spacer()
Text(shortcut.description)
.font(.callout)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
if idx < matchingShortcuts.count - 1 {
Divider().padding(.leading, 12)
}
}
}
.background(.background, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(.quaternary))
}
}
#endif
// No results
if filteredCategories.isEmpty && matchingShortcuts.isEmpty {
VStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.largeTitle)
.foregroundStyle(.tertiary)
Text("No results for \"\(searchText)\"")
.font(.callout)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
Divider()
// Bottom bar
HStack {
Spacer()
Button("Done") { dismiss() }
.keyboardShortcut(.return, modifiers: [])
.buttonStyle(.borderedProminent)
.controlSize(.regular)
Spacer()
}
.padding(.horizontal, 24)
.padding(.vertical, 12)
}
.frame(minWidth: 520, idealWidth: 600, minHeight: 480, idealHeight: 700)
}
}
// MARK: - Category Section
private struct CategorySection: View {
let category: CommandCategory
@Binding var expandedCommandID: UUID?
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Label(category.name, systemImage: category.icon)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
VStack(spacing: 0) {
ForEach(Array(category.commands.enumerated()), id: \.element.id) { idx, cmd in
CommandRow(
command: cmd,
isExpanded: expandedCommandID == cmd.id,
onTap: {
withAnimation(.easeInOut(duration: 0.2)) {
expandedCommandID = expandedCommandID == cmd.id ? nil : cmd.id
}
}
)
if idx < category.commands.count - 1 {
Divider().padding(.leading, 12)
}
}
}
.background(.background, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(.quaternary))
}
}
}
// MARK: - Command Row
private struct CommandRow: View {
let command: CommandDetail
let isExpanded: Bool
let onTap: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header (always visible)
Button(action: onTap) {
HStack(spacing: 10) {
Text(command.command)
.font(.system(size: 13, weight: .medium, design: .monospaced))
.foregroundStyle(.primary)
Text(command.brief)
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
}
.contentShape(Rectangle())
.padding(.horizontal, 12)
.padding(.vertical, 10)
}
.buttonStyle(.plain)
// Expanded detail
if isExpanded {
VStack(alignment: .leading, spacing: 10) {
Text(command.detail)
.font(.callout)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
if !command.examples.isEmpty {
VStack(alignment: .leading, spacing: 6) {
Text(command.examples.count == 1 ? "Example" : "Examples")
.font(.caption)
.foregroundStyle(.secondary)
.fontWeight(.medium)
ForEach(command.examples, id: \.self) { example in
Text(example)
.font(.system(size: 12, design: .monospaced))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.blue.opacity(0.06), in: RoundedRectangle(cornerRadius: 6))
}
}
}
}
.padding(.horizontal, 12)
.padding(.bottom, 12)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
}
}
#Preview {
HelpView()
}

View File

@@ -0,0 +1,223 @@
//
// ModelInfoView.swift
// oAI
//
// Rich model information modal
//
import SwiftUI
struct ModelInfoView: View {
let model: ModelInfo
@Environment(\.dismiss) var dismiss
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Model Info")
.font(.system(size: 18, weight: .bold))
Spacer()
Button { dismiss() } label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.keyboardShortcut(.escape, modifiers: [])
}
.padding(.horizontal, 24)
.padding(.top, 20)
.padding(.bottom, 12)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 20) {
// Overview
sectionHeader("Overview")
infoRow("Name", model.name)
infoRow("ID", model.id)
if let provider = model.topProvider {
infoRow("Provider", provider)
}
if let desc = model.description {
VStack(alignment: .leading, spacing: 6) {
Text("Description")
.font(.subheadline.weight(.medium))
.foregroundColor(.secondary)
Text(desc)
.font(.body)
.foregroundColor(.primary)
.fixedSize(horizontal: false, vertical: true)
.textSelection(.enabled)
}
.padding(.leading, 4)
}
Divider()
// Pricing
sectionHeader("Pricing")
infoRow("Input", model.promptPriceDisplay + " / 1M tokens")
infoRow("Output", model.completionPriceDisplay + " / 1M tokens")
if model.pricing.prompt > 0 {
VStack(alignment: .leading, spacing: 6) {
Text("Cost Examples")
.font(.caption)
.foregroundColor(.secondary)
HStack(spacing: 16) {
costExample(label: "1K tokens", inputTokens: 1_000)
costExample(label: "10K tokens", inputTokens: 10_000)
costExample(label: "100K tokens", inputTokens: 100_000)
}
}
.padding(.leading, 4)
}
Divider()
// Context Window
sectionHeader("Context Window")
infoRow("Max Tokens", model.contextLength.formatted())
if model.contextLength > 0 {
VStack(alignment: .leading, spacing: 4) {
let maxContext = 2_000_000.0
let fraction = min(Double(model.contextLength) / maxContext, 1.0)
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.gray.opacity(0.2))
.frame(height: 16)
GeometryReader { geo in
RoundedRectangle(cornerRadius: 4)
.fill(Color.blue)
.frame(width: geo.size.width * fraction, height: 16)
}
.frame(height: 16)
}
Text(model.contextLengthDisplay + " tokens")
.font(.caption2)
.foregroundColor(.secondary)
}
.padding(.leading, 4)
}
Divider()
// Capabilities
sectionHeader("Capabilities")
HStack(spacing: 12) {
capabilityBadge(icon: "eye.fill", label: "Vision", active: model.capabilities.vision)
capabilityBadge(icon: "wrench.fill", label: "Tools", active: model.capabilities.tools)
capabilityBadge(icon: "globe", label: "Online", active: model.capabilities.online)
capabilityBadge(icon: "photo.fill", label: "Image Gen", active: model.capabilities.imageGeneration)
}
// Architecture (if available)
if let arch = model.architecture {
Divider()
sectionHeader("Architecture")
if let modality = arch.modality {
infoRow("Modality", modality)
}
if let tokenizer = arch.tokenizer {
infoRow("Tokenizer", tokenizer)
}
if let instructType = arch.instructType {
infoRow("Instruct Type", instructType)
}
}
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
Divider()
// Bottom bar
HStack {
Spacer()
Button("Done") { dismiss() }
.keyboardShortcut(.return, modifiers: [])
.buttonStyle(.borderedProminent)
.controlSize(.regular)
Spacer()
}
.padding(.horizontal, 24)
.padding(.vertical, 12)
}
.frame(minWidth: 550, idealWidth: 650, minHeight: 550, idealHeight: 750)
}
// MARK: - Layout Helpers
private func sectionHeader(_ title: String) -> some View {
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
}
private func infoRow(_ label: String, _ value: String) -> some View {
HStack {
Text(label)
.font(.body)
Spacer()
Text(value)
.font(.body)
.foregroundColor(.secondary)
.textSelection(.enabled)
}
.padding(.leading, 4)
}
@ViewBuilder
private func costExample(label: String, inputTokens: Int) -> some View {
let cost = (Double(inputTokens) * model.pricing.prompt / 1_000_000) +
(Double(inputTokens) * model.pricing.completion / 1_000_000)
VStack(spacing: 2) {
Text(label)
.font(.caption2)
.foregroundColor(.secondary)
Text(String(format: "$%.4f", cost))
.font(.caption.monospacedDigit())
.foregroundColor(.primary)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.gray.opacity(0.1))
.cornerRadius(4)
}
@ViewBuilder
private func capabilityBadge(icon: String, label: String, active: Bool) -> some View {
VStack(spacing: 4) {
Image(systemName: icon)
.font(.title3)
.foregroundColor(active ? .blue : .gray.opacity(0.4))
Text(label)
.font(.caption2)
.foregroundColor(active ? .primary : .secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(active ? Color.blue.opacity(0.1) : Color.gray.opacity(0.05))
.cornerRadius(8)
}
}
#Preview {
ModelInfoView(model: ModelInfo(
id: "anthropic/claude-sonnet-4",
name: "Claude Sonnet 4",
description: "Balanced intelligence and speed. This is a longer description to test how the modal handles multi-line text that wraps across several lines in the description field.",
contextLength: 200_000,
pricing: .init(prompt: 3.0, completion: 15.0),
capabilities: .init(vision: true, tools: true, online: false),
architecture: .init(tokenizer: "claude", instructType: "claude", modality: "text+image->text"),
topProvider: "anthropic"
))
}

View File

@@ -0,0 +1,222 @@
//
// ModelSelectorView.swift
// oAI
//
// Model selection screen
//
import SwiftUI
struct ModelSelectorView: View {
let models: [ModelInfo]
let selectedModel: ModelInfo?
let onSelect: (ModelInfo) -> Void
@Environment(\.dismiss) var dismiss
@State private var searchText = ""
@State private var filterVision = false
@State private var filterTools = false
@State private var filterOnline = false
@State private var filterImageGen = false
@State private var keyboardIndex: Int = -1
private var filteredModels: [ModelInfo] {
models.filter { model in
let matchesSearch = searchText.isEmpty ||
model.name.lowercased().contains(searchText.lowercased()) ||
model.id.lowercased().contains(searchText.lowercased())
let matchesVision = !filterVision || model.capabilities.vision
let matchesTools = !filterTools || model.capabilities.tools
let matchesOnline = !filterOnline || model.capabilities.online
let matchesImageGen = !filterImageGen || model.capabilities.imageGeneration
return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen
}
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Search bar
TextField("Search models...", text: $searchText)
.textFieldStyle(.roundedBorder)
.padding()
.onChange(of: searchText) {
// Reset keyboard index when search changes
keyboardIndex = -1
}
// Filters
HStack(spacing: 12) {
FilterToggle(isOn: $filterVision, icon: "\u{1F441}\u{FE0F}", label: "Vision")
FilterToggle(isOn: $filterTools, icon: "\u{1F527}", label: "Tools")
FilterToggle(isOn: $filterOnline, icon: "\u{1F310}", label: "Online")
FilterToggle(isOn: $filterImageGen, icon: "\u{1F3A8}", label: "Image Gen")
}
.padding(.horizontal)
.padding(.bottom, 12)
Divider()
// Model list
if filteredModels.isEmpty {
ContentUnavailableView(
"No Models Found",
systemImage: "magnifyingglass",
description: Text("Try adjusting your search or filters")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollViewReader { proxy in
List(Array(filteredModels.enumerated()), id: \.element.id) { index, model in
ModelRowView(
model: model,
isSelected: model.id == selectedModel?.id,
isKeyboardHighlighted: index == keyboardIndex
)
.id(model.id)
.contentShape(Rectangle())
.onTapGesture {
onSelect(model)
}
}
.listStyle(.plain)
.onChange(of: keyboardIndex) { _, newIndex in
if newIndex >= 0 && newIndex < filteredModels.count {
withAnimation {
proxy.scrollTo(filteredModels[newIndex].id, anchor: .center)
}
}
}
}
}
}
.frame(minWidth: 600, minHeight: 500)
.navigationTitle("Select Model")
#if os(macOS)
.onKeyPress(.downArrow) {
if keyboardIndex < filteredModels.count - 1 {
keyboardIndex += 1
}
return .handled
}
.onKeyPress(.upArrow) {
if keyboardIndex > 0 {
keyboardIndex -= 1
} else if keyboardIndex == -1 && !filteredModels.isEmpty {
keyboardIndex = 0
}
return .handled
}
.onKeyPress(.return) {
if keyboardIndex >= 0 && keyboardIndex < filteredModels.count {
onSelect(filteredModels[keyboardIndex])
return .handled
}
return .ignored
}
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
.onAppear {
// Initialize keyboard index to current selection
if let selected = selectedModel,
let index = filteredModels.firstIndex(where: { $0.id == selected.id }) {
keyboardIndex = index
}
}
}
}
}
struct FilterToggle: View {
@Binding var isOn: Bool
let icon: String
let label: String
var body: some View {
Button(action: { isOn.toggle() }) {
HStack(spacing: 4) {
Text(icon)
Text(label)
}
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(isOn ? Color.blue.opacity(0.2) : Color.gray.opacity(0.1))
.foregroundColor(isOn ? .blue : .secondary)
.cornerRadius(6)
}
.buttonStyle(.plain)
}
}
struct ModelRowView: View {
let model: ModelInfo
let isSelected: Bool
var isKeyboardHighlighted: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(model.name)
.font(.headline)
.foregroundColor(isSelected ? .blue : .primary)
Spacer()
// Capabilities
HStack(spacing: 4) {
if model.capabilities.vision { Text("\u{1F441}\u{FE0F}").font(.caption) }
if model.capabilities.tools { Text("\u{1F527}").font(.caption) }
if model.capabilities.online { Text("\u{1F310}").font(.caption) }
if model.capabilities.imageGeneration { Text("\u{1F3A8}").font(.caption) }
}
if isSelected {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
}
}
Text(model.id)
.font(.caption)
.foregroundColor(.secondary)
if let description = model.description {
Text(description)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(2)
}
HStack(spacing: 12) {
Label(model.contextLengthDisplay, systemImage: "text.alignleft")
Label(model.promptPriceDisplay + "/1M", systemImage: "dollarsign.circle")
}
.font(.caption2)
.foregroundColor(.secondary)
}
.padding(.vertical, 6)
.padding(.horizontal, isKeyboardHighlighted ? 4 : 0)
.background(
isKeyboardHighlighted
? RoundedRectangle(cornerRadius: 6).fill(Color.accentColor.opacity(0.15))
: nil
)
}
}
#Preview {
ModelSelectorView(
models: ModelInfo.mockModels,
selectedModel: ModelInfo.mockModels.first,
onSelect: { _ in }
)
}

View File

@@ -0,0 +1,500 @@
//
// SettingsView.swift
// oAI
//
// Settings and configuration screen
//
import SwiftUI
import UniformTypeIdentifiers
struct SettingsView: View {
@Environment(\.dismiss) var dismiss
@Bindable private var settingsService = SettingsService.shared
private var mcpService = MCPService.shared
@State private var openrouterKey = ""
@State private var anthropicKey = ""
@State private var openaiKey = ""
@State private var googleKey = ""
@State private var googleEngineID = ""
@State private var showFolderPicker = false
@State private var selectedTab = 0
// OAuth state
@State private var oauthCode = ""
@State private var oauthError: String?
@State private var showOAuthCodeField = false
private var oauthService = AnthropicOAuthService.shared
private let labelWidth: CGFloat = 140
var body: some View {
VStack(spacing: 0) {
// Title
Text("Settings")
.font(.system(size: 18, weight: .bold))
.padding(.top, 20)
.padding(.bottom, 12)
// Tab picker
Picker("", selection: $selectedTab) {
Text("General").tag(0)
Text("MCP").tag(1)
Text("Appearance").tag(2)
}
.pickerStyle(.segmented)
.padding(.horizontal, 24)
.padding(.bottom, 12)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 20) {
switch selectedTab {
case 0:
generalTab
case 1:
mcpTab
case 2:
appearanceTab
default:
generalTab
}
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
Divider()
// Bottom bar
HStack {
Spacer()
Button("Done") { dismiss() }
.keyboardShortcut(.return, modifiers: [])
.buttonStyle(.borderedProminent)
.controlSize(.regular)
Spacer()
}
.padding(.horizontal, 24)
.padding(.vertical, 12)
}
.frame(minWidth: 550, idealWidth: 600, minHeight: 500, idealHeight: 650)
}
// MARK: - General Tab
@ViewBuilder
private var generalTab: some View {
// Provider
sectionHeader("Provider")
row("Default Provider") {
Picker("", selection: $settingsService.defaultProvider) {
ForEach(ProviderRegistry.shared.configuredProviders, id: \.self) { provider in
Text(provider.displayName).tag(provider)
}
}
.labelsHidden()
.fixedSize()
}
divider()
// API Keys
sectionHeader("API Keys")
row("OpenRouter") {
SecureField("sk-or-...", text: $openrouterKey)
.textFieldStyle(.roundedBorder)
.onAppear { openrouterKey = settingsService.openrouterAPIKey ?? "" }
.onChange(of: openrouterKey) {
settingsService.openrouterAPIKey = openrouterKey.isEmpty ? nil : openrouterKey
ProviderRegistry.shared.clearCache()
}
}
// Anthropic: OAuth or API key
row("Anthropic") {
VStack(alignment: .leading, spacing: 8) {
if oauthService.isAuthenticated {
// Logged in via OAuth
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("Logged in via Claude Pro/Max")
.font(.subheadline)
Spacer()
Button("Logout") {
oauthService.logout()
ProviderRegistry.shared.clearCache()
}
.font(.subheadline)
.foregroundStyle(.red)
}
} else if showOAuthCodeField {
// Waiting for code paste
HStack(spacing: 8) {
TextField("Paste authorization code...", text: $oauthCode)
.textFieldStyle(.roundedBorder)
Button("Submit") {
Task { await submitOAuthCode() }
}
.disabled(oauthCode.isEmpty || oauthService.isLoggingIn)
Button("Cancel") {
showOAuthCodeField = false
oauthCode = ""
oauthError = nil
}
.font(.subheadline)
}
if let error = oauthError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
}
} else {
// Login button + API key field
HStack(spacing: 8) {
Button {
startOAuthLogin()
} label: {
HStack(spacing: 4) {
Image(systemName: "person.circle")
Text("Login with Claude Pro/Max")
}
.font(.subheadline)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
Text("or")
.font(.caption)
.foregroundStyle(.secondary)
}
SecureField("sk-ant-... (API key)", text: $anthropicKey)
.textFieldStyle(.roundedBorder)
.onAppear { anthropicKey = settingsService.anthropicAPIKey ?? "" }
.onChange(of: anthropicKey) {
settingsService.anthropicAPIKey = anthropicKey.isEmpty ? nil : anthropicKey
ProviderRegistry.shared.clearCache()
}
}
}
}
row("OpenAI") {
SecureField("sk-...", text: $openaiKey)
.textFieldStyle(.roundedBorder)
.onAppear { openaiKey = settingsService.openaiAPIKey ?? "" }
.onChange(of: openaiKey) {
settingsService.openaiAPIKey = openaiKey.isEmpty ? nil : openaiKey
ProviderRegistry.shared.clearCache()
}
}
row("Ollama URL") {
TextField("http://localhost:11434", text: $settingsService.ollamaBaseURL)
.textFieldStyle(.roundedBorder)
.help("Enter your Ollama server URL to enable the Ollama provider")
}
divider()
// Features
sectionHeader("Features")
row("") {
VStack(alignment: .leading, spacing: 6) {
Toggle("Online Mode (Web Search)", isOn: $settingsService.onlineMode)
Toggle("Conversation Memory", isOn: $settingsService.memoryEnabled)
Toggle("MCP (File Access)", isOn: $settingsService.mcpEnabled)
}
}
divider()
// Web Search
sectionHeader("Web Search")
row("Search Provider") {
Picker("", selection: $settingsService.searchProvider) {
ForEach(Settings.SearchProvider.allCases, id: \.self) { provider in
Text(provider.displayName).tag(provider)
}
}
.labelsHidden()
.fixedSize()
}
if settingsService.searchProvider == .google {
row("Google API Key") {
SecureField("", text: $googleKey)
.textFieldStyle(.roundedBorder)
.onAppear { googleKey = settingsService.googleAPIKey ?? "" }
.onChange(of: googleKey) {
settingsService.googleAPIKey = googleKey.isEmpty ? nil : googleKey
}
}
row("Search Engine ID") {
TextField("", text: $googleEngineID)
.textFieldStyle(.roundedBorder)
.onAppear { googleEngineID = settingsService.googleSearchEngineID ?? "" }
.onChange(of: googleEngineID) {
settingsService.googleSearchEngineID = googleEngineID.isEmpty ? nil : googleEngineID
}
}
}
divider()
// Model Settings
sectionHeader("Model Settings")
row("Default Model ID") {
TextField("e.g. anthropic/claude-sonnet-4", text: Binding(
get: { settingsService.defaultModel ?? "" },
set: { settingsService.defaultModel = $0.isEmpty ? nil : $0 }
))
.textFieldStyle(.roundedBorder)
}
row("") {
Toggle("Streaming Responses", isOn: $settingsService.streamEnabled)
}
divider()
// Logging
sectionHeader("Logging")
row("Log Level") {
Picker("", selection: Binding(
get: { FileLogger.shared.minimumLevel },
set: { FileLogger.shared.minimumLevel = $0 }
)) {
ForEach(LogLevel.allCases, id: \.self) { level in
Text(level.displayName).tag(level)
}
}
.labelsHidden()
.fixedSize()
}
HStack {
Spacer().frame(width: labelWidth + 12)
Text("Controls which messages are written to ~/Library/Logs/oAI.log")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
// MARK: - MCP Tab
@ViewBuilder
private var mcpTab: some View {
// Enable toggle
sectionHeader("MCP")
row("") {
Toggle("MCP Enabled (File Access)", isOn: $settingsService.mcpEnabled)
}
if settingsService.mcpEnabled {
divider()
// Folders
sectionHeader("Allowed Folders")
if mcpService.allowedFolders.isEmpty {
HStack {
Spacer().frame(width: labelWidth + 12)
Text("No folders added")
.foregroundStyle(.secondary)
.font(.subheadline)
}
} else {
ForEach(Array(mcpService.allowedFolders.enumerated()), id: \.offset) { index, folder in
HStack(spacing: 0) {
Spacer().frame(width: labelWidth + 12)
Image(systemName: "folder.fill")
.foregroundStyle(.blue)
.frame(width: 20)
VStack(alignment: .leading, spacing: 0) {
Text((folder as NSString).lastPathComponent)
.font(.body)
Text(abbreviatePath(folder))
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.leading, 6)
Spacer()
Button {
withAnimation { _ = mcpService.removeFolder(at: index) }
} label: {
Image(systemName: "trash.fill")
.foregroundStyle(.red)
.font(.caption)
}
.buttonStyle(.plain)
}
}
}
HStack(spacing: 0) {
Spacer().frame(width: labelWidth + 12)
Button {
showFolderPicker = true
} label: {
HStack(spacing: 4) {
Image(systemName: "plus")
Text("Add Folder...")
}
.font(.subheadline)
}
.buttonStyle(.borderless)
Spacer()
}
.fileImporter(
isPresented: $showFolderPicker,
allowedContentTypes: [.folder],
allowsMultipleSelection: false
) { result in
if case .success(let urls) = result, let url = urls.first {
if url.startAccessingSecurityScopedResource() {
withAnimation { _ = mcpService.addFolder(url.path) }
url.stopAccessingSecurityScopedResource()
}
}
}
divider()
// Permissions
sectionHeader("Permissions")
row("") {
VStack(alignment: .leading, spacing: 6) {
Toggle("Write & Edit Files", isOn: $settingsService.mcpCanWriteFiles)
Toggle("Delete Files", isOn: $settingsService.mcpCanDeleteFiles)
Toggle("Create Directories", isOn: $settingsService.mcpCanCreateDirectories)
Toggle("Move & Copy Files", isOn: $settingsService.mcpCanMoveFiles)
}
}
HStack {
Spacer().frame(width: labelWidth + 12)
Text("Read access is always available when MCP is enabled. Write permissions must be explicitly enabled.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
divider()
// Filtering
sectionHeader("Filtering")
row("") {
Toggle("Respect .gitignore", isOn: Binding(
get: { settingsService.mcpRespectGitignore },
set: { newValue in
settingsService.mcpRespectGitignore = newValue
mcpService.reloadGitignores()
}
))
}
HStack {
Spacer().frame(width: labelWidth + 12)
Text("When enabled, listing and searching skip gitignored files. Write operations always ignore .gitignore.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
// MARK: - Appearance Tab
@ViewBuilder
private var appearanceTab: some View {
sectionHeader("Text Sizes")
row("GUI Text") {
HStack(spacing: 8) {
Slider(value: $settingsService.guiTextSize, in: 10...20, step: 1)
.frame(maxWidth: 200)
Text("\(Int(settingsService.guiTextSize)) pt")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 40)
}
}
row("Dialog Text") {
HStack(spacing: 8) {
Slider(value: $settingsService.dialogTextSize, in: 10...24, step: 1)
.frame(maxWidth: 200)
Text("\(Int(settingsService.dialogTextSize)) pt")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 40)
}
}
row("Input Text") {
HStack(spacing: 8) {
Slider(value: $settingsService.inputTextSize, in: 10...24, step: 1)
.frame(maxWidth: 200)
Text("\(Int(settingsService.inputTextSize)) pt")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 40)
}
}
}
// MARK: - Layout Helpers
private func sectionHeader(_ title: String) -> some View {
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
}
private func row<Content: View>(_ label: String, @ViewBuilder content: () -> Content) -> some View {
HStack(alignment: .center, spacing: 12) {
Text(label)
.font(.body)
.frame(width: labelWidth, alignment: .trailing)
content()
}
}
private func divider() -> some View {
Divider().padding(.vertical, 2)
}
private func abbreviatePath(_ path: String) -> String {
let home = NSHomeDirectory()
if path.hasPrefix(home) {
return "~" + path.dropFirst(home.count)
}
return path
}
// MARK: - OAuth Helpers
private func startOAuthLogin() {
let url = oauthService.generateAuthorizationURL()
#if os(macOS)
NSWorkspace.shared.open(url)
#endif
showOAuthCodeField = true
oauthError = nil
oauthCode = ""
}
private func submitOAuthCode() async {
oauthService.isLoggingIn = true
oauthError = nil
do {
try await oauthService.exchangeCode(oauthCode)
showOAuthCodeField = false
oauthCode = ""
ProviderRegistry.shared.clearCache()
} catch {
oauthError = error.localizedDescription
}
oauthService.isLoggingIn = false
}
}
#Preview {
SettingsView()
}

View File

@@ -0,0 +1,148 @@
//
// StatsView.swift
// oAI
//
// Session statistics screen
//
import SwiftUI
struct StatsView: View {
let stats: SessionStats
let model: ModelInfo?
let provider: Settings.Provider
@Environment(\.dismiss) var dismiss
var body: some View {
NavigationStack {
List {
Section("Session Info") {
StatRow(label: "Provider", value: provider.displayName)
StatRow(label: "Model", value: model?.name ?? "None selected")
StatRow(label: "Messages", value: "\(stats.messageCount)")
}
Section("Token Usage") {
StatRow(label: "Input Tokens", value: stats.totalInputTokens.formatted())
StatRow(label: "Output Tokens", value: stats.totalOutputTokens.formatted())
StatRow(label: "Total Tokens", value: stats.totalTokens.formatted())
if stats.totalTokens > 0 {
HStack {
Text("Token Distribution")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
GeometryReader { geo in
HStack(spacing: 0) {
Rectangle()
.fill(Color.blue)
.frame(width: geo.size.width * CGFloat(stats.totalInputTokens) / CGFloat(stats.totalTokens))
Rectangle()
.fill(Color.green)
.frame(width: geo.size.width * CGFloat(stats.totalOutputTokens) / CGFloat(stats.totalTokens))
}
}
.frame(height: 20)
.cornerRadius(4)
}
}
}
Section("Costs") {
StatRow(label: "Total Cost", value: stats.totalCostDisplay)
if stats.messageCount > 0 {
StatRow(label: "Avg per Message", value: String(format: "$%.4f", stats.averageCostPerMessage))
}
}
if let model = model {
Section("Model Details") {
StatRow(label: "Context Length", value: model.contextLengthDisplay)
StatRow(label: "Prompt Price", value: model.promptPriceDisplay + "/1M tokens")
StatRow(label: "Completion Price", value: model.completionPriceDisplay + "/1M tokens")
HStack {
Text("Capabilities")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
HStack(spacing: 8) {
if model.capabilities.vision {
CapabilityBadge(icon: "👁️", label: "Vision")
}
if model.capabilities.tools {
CapabilityBadge(icon: "🔧", label: "Tools")
}
if model.capabilities.online {
CapabilityBadge(icon: "🌐", label: "Online")
}
}
}
}
}
}
#if os(iOS)
.listStyle(.insetGrouped)
#else
.listStyle(.sidebar)
#endif
.navigationTitle("Statistics")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
dismiss()
}
}
}
.frame(minWidth: 500, idealWidth: 550, minHeight: 450, idealHeight: 500)
}
}
}
struct StatRow: View {
let label: String
let value: String
var body: some View {
HStack {
Text(label)
.font(.body)
Spacer()
Text(value)
.font(.body.monospacedDigit())
.foregroundColor(.secondary)
}
}
}
struct CapabilityBadge: View {
let icon: String
let label: String
var body: some View {
HStack(spacing: 2) {
Text(icon)
Text(label)
}
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(Color.blue.opacity(0.1))
.cornerRadius(4)
}
}
#Preview {
StatsView(
stats: SessionStats(
totalInputTokens: 1250,
totalOutputTokens: 3420,
totalCost: 0.0152,
messageCount: 12
),
model: ModelInfo.mockModels.first,
provider: .openrouter
)
}