New version v2.3.6

This commit is contained in:
2026-03-04 10:19:16 +01:00
parent 65a35cd508
commit 49f842f119
52 changed files with 14034 additions and 358 deletions

View File

@@ -200,7 +200,7 @@ struct ContentView: View {
// Helper view for toolbar labels
struct ToolbarLabel: View {
let title: String
let title: LocalizedStringKey
let systemImage: String
let showLabels: Bool
let scale: Image.Scale

View File

@@ -47,19 +47,19 @@ struct FooterView: View {
HStack(spacing: 16) {
FooterItem(
icon: "message",
label: "Messages",
label: "Messages:",
value: "\(stats.messageCount)"
)
FooterItem(
icon: "chart.bar.xaxis",
label: "Tokens",
label: "Tokens:",
value: "\(stats.totalTokens) (\(stats.totalInputTokens) in, \(stats.totalOutputTokens) out)"
)
FooterItem(
icon: "dollarsign.circle",
label: "Cost",
label: "Cost:",
value: stats.totalCostDisplay
)
@@ -134,7 +134,7 @@ struct SaveIndicator: View {
return .secondary
}
private var tooltip: String {
private var tooltip: LocalizedStringKey {
if isModified { return "Click to re-save \"\(conversationName ?? "")\"" }
if isSaved { return "Saved — no changes" }
return "Not saved — use /save <name>"
@@ -162,7 +162,7 @@ struct SaveIndicator: View {
struct FooterItem: View {
let icon: String
let label: String
let label: LocalizedStringKey
let value: String
private let guiSize = SettingsService.shared.guiTextSize
@@ -172,7 +172,7 @@ struct FooterItem: View {
.font(.system(size: guiSize - 2))
.foregroundColor(.oaiSecondary)
Text(label + ":")
Text(label)
.font(.system(size: guiSize - 2))
.foregroundColor(.oaiSecondary)
@@ -187,9 +187,24 @@ struct SyncStatusFooter: View {
private let gitSync = GitSyncService.shared
private let settings = SettingsService.shared
private let guiSize = SettingsService.shared.guiTextSize
@State private var syncText = "Not Synced"
private enum SyncState { case off, notInitialized, ready, syncing, error, synced(Date) }
@State private var syncState: SyncState = .off
@State private var syncColor: Color = .secondary
private var syncText: LocalizedStringKey {
switch syncState {
case .off: return "Sync: Off"
case .notInitialized: return "Sync: Not Initialized"
case .ready: return "Sync: Ready"
case .syncing: return "Syncing..."
case .error: return "Sync Error"
case .synced(let date):
let rel = RelativeDateTimeFormatter().localizedString(for: date, relativeTo: .now)
return "Last Sync: \(rel)"
}
}
var body: some View {
HStack(spacing: 6) {
Image(systemName: "arrow.triangle.2.circlepath")
@@ -200,61 +215,27 @@ struct SyncStatusFooter: View {
.font(.system(size: guiSize - 2, weight: .medium))
.foregroundColor(syncColor)
}
.onAppear {
updateSyncStatus()
}
.onChange(of: gitSync.syncStatus.lastSyncTime) {
updateSyncStatus()
}
.onChange(of: gitSync.syncStatus.isCloned) {
updateSyncStatus()
}
.onChange(of: gitSync.lastSyncError) {
updateSyncStatus()
}
.onChange(of: gitSync.isSyncing) {
updateSyncStatus()
}
.onChange(of: settings.syncConfigured) {
updateSyncStatus()
}
.onAppear { updateSyncStatus() }
.onChange(of: gitSync.syncStatus.lastSyncTime) { updateSyncStatus() }
.onChange(of: gitSync.syncStatus.isCloned) { updateSyncStatus() }
.onChange(of: gitSync.lastSyncError) { updateSyncStatus() }
.onChange(of: gitSync.isSyncing) { updateSyncStatus() }
.onChange(of: settings.syncConfigured) { updateSyncStatus() }
}
private func updateSyncStatus() {
if gitSync.lastSyncError != nil {
syncText = "Sync Error"
syncColor = .red
syncState = .error; syncColor = .red
} else if gitSync.isSyncing {
syncText = "Syncing..."
syncColor = .orange
syncState = .syncing; syncColor = .orange
} else if let lastSync = gitSync.syncStatus.lastSyncTime {
syncText = "Last Sync: \(timeAgo(lastSync))"
syncColor = .green
syncState = .synced(lastSync); syncColor = .green
} else if gitSync.syncStatus.isCloned {
syncText = "Sync: Ready"
syncColor = .secondary
syncState = .ready; syncColor = .secondary
} else if settings.syncConfigured {
syncText = "Sync: Not Initialized"
syncColor = .orange
syncState = .notInitialized; syncColor = .orange
} else {
syncText = "Sync: Off"
syncColor = .secondary
}
}
private func timeAgo(_ date: Date) -> String {
let seconds = Int(Date().timeIntervalSince(date))
if seconds < 60 {
return "just now"
} else if seconds < 3600 {
let minutes = seconds / 60
return "\(minutes)m ago"
} else if seconds < 86400 {
let hours = seconds / 3600
return "\(hours)h ago"
} else {
let days = seconds / 86400
return "\(days)d ago"
syncState = .off; syncColor = .secondary
}
}
}

View File

@@ -190,7 +190,7 @@ struct StatItem: View {
struct StatusPill: View {
let icon: String
let label: String
let label: LocalizedStringKey
let color: Color
var body: some View {
@@ -210,9 +210,31 @@ struct StatusPill: View {
struct SyncStatusPill: View {
private let gitSync = GitSyncService.shared
private enum SyncPillState { case notConfigured, syncing, error(String), synced(Date?) }
@State private var syncState: SyncPillState = .notConfigured
@State private var syncColor: Color = .secondary
@State private var syncLabel: String = "Sync"
@State private var tooltipText: String = ""
private var syncLabel: LocalizedStringKey {
switch syncState {
case .notConfigured: return "Sync"
case .syncing: return "Syncing"
case .error: return "Error"
case .synced: return "Synced"
}
}
private var tooltipText: LocalizedStringKey {
switch syncState {
case .notConfigured: return "Sync not configured"
case .syncing: return "Syncing..."
case .error(let msg): return "Sync failed: \(msg)"
case .synced(let date):
guard let date else { return "Synced" }
let rel = RelativeDateTimeFormatter().localizedString(for: date, relativeTo: .now)
return "Last synced: \(rel)"
}
}
var body: some View {
HStack(spacing: 3) {
@@ -227,58 +249,21 @@ struct SyncStatusPill: View {
.padding(.vertical, 2)
.background(syncColor.opacity(0.1), in: Capsule())
.help(tooltipText)
.onAppear {
updateState()
}
.onChange(of: gitSync.syncStatus) {
updateState()
}
.onChange(of: gitSync.isSyncing) {
updateState()
}
.onChange(of: gitSync.lastSyncError) {
updateState()
}
.onAppear { updateState() }
.onChange(of: gitSync.syncStatus) { updateState() }
.onChange(of: gitSync.isSyncing) { updateState() }
.onChange(of: gitSync.lastSyncError) { updateState() }
}
private func updateState() {
// Determine sync state
if let error = gitSync.lastSyncError {
syncColor = .red
syncLabel = "Error"
tooltipText = "Sync failed: \(error)"
syncState = .error(error); syncColor = .red
} else if gitSync.isSyncing {
syncColor = .orange
syncLabel = "Syncing"
tooltipText = "Syncing..."
syncState = .syncing; syncColor = .orange
} else if gitSync.syncStatus.isCloned {
syncColor = .green
syncLabel = "Synced"
if let lastSync = gitSync.syncStatus.lastSyncTime {
tooltipText = "Last synced: \(timeAgo(lastSync))"
} else {
tooltipText = "Synced"
}
syncState = .synced(gitSync.syncStatus.lastSyncTime); syncColor = .green
} else {
syncColor = .secondary
syncLabel = "Sync"
tooltipText = "Sync not configured"
}
}
private func timeAgo(_ date: Date) -> String {
let seconds = Int(Date().timeIntervalSince(date))
if seconds < 60 {
return "just now"
} else if seconds < 3600 {
let minutes = seconds / 60
return "\(minutes)m ago"
} else if seconds < 86400 {
let hours = seconds / 3600
return "\(hours)h ago"
} else {
let days = seconds / 86400
return "\(days)d ago"
syncState = .notConfigured; syncColor = .secondary
}
}
}

View File

@@ -271,7 +271,7 @@ struct CommandSuggestionsView: View {
let selectedIndex: Int
let onSelect: (String) -> Void
static let builtInCommands: [(command: String, description: String)] = [
static let builtInCommands: [(command: String, description: LocalizedStringKey)] = [
("/help", "Show help and available commands"),
("/history", "View command history"),
("/model", "Select AI model"),
@@ -302,19 +302,19 @@ struct CommandSuggestionsView: View {
("/mcp write off", "Disable MCP write permissions"),
]
static func allCommands() -> [(command: String, description: String)] {
static func allCommands() -> [(command: String, description: LocalizedStringKey)] {
let shortcuts = SettingsService.shared.userShortcuts.map { s in
(s.command, "\(s.description)")
(s.command, LocalizedStringKey("\(s.description)"))
}
return builtInCommands + shortcuts
}
static func filteredCommands(for searchText: String) -> [(command: String, description: String)] {
static func filteredCommands(for searchText: String) -> [(command: String, description: LocalizedStringKey)] {
let search = searchText.lowercased()
return allCommands().filter { $0.command.contains(search) || search == "/" }
}
private var suggestions: [(command: String, description: String)] {
private var suggestions: [(command: String, description: LocalizedStringKey)] {
Self.filteredCommands(for: searchText)
}

View File

@@ -34,6 +34,7 @@ struct MessageRow: View {
private let settings = SettingsService.shared
@State private var isExpanded = false
@State private var isThinkingExpanded = true // auto-expand while streaming, collapse after
#if os(macOS)
@State private var isHovering = false
@@ -107,6 +108,27 @@ struct MessageRow: View {
.foregroundColor(.oaiSecondary)
}
// Thinking / reasoning block (collapsible)
if let thinking = message.thinkingContent, !thinking.isEmpty {
thinkingBlock(thinking)
.onChange(of: message.content) { _, newContent in
// Auto-collapse when response content starts arriving
if !newContent.isEmpty && isThinkingExpanded && !message.isStreaming {
withAnimation(.easeInOut(duration: 0.2)) {
isThinkingExpanded = false
}
}
}
.onChange(of: message.isStreaming) { _, streaming in
// Collapse when streaming finishes
if !streaming && !message.content.isEmpty {
withAnimation(.easeInOut(duration: 0.3)) {
isThinkingExpanded = false
}
}
}
}
// Content
if !message.content.isEmpty {
messageContent
@@ -186,6 +208,64 @@ struct MessageRow: View {
// Close standardMessageLayout - the above closing braces close it
// The body: some View now handles the split between compact and standard
// MARK: - Thinking Block
@ViewBuilder
private func thinkingBlock(_ thinking: String) -> some View {
VStack(alignment: .leading, spacing: 0) {
// Header button
Button(action: {
withAnimation(.easeInOut(duration: 0.18)) { isThinkingExpanded.toggle() }
}) {
HStack(spacing: 6) {
if message.isStreaming && message.content.isEmpty {
ProgressView()
.scaleEffect(0.5)
.frame(width: 12, height: 12)
Text("Thinking…")
.font(.system(size: 11))
.foregroundStyle(.secondary)
} else {
Image(systemName: "brain")
.font(.system(size: 11))
.foregroundStyle(.secondary)
Text("Reasoning")
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: isThinkingExpanded ? "chevron.up" : "chevron.down")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.secondary.opacity(0.5))
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if isThinkingExpanded {
Divider()
.padding(.horizontal, 6)
ScrollView {
Text(thinking)
.font(.system(size: 12))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
}
.frame(maxHeight: 220)
}
}
.background(Color.secondary.opacity(0.07))
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(Color.secondary.opacity(0.15), lineWidth: 1)
)
}
// MARK: - Compact System Message
@ViewBuilder

View File

@@ -38,7 +38,7 @@ struct AgentSkillsView: View {
@Bindable private var settings = SettingsService.shared
@State private var editContext: SkillEditContext? = nil
@State private var statusMessage: String? = nil
@State private var statusMessage: LocalizedStringKey? = nil
private var activeCount: Int { settings.agentSkills.filter { $0.isActive }.count }
@@ -166,7 +166,7 @@ struct AgentSkillsView: View {
if importMarkdown(url) { imported += 1 }
}
}
if imported > 0 { show("Imported \(imported) skill\(imported == 1 ? "" : "s")") }
if imported > 0 { show("^[\(imported) skill](inflect: true) imported") }
}
@discardableResult
@@ -256,7 +256,7 @@ struct AgentSkillsView: View {
}
exported += 1
}
show("Exported \(exported) skill\(exported == 1 ? "" : "s") to Downloads")
show("^[\(exported) skill](inflect: true) exported to Downloads")
}
private func exportOne(_ skill: AgentSkill) {
@@ -335,7 +335,7 @@ struct AgentSkillsView: View {
return ""
}
private func show(_ text: String) {
private func show(_ text: LocalizedStringKey) {
statusMessage = text
Task { try? await Task.sleep(for: .seconds(3)); statusMessage = nil }
}
@@ -380,7 +380,7 @@ private struct AgentSkillRow: View {
// File count badge
if fileCount > 0 {
Label("\(fileCount) file\(fileCount == 1 ? "" : "s")", systemImage: "doc")
Label("^[\(fileCount) file](inflect: true)", systemImage: "doc")
.font(.caption2).foregroundStyle(.secondary)
.padding(.horizontal, 6).padding(.vertical, 3)
.background(.blue.opacity(0.1), in: Capsule())
@@ -411,7 +411,7 @@ struct AgentSkillsTabContent: View {
@Bindable private var settings = SettingsService.shared
@State private var editContext: SkillEditContext? = nil
@State private var statusMessage: String? = nil
@State private var statusMessage: LocalizedStringKey? = nil
private var activeCount: Int { settings.agentSkills.filter { $0.isActive }.count }
@@ -431,7 +431,7 @@ struct AgentSkillsTabContent: View {
.background(.purple.opacity(0.07), in: RoundedRectangle(cornerRadius: 8))
if activeCount > 0 {
Label("\(activeCount) skill\(activeCount == 1 ? "" : "s") active — appended to every system prompt", systemImage: "checkmark.circle.fill")
Label("^[\(activeCount) skill](inflect: true) active — appended to every system prompt", systemImage: "checkmark.circle.fill")
.font(.caption).foregroundStyle(.green)
}
@@ -516,7 +516,7 @@ struct AgentSkillsTabContent: View {
if importMarkdown(url) { imported += 1 }
}
}
if imported > 0 { show("Imported \(imported) skill\(imported == 1 ? "" : "s")") }
if imported > 0 { show("^[\(imported) skill](inflect: true) imported") }
}
@discardableResult
@@ -602,7 +602,7 @@ struct AgentSkillsTabContent: View {
}
exported += 1
}
show("Exported \(exported) skill\(exported == 1 ? "" : "s") to Downloads")
show("^[\(exported) skill](inflect: true) exported to Downloads")
}
private func exportOne(_ skill: AgentSkill) {
@@ -671,7 +671,7 @@ struct AgentSkillsTabContent: View {
return ""
}
private func show(_ text: String) {
private func show(_ text: LocalizedStringKey) {
statusMessage = text
Task { try? await Task.sleep(for: .seconds(3)); statusMessage = nil }
}

View File

@@ -155,7 +155,7 @@ struct CreditsView: View {
}
struct CreditRow: View {
let label: String
let label: LocalizedStringKey
let value: String
var highlight: Bool = false

View File

@@ -190,7 +190,7 @@ struct EmailLogView: View {
private var bottomActions: some View {
HStack {
Text("\(logs.count) \(logs.count == 1 ? "entry" : "entries")")
Text("^[\(logs.count) entry](inflect: true)")
.font(.system(size: 13))
.foregroundColor(.secondary)
@@ -327,7 +327,7 @@ struct EmailLogRow: View {
// MARK: - Stat Item
struct EmailStatItem: View {
let title: String
let title: LocalizedStringKey
let value: String
let color: Color

View File

@@ -38,7 +38,7 @@ struct CommandDetail: Identifiable {
struct CommandCategory: Identifiable {
let id = UUID()
let name: String
let name: LocalizedStringKey
let icon: String
let commands: [CommandDetail]
}
@@ -358,7 +358,7 @@ struct HelpView: View {
.padding(.vertical, 4)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 5))
Spacer()
Text(shortcut.description)
Text(LocalizedStringKey(shortcut.description))
.font(.callout)
.foregroundStyle(.secondary)
}
@@ -463,7 +463,7 @@ private struct CommandRow: View {
.font(.system(size: 13, weight: .medium, design: .monospaced))
.foregroundStyle(.primary)
Text(command.brief)
Text(LocalizedStringKey(command.brief))
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -493,14 +493,14 @@ private struct CommandRow: View {
// Expanded detail
if isExpanded {
VStack(alignment: .leading, spacing: 10) {
Text(command.detail)
Text(LocalizedStringKey(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")
Text(command.examples.count == 1 ? "Example" as LocalizedStringKey : "Examples")
.font(.caption)
.foregroundStyle(.secondary)
.fontWeight(.medium)

View File

@@ -132,6 +132,7 @@ struct ModelInfoView: View {
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)
capabilityBadge(icon: "brain", label: "Thinking", active: model.capabilities.thinking)
}
// Architecture (if available)
@@ -172,14 +173,14 @@ struct ModelInfoView: View {
// MARK: - Layout Helpers
private func sectionHeader(_ title: String) -> some View {
private func sectionHeader(_ title: LocalizedStringKey) -> some View {
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
}
private func infoRow(_ label: String, _ value: String) -> some View {
private func infoRow(_ label: LocalizedStringKey, _ value: String) -> some View {
HStack {
Text(label)
.font(.body)
@@ -193,7 +194,7 @@ struct ModelInfoView: View {
}
@ViewBuilder
private func costExample(label: String, inputTokens: Int) -> some View {
private func costExample(label: LocalizedStringKey, 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) {
@@ -211,7 +212,7 @@ struct ModelInfoView: View {
}
@ViewBuilder
private func capabilityBadge(icon: String, label: String, active: Bool) -> some View {
private func capabilityBadge(icon: String, label: LocalizedStringKey, active: Bool) -> some View {
VStack(spacing: 4) {
Image(systemName: icon)
.font(.title3)

View File

@@ -36,20 +36,37 @@ struct ModelSelectorView: View {
@State private var filterTools = false
@State private var filterOnline = false
@State private var filterImageGen = false
@State private var filterThinking = false
@State private var keyboardIndex: Int = -1
@State private var sortOrder: ModelSortOrder = .default
@State private var selectedInfoModel: ModelInfo? = nil
private var filteredModels: [ModelInfo] {
models.filter { model in
let q = searchText.lowercased()
let filtered = models.filter { model in
let matchesSearch = searchText.isEmpty ||
model.name.lowercased().contains(searchText.lowercased()) ||
model.id.lowercased().contains(searchText.lowercased())
model.name.lowercased().contains(q) ||
model.id.lowercased().contains(q) ||
model.description?.lowercased().contains(q) == true
let matchesVision = !filterVision || model.capabilities.vision
let matchesTools = !filterTools || model.capabilities.tools
let matchesOnline = !filterOnline || model.capabilities.online
let matchesImageGen = !filterImageGen || model.capabilities.imageGeneration
let matchesThinking = !filterThinking || model.capabilities.thinking
return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen
return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen && matchesThinking
}
switch sortOrder {
case .default:
return filtered
case .priceLowHigh:
return filtered.sorted { $0.pricing.prompt < $1.pricing.prompt }
case .priceHighLow:
return filtered.sorted { $0.pricing.prompt > $1.pricing.prompt }
case .contextHighLow:
return filtered.sorted { $0.contextLength > $1.contextLength }
}
}
@@ -61,16 +78,47 @@ struct ModelSelectorView: View {
.textFieldStyle(.roundedBorder)
.padding()
.onChange(of: searchText) {
// Reset keyboard index when search changes
keyboardIndex = -1
}
// Filters
// Filters + Sort
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")
FilterToggle(isOn: $filterThinking, icon: "\u{1F9E0}", label: "Thinking")
Spacer()
// Sort menu
Menu {
ForEach(ModelSortOrder.allCases, id: \.rawValue) { order in
Button {
sortOrder = order
keyboardIndex = -1
} label: {
if sortOrder == order {
Label(order.label, systemImage: "checkmark")
} else {
Text(order.label)
}
}
}
} label: {
HStack(spacing: 4) {
Image(systemName: "arrow.up.arrow.down")
Text("Sort")
}
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(sortOrder != .default ? Color.blue.opacity(0.2) : Color.gray.opacity(0.1))
.foregroundColor(sortOrder != .default ? .blue : .secondary)
.cornerRadius(6)
}
.menuStyle(.borderlessButton)
.fixedSize()
}
.padding(.horizontal)
.padding(.bottom, 12)
@@ -91,13 +139,11 @@ struct ModelSelectorView: View {
ModelRowView(
model: model,
isSelected: model.id == selectedModel?.id,
isKeyboardHighlighted: index == keyboardIndex
isKeyboardHighlighted: index == keyboardIndex,
onSelect: { onSelect(model) },
onInfo: { selectedInfoModel = model }
)
.id(model.id)
.contentShape(Rectangle())
.onTapGesture {
onSelect(model)
}
}
.listStyle(.plain)
.onChange(of: keyboardIndex) { _, newIndex in
@@ -143,20 +189,42 @@ struct ModelSelectorView: View {
}
}
.onAppear {
// Initialize keyboard index to current selection
if let selected = selectedModel,
let index = filteredModels.firstIndex(where: { $0.id == selected.id }) {
keyboardIndex = index
}
}
.sheet(item: $selectedInfoModel) { model in
ModelInfoView(model: model)
}
}
}
}
// MARK: - Sort Order
enum ModelSortOrder: String, CaseIterable {
case `default`
case priceLowHigh
case priceHighLow
case contextHighLow
var label: LocalizedStringKey {
switch self {
case .default: return "Default"
case .priceLowHigh: return "Price: Low to High"
case .priceHighLow: return "Price: High to Low"
case .contextHighLow: return "Context: High to Low"
}
}
}
// MARK: - Filter Toggle
struct FilterToggle: View {
@Binding var isOn: Bool
let icon: String
let label: String
let label: LocalizedStringKey
var body: some View {
Button(action: { isOn.toggle() }) {
@@ -175,51 +243,74 @@ struct FilterToggle: View {
}
}
// MARK: - Model Row
struct ModelRowView: View {
let model: ModelInfo
let isSelected: Bool
var isKeyboardHighlighted: Bool = false
let onSelect: () -> Void
let onInfo: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(model.name)
.font(.headline)
.foregroundColor(isSelected ? .blue : .primary)
HStack(alignment: .top, spacing: 8) {
// Selectable main content
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(model.name)
.font(.headline)
.foregroundColor(isSelected ? .blue : .primary)
Spacer()
if isSelected {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
}
}
// Capabilities
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)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture { onSelect() }
// Right side: capabilities + info button
VStack(alignment: .trailing, spacing: 6) {
// Capability icons
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 model.capabilities.thinking { Text("\u{1F9E0}").font(.caption) }
}
if isSelected {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
// Info button
Button(action: onInfo) {
Image(systemName: "info.circle")
.font(.caption)
.foregroundColor(.secondary)
.frame(width: 20, height: 20)
}
.buttonStyle(.plain)
.help("Show model info")
}
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(.top, 2)
}
.padding(.vertical, 6)
.padding(.horizontal, isKeyboardHighlighted ? 4 : 0)

View File

@@ -142,7 +142,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
tabButton(7, icon: "brain", label: "Skills")
tabButton(4, icon: "arrow.triangle.2.circlepath", label: "Sync")
tabButton(5, icon: "envelope", label: "Email")
tabButton(8, icon: "doc.text", label: "Paperless")
tabButton(8, icon: "doc.text", label: "Paperless", beta: true)
tabButton(9, icon: "icloud.and.arrow.up", label: "Backup")
}
.padding(.horizontal, 16)
@@ -283,10 +283,42 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
.toggleStyle(.switch)
}
rowDivider()
row("MCP (File Access)") {
Toggle("", isOn: $settingsService.mcpEnabled)
row("Reasoning (Thinking)") {
Toggle("", isOn: $settingsService.reasoningEnabled)
.toggleStyle(.switch)
}
if settingsService.reasoningEnabled {
rowDivider()
row("Reasoning Effort") {
Picker("", selection: $settingsService.reasoningEffort) {
Text("High (~80%)").tag("high")
Text("Medium (~50%)").tag("medium")
Text("Low (~20%)").tag("low")
Text("Minimal (~10%)").tag("minimal")
}
.labelsHidden()
.fixedSize()
}
VStack(alignment: .leading, spacing: 2) {
Text(reasoningEffortDescription)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.bottom, 4)
rowDivider()
row("Hide Reasoning in Response") {
Toggle("", isOn: $settingsService.reasoningExclude)
.toggleStyle(.switch)
}
VStack(alignment: .leading, spacing: 2) {
Text("Model thinks internally but reasoning is not shown in chat")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.bottom, 4)
}
}
}
@@ -2050,13 +2082,25 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
// MARK: - Tab Navigation
private func tabButton(_ tag: Int, icon: String, label: String) -> some View {
private func tabButton(_ tag: Int, icon: String, label: LocalizedStringKey, beta: Bool = false) -> some View {
Button(action: { selectedTab = tag }) {
VStack(spacing: 3) {
Image(systemName: icon)
.font(.system(size: 22))
.frame(height: 28)
.foregroundStyle(selectedTab == tag ? .blue : .secondary)
ZStack(alignment: .topTrailing) {
Image(systemName: icon)
.font(.system(size: 22))
.frame(height: 28)
.foregroundStyle(selectedTab == tag ? .blue : .secondary)
if beta {
Text("β")
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.white)
.padding(.horizontal, 3)
.padding(.vertical, 1)
.background(Color.orange)
.clipShape(Capsule())
.offset(x: 6, y: -2)
}
}
Text(label)
.font(.system(size: 11))
.foregroundStyle(selectedTab == tag ? .blue : .secondary)
@@ -2070,7 +2114,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
.buttonStyle(.plain)
}
private func tabTitle(_ tag: Int) -> String {
private func tabTitle(_ tag: Int) -> LocalizedStringKey {
switch tag {
case 0: return "General"
case 1: return "MCP"
@@ -2086,13 +2130,23 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
}
}
// MARK: - Reasoning Helpers
private var reasoningEffortDescription: LocalizedStringKey {
switch settingsService.reasoningEffort {
case "high": return "Uses ~80% of max tokens for reasoning — best for hard problems"
case "medium": return "Uses ~50% of max tokens for reasoning — balanced default"
case "low": return "Uses ~20% of max tokens for reasoning — faster, cheaper"
case "minimal": return "Uses ~10% of max tokens for reasoning — lightest thinking"
default: return "Uses ~50% of max tokens for reasoning — balanced default"
}
}
// MARK: - Layout Helpers
private func row<Content: View>(_ label: String, @ViewBuilder content: () -> Content) -> some View {
private func row<Content: View>(_ label: LocalizedStringKey, @ViewBuilder content: () -> Content) -> some View {
HStack(alignment: .center, spacing: 12) {
if !label.isEmpty {
Text(label).font(.system(size: 14))
}
Text(label).font(.system(size: 14))
Spacer()
content()
}
@@ -2100,7 +2154,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
.padding(.vertical, 10)
}
private func sectionHeader(_ title: String) -> some View {
private func sectionHeader(_ title: LocalizedStringKey) -> some View {
Text(title)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
@@ -2205,7 +2259,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
return .green
}
private var syncStatusText: String {
private var syncStatusText: LocalizedStringKey {
guard settingsService.syncEnabled else { return "Disabled" }
guard settingsService.syncConfigured else { return "Not configured" }
guard gitSync.syncStatus.isCloned else { return "Not cloned" }
@@ -2318,19 +2372,9 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
}
private func timeAgo(_ date: Date) -> String {
let seconds = Int(Date().timeIntervalSince(date))
if seconds < 60 {
return "just now"
} else if seconds < 3600 {
let minutes = seconds / 60
return "\(minutes) minute\(minutes == 1 ? "" : "s") ago"
} else if seconds < 86400 {
let hours = seconds / 3600
return "\(hours) hour\(hours == 1 ? "" : "s") ago"
} else {
let days = seconds / 86400
return "\(days) day\(days == 1 ? "" : "s") ago"
}
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .full
return formatter.localizedString(for: date, relativeTo: .now)
}
}

View File

@@ -120,7 +120,7 @@ struct StatsView: View {
}
struct StatRow: View {
let label: String
let label: LocalizedStringKey
let value: String
var body: some View {
@@ -137,7 +137,7 @@ struct StatRow: View {
struct CapabilityBadge: View {
let icon: String
let label: String
let label: LocalizedStringKey
var body: some View {
HStack(spacing: 2) {