Bug gixes, features added, GUI updates and more

This commit is contained in:
2026-02-12 14:29:35 +01:00
parent 52447b5e17
commit 7265d22438
21 changed files with 2187 additions and 123 deletions

View File

@@ -60,6 +60,8 @@ struct ChatView: View {
// Input bar
InputBar(
text: $viewModel.inputText,
commandHistory: $viewModel.commandHistory,
historyIndex: $viewModel.historyIndex,
isGenerating: viewModel.isGenerating,
mcpStatus: viewModel.mcpStatus,
onlineMode: viewModel.onlineMode,

View File

@@ -42,6 +42,7 @@ struct ContentView: View {
selectedModel: chatViewModel.selectedModel,
onSelect: { model in
chatViewModel.selectedModel = model
SettingsService.shared.defaultModel = model.id
chatViewModel.showModelSelector = false
}
)
@@ -77,6 +78,11 @@ struct ContentView: View {
.sheet(item: $vm.modelInfoTarget) { model in
ModelInfoView(model: model)
}
.sheet(isPresented: $vm.showHistory) {
HistoryView(onSelect: { input in
chatViewModel.inputText = input
})
}
}
#if os(macOS)
@@ -91,11 +97,17 @@ struct ContentView: View {
.help("New conversation")
Button(action: { chatViewModel.showConversations = true }) {
Label("History", systemImage: "clock.arrow.circlepath")
Label("Conversations", systemImage: "clock.arrow.circlepath")
}
.keyboardShortcut("l", modifiers: .command)
.help("Saved conversations (Cmd+L)")
Button(action: { chatViewModel.showHistory = true }) {
Label("History", systemImage: "list.bullet")
}
.keyboardShortcut("h", modifiers: .command)
.help("Command history (Cmd+H)")
Spacer()
Button(action: { chatViewModel.showModelSelector = true }) {

View File

@@ -9,6 +9,8 @@ import SwiftUI
struct InputBar: View {
@Binding var text: String
@Binding var commandHistory: [String]
@Binding var historyIndex: Int
let isGenerating: Bool
let mcpStatus: String?
let onlineMode: Bool
@@ -22,7 +24,7 @@ struct InputBar: View {
/// Commands that execute immediately without additional arguments
private static let immediateCommands: Set<String> = [
"/help", "/model", "/clear", "/retry", "/stats", "/config",
"/help", "/history", "/model", "/clear", "/retry", "/stats", "/config",
"/settings", "/credits", "/list", "/load",
"/memory on", "/memory off", "/online on", "/online off",
"/mcp on", "/mcp off", "/mcp status", "/mcp list",
@@ -79,28 +81,56 @@ struct InputBar: View {
.onChange(of: text) {
showCommandDropdown = text.hasPrefix("/")
selectedSuggestionIndex = 0
// Reset history index when user types
historyIndex = commandHistory.count
}
#if os(macOS)
.onKeyPress(.upArrow) {
guard showCommandDropdown else { return .ignored }
if selectedSuggestionIndex > 0 {
selectedSuggestionIndex -= 1
// If command dropdown is showing, navigate dropdown
if showCommandDropdown {
if selectedSuggestionIndex > 0 {
selectedSuggestionIndex -= 1
}
return .handled
}
// Otherwise, navigate command history
if historyIndex > 0 {
historyIndex -= 1
text = commandHistory[historyIndex]
}
return .handled
}
.onKeyPress(.downArrow) {
guard showCommandDropdown else { return .ignored }
let count = CommandSuggestionsView.filteredCommands(for: text).count
if selectedSuggestionIndex < count - 1 {
selectedSuggestionIndex += 1
// If command dropdown is showing, navigate dropdown
if showCommandDropdown {
let count = CommandSuggestionsView.filteredCommands(for: text).count
if selectedSuggestionIndex < count - 1 {
selectedSuggestionIndex += 1
}
return .handled
}
// Otherwise, navigate command history
if historyIndex < commandHistory.count - 1 {
historyIndex += 1
text = commandHistory[historyIndex]
} else if historyIndex == commandHistory.count - 1 {
// At the end of history, clear text and move to "new" position
historyIndex = commandHistory.count
text = ""
}
return .handled
}
.onKeyPress(.escape) {
// If command dropdown is showing, close it
if showCommandDropdown {
showCommandDropdown = false
return .handled
}
// If model is generating, cancel it
if isGenerating {
onCancel()
return .handled
}
return .ignored
}
.onKeyPress(.return) {
@@ -227,6 +257,7 @@ struct CommandSuggestionsView: View {
static let allCommands: [(command: String, description: String)] = [
("/help", "Show help and available commands"),
("/history", "View command history"),
("/model", "Select AI model"),
("/clear", "Clear chat history"),
("/retry", "Retry last message"),
@@ -316,6 +347,8 @@ struct CommandSuggestionsView: View {
Spacer()
InputBar(
text: .constant(""),
commandHistory: .constant([]),
historyIndex: .constant(0),
isGenerating: false,
mcpStatus: "📁 Files",
onlineMode: true,

View File

@@ -16,7 +16,7 @@ struct MarkdownContentView: View {
var body: some View {
let segments = parseSegments(content)
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 12) {
ForEach(segments.indices, id: \.self) { index in
switch segments[index] {
case .text(let text):
@@ -25,6 +25,8 @@ struct MarkdownContentView: View {
}
case .codeBlock(let language, let code):
CodeBlockView(language: language, code: code, fontSize: fontSize)
case .table(let tableText):
TableView(content: tableText, fontSize: fontSize)
}
}
}
@@ -32,16 +34,18 @@ struct MarkdownContentView: View {
@ViewBuilder
private func markdownText(_ text: String) -> some View {
if let attrString = try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) {
if let attrString = try? AttributedString(markdown: text, options: .init(interpretedSyntax: .full)) {
Text(attrString)
.font(.system(size: fontSize))
.foregroundColor(.oaiPrimary)
.lineSpacing(4)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
} else {
Text(text)
.font(.system(size: fontSize))
.foregroundColor(.oaiPrimary)
.lineSpacing(4)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
@@ -52,6 +56,7 @@ struct MarkdownContentView: View {
enum Segment {
case text(String)
case codeBlock(language: String?, code: String)
case table(String)
}
private func parseSegments(_ content: String) -> [Segment] {
@@ -61,10 +66,17 @@ struct MarkdownContentView: View {
var inCodeBlock = false
var codeLanguage: String? = nil
var codeContent = ""
var inTable = false
var tableLines: [String] = []
for line in lines {
if !inCodeBlock && line.hasPrefix("```") {
// Start of code block
if inTable {
segments.append(.table(tableLines.joined(separator: "\n")))
tableLines = []
inTable = false
}
if !currentText.isEmpty {
segments.append(.text(currentText))
currentText = ""
@@ -75,7 +87,6 @@ struct MarkdownContentView: View {
codeContent = ""
} else if inCodeBlock && line.hasPrefix("```") {
// End of code block
// Remove trailing newline from code
if codeContent.hasSuffix("\n") {
codeContent = String(codeContent.dropLast())
}
@@ -85,7 +96,25 @@ struct MarkdownContentView: View {
codeContent = ""
} else if inCodeBlock {
codeContent += line + "\n"
} else if line.contains("|") && (line.hasPrefix("|") || line.filter({ $0 == "|" }).count >= 2) {
// Markdown table line
if !inTable {
// Start of table
if !currentText.isEmpty {
segments.append(.text(currentText))
currentText = ""
}
inTable = true
}
tableLines.append(line)
} else {
// Regular text
if inTable {
// End of table
segments.append(.table(tableLines.joined(separator: "\n")))
tableLines = []
inTable = false
}
currentText += line + "\n"
}
}
@@ -98,9 +127,13 @@ struct MarkdownContentView: View {
segments.append(.codeBlock(language: codeLanguage, code: codeContent))
}
// Handle unclosed table
if inTable {
segments.append(.table(tableLines.joined(separator: "\n")))
}
// Remaining text
if !currentText.isEmpty {
// Remove trailing newline
if currentText.hasSuffix("\n") {
currentText = String(currentText.dropLast())
}
@@ -113,6 +146,136 @@ struct MarkdownContentView: View {
}
}
// MARK: - Table View
struct TableView: View {
let content: String
let fontSize: Double
private struct TableData {
let headers: [String]
let alignments: [TextAlignment]
let rows: [[String]]
}
private var tableData: TableData {
parseTable(content)
}
var body: some View {
let data = tableData
guard !data.headers.isEmpty else {
return AnyView(EmptyView())
}
return AnyView(
VStack(alignment: .leading, spacing: 0) {
// Headers
HStack(spacing: 0) {
ForEach(data.headers.indices, id: \.self) { index in
if index < data.headers.count {
Text(data.headers[index].trimmingCharacters(in: .whitespaces))
.font(.system(size: fontSize, weight: .semibold))
.foregroundColor(.oaiPrimary)
.frame(maxWidth: .infinity, alignment: alignmentFor(
index < data.alignments.count ? data.alignments[index] : .leading
))
.padding(8)
.background(Color.oaiSecondary.opacity(0.1))
if index < data.headers.count - 1 {
Divider()
}
}
}
}
.overlay(
Rectangle()
.stroke(Color.oaiSecondary.opacity(0.3), lineWidth: 1)
)
// Rows
ForEach(data.rows.indices, id: \.self) { rowIndex in
if rowIndex < data.rows.count {
HStack(spacing: 0) {
ForEach(0..<data.headers.count, id: \.self) { colIndex in
let cellContent = colIndex < data.rows[rowIndex].count ? data.rows[rowIndex][colIndex] : ""
let alignment = colIndex < data.alignments.count ? data.alignments[colIndex] : .leading
Text(cellContent.trimmingCharacters(in: .whitespaces))
.font(.system(size: fontSize))
.foregroundColor(.oaiPrimary)
.frame(maxWidth: .infinity, alignment: alignmentFor(alignment))
.padding(8)
if colIndex < data.headers.count - 1 {
Divider()
}
}
}
.background(rowIndex % 2 == 0 ? Color.clear : Color.oaiSecondary.opacity(0.05))
.overlay(
Rectangle()
.stroke(Color.oaiSecondary.opacity(0.3), lineWidth: 1)
)
}
}
}
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color.oaiSecondary.opacity(0.3), lineWidth: 1)
)
)
}
private func alignmentFor(_ textAlignment: TextAlignment) -> Alignment {
switch textAlignment {
case .leading: return .leading
case .center: return .center
case .trailing: return .trailing
}
}
private func parseTable(_ content: String) -> TableData {
let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty }
guard lines.count >= 2 else {
return TableData(headers: [], alignments: [], rows: [])
}
// Parse headers (first line)
let headers = lines[0].components(separatedBy: "|")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
// Parse alignment row (second line with dashes)
let alignmentLine = lines[1]
let alignments = alignmentLine.components(separatedBy: "|")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
.map { cell -> TextAlignment in
let trimmed = cell.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix(":") && trimmed.hasSuffix(":") {
return .center
} else if trimmed.hasSuffix(":") {
return .trailing
} else {
return .leading
}
}
// Parse data rows (remaining lines)
let rows = lines.dropFirst(2).map { line in
line.components(separatedBy: "|")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
}
return TableData(headers: headers, alignments: alignments, rows: rows)
}
}
// MARK: - Code Block View
struct CodeBlockView: View {

View File

@@ -84,19 +84,38 @@ struct MessageRow: View {
.padding(.top, 4)
}
// Token/cost info
if let tokens = message.tokens, let cost = message.cost {
// Token/cost/time info
if message.role == .assistant && (message.tokens != nil || message.cost != nil || message.responseTime != nil || message.wasInterrupted) {
HStack(spacing: 8) {
Label("\(tokens)", systemImage: "chart.bar.xaxis")
Text("\u{2022}")
Text(String(format: "$%.4f", cost))
if let tokens = message.tokens {
Label("\(tokens)", systemImage: "chart.bar.xaxis")
if message.cost != nil || message.responseTime != nil || message.wasInterrupted {
Text("\u{2022}")
}
}
if let cost = message.cost {
Text(String(format: "$%.4f", cost))
if message.responseTime != nil || message.wasInterrupted {
Text("\u{2022}")
}
}
if let responseTime = message.responseTime {
Text(String(format: "%.1fs", responseTime))
if message.wasInterrupted {
Text("\u{2022}")
}
}
if message.wasInterrupted {
Text("⚠️ interrupted")
.foregroundColor(.orange)
}
}
.font(.caption2)
.foregroundColor(.oaiSecondary)
}
}
}
.padding(12)
.padding(16)
.background(Color.messageBackground(for: message.role))
.cornerRadius(8)
.overlay(

View File

@@ -12,6 +12,8 @@ struct ConversationListView: View {
@Environment(\.dismiss) var dismiss
@State private var searchText = ""
@State private var conversations: [Conversation] = []
@State private var selectedConversations: Set<UUID> = []
@State private var isSelecting = false
var onLoad: ((Conversation) -> Void)?
private var filteredConversations: [Conversation] {
@@ -30,13 +32,42 @@ struct ConversationListView: View {
Text("Conversations")
.font(.system(size: 18, weight: .bold))
Spacer()
Button { dismiss() } label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.secondary)
if isSelecting {
Button("Cancel") {
isSelecting = false
selectedConversations.removeAll()
}
.buttonStyle(.plain)
if !selectedConversations.isEmpty {
Button(role: .destructive) {
deleteSelected()
} label: {
HStack(spacing: 4) {
Image(systemName: "trash")
Text("Delete (\(selectedConversations.count))")
}
}
.buttonStyle(.plain)
.foregroundStyle(.red)
}
} else {
if !conversations.isEmpty {
Button("Select") {
isSelecting = true
}
.buttonStyle(.plain)
}
Button { dismiss() } label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.keyboardShortcut(.escape, modifiers: [])
}
.buttonStyle(.plain)
.keyboardShortcut(.escape, modifiers: [])
}
.padding(.horizontal, 24)
.padding(.top, 20)
@@ -81,26 +112,57 @@ struct ConversationListView: View {
} 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)
HStack(spacing: 12) {
if isSelecting {
Button {
toggleSelection(conversation.id)
} label: {
Label("Delete", systemImage: "trash")
Image(systemName: selectedConversations.contains(conversation.id) ? "checkmark.circle.fill" : "circle")
.foregroundStyle(selectedConversations.contains(conversation.id) ? .blue : .secondary)
.font(.title2)
}
.buttonStyle(.plain)
}
ConversationRow(conversation: conversation)
.contentShape(Rectangle())
.onTapGesture {
if isSelecting {
toggleSelection(conversation.id)
} else {
onLoad?(conversation)
dismiss()
}
}
Spacer()
if !isSelecting {
Button {
exportConversation(conversation)
deleteConversation(conversation)
} label: {
Label("Export", systemImage: "square.and.arrow.up")
Image(systemName: "trash")
.foregroundStyle(.red)
.font(.system(size: 16))
}
.tint(.blue)
.buttonStyle(.plain)
.help("Delete conversation")
}
}
.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)
@@ -123,7 +185,7 @@ struct ConversationListView: View {
.onAppear {
loadConversations()
}
.frame(minWidth: 500, minHeight: 400)
.frame(minWidth: 700, idealWidth: 800, minHeight: 500, idealHeight: 600)
}
private func loadConversations() {
@@ -135,6 +197,29 @@ struct ConversationListView: View {
}
}
private func toggleSelection(_ id: UUID) {
if selectedConversations.contains(id) {
selectedConversations.remove(id)
} else {
selectedConversations.insert(id)
}
}
private func deleteSelected() {
for id in selectedConversations {
do {
let _ = try DatabaseService.shared.deleteConversation(id: id)
} catch {
Log.db.error("Failed to delete conversation: \(error.localizedDescription)")
}
}
withAnimation {
conversations.removeAll { selectedConversations.contains($0.id) }
selectedConversations.removeAll()
isSelecting = false
}
}
private func deleteConversation(_ conversation: Conversation) {
do {
let _ = try DatabaseService.shared.deleteConversation(id: conversation.id)
@@ -167,20 +252,28 @@ struct ConversationListView: View {
struct ConversationRow: View {
let conversation: Conversation
private var formattedDate: String {
let formatter = DateFormatter()
formatter.dateFormat = "dd.MM.yyyy HH:mm:ss"
return formatter.string(from: conversation.updatedAt)
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
VStack(alignment: .leading, spacing: 8) {
Text(conversation.name)
.font(.headline)
.font(.system(size: 16, weight: .semibold))
HStack(spacing: 8) {
Label("\(conversation.messageCount)", systemImage: "message")
.font(.system(size: 13))
Text("\u{2022}")
Text(conversation.updatedAt, style: .relative)
.font(.system(size: 13))
Text(formattedDate)
.font(.system(size: 13))
}
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
.padding(.vertical, 6)
}
}

View File

@@ -28,6 +28,12 @@ struct CommandCategory: Identifiable {
private let helpCategories: [CommandCategory] = [
CommandCategory(name: "Chat", icon: "bubble.left.and.bubble.right", commands: [
CommandDetail(
command: "/history",
brief: "View command history",
detail: "Opens a searchable modal showing all your previous messages with timestamps in European format (dd.MM.yyyy HH:mm:ss). Search by text content or date to find specific messages. Click any entry to reuse it.",
examples: ["/history"]
),
CommandDetail(
command: "/clear",
brief: "Clear chat history",
@@ -170,10 +176,11 @@ private let keyboardShortcuts: [(key: String, description: String)] = [
("Shift + Return", "New line"),
("\u{2318}M", "Model Selector"),
("\u{2318}K", "Clear Chat"),
("\u{2318}H", "Command History"),
("\u{2318}L", "Conversations"),
("\u{21E7}\u{2318}S", "Statistics"),
("\u{2318},", "Settings"),
("\u{2318}/", "Help"),
("\u{2318}L", "Conversations"),
]
// MARK: - HelpView

View File

@@ -0,0 +1,144 @@
//
// HistoryView.swift
// oAI
//
// Command history viewer with search
//
import os
import SwiftUI
struct HistoryView: View {
@Environment(\.dismiss) var dismiss
@State private var searchText = ""
@State private var historyEntries: [HistoryEntry] = []
var onSelect: ((String) -> Void)?
private var filteredHistory: [HistoryEntry] {
if searchText.isEmpty {
return historyEntries
}
return historyEntries.filter {
$0.input.lowercased().contains(searchText.lowercased()) ||
$0.formattedDate.contains(searchText)
}
}
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Command History")
.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 by text or date (dd.mm.yyyy)...", 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 filteredHistory.isEmpty {
Spacer()
VStack(spacing: 8) {
Image(systemName: searchText.isEmpty ? "list.bullet" : "magnifyingglass")
.font(.largeTitle)
.foregroundStyle(.tertiary)
Text(searchText.isEmpty ? "No Command History" : "No Matches")
.font(.headline)
.foregroundStyle(.secondary)
Text(searchText.isEmpty ? "Your command history will appear here" : "Try a different search term or date")
.font(.caption)
.foregroundStyle(.tertiary)
}
Spacer()
} else {
List {
ForEach(filteredHistory) { entry in
HistoryRow(entry: entry)
.contentShape(Rectangle())
.onTapGesture {
onSelect?(entry.input)
dismiss()
}
}
}
.listStyle(.plain)
}
}
.frame(minWidth: 600, minHeight: 400)
.background(Color.oaiBackground)
.task {
loadHistory()
}
}
private func loadHistory() {
do {
let records = try DatabaseService.shared.loadCommandHistory()
historyEntries = records.map { HistoryEntry(input: $0.input, timestamp: $0.timestamp) }
} catch {
Log.db.error("Failed to load command history: \(error.localizedDescription)")
}
}
}
struct HistoryRow: View {
let entry: HistoryEntry
private let settings = SettingsService.shared
var body: some View {
VStack(alignment: .leading, spacing: 6) {
// Input text
Text(entry.input)
.font(.system(size: settings.dialogTextSize))
.foregroundStyle(.primary)
.lineLimit(3)
// Timestamp
HStack(spacing: 4) {
Image(systemName: "clock")
.font(.caption2)
.foregroundStyle(.secondary)
Text(entry.formattedDate)
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 8)
.padding(.horizontal, 4)
}
}
#Preview {
HistoryView { input in
print("Selected: \(input)")
}
}

View File

@@ -27,13 +27,30 @@ struct SettingsView: View {
@State private var showOAuthCodeField = false
private var oauthService = AnthropicOAuthService.shared
private let labelWidth: CGFloat = 140
private let labelWidth: CGFloat = 160
// Default system prompt
private let defaultSystemPrompt = """
You are a helpful AI assistant. Follow these guidelines:
1. **Accuracy First**: Never invent information or make assumptions. If you're unsure about something, say so clearly.
2. **Ask for Clarification**: When a request is ambiguous or lacks necessary details, ask clarifying questions before proceeding.
3. **Be Honest About Limitations**: If you cannot or should not help with a request, explain why clearly and politely. Don't attempt tasks outside your capabilities.
4. **Stay Grounded**: Base responses on facts and known information. Clearly distinguish between facts, informed opinions, and speculation.
5. **Be Direct**: Provide concise, relevant answers. Avoid unnecessary preambles or apologies.
Remember: It's better to ask questions or admit uncertainty than to provide incorrect or fabricated information.
"""
var body: some View {
VStack(spacing: 0) {
// Title
Text("Settings")
.font(.system(size: 18, weight: .bold))
.font(.system(size: 22, weight: .bold))
.padding(.top, 20)
.padding(.bottom, 12)
@@ -42,6 +59,7 @@ struct SettingsView: View {
Text("General").tag(0)
Text("MCP").tag(1)
Text("Appearance").tag(2)
Text("Advanced").tag(3)
}
.pickerStyle(.segmented)
.padding(.horizontal, 24)
@@ -58,6 +76,8 @@ struct SettingsView: View {
mcpTab
case 2:
appearanceTab
case 3:
advancedTab
default:
generalTab
}
@@ -80,7 +100,7 @@ struct SettingsView: View {
.padding(.horizontal, 24)
.padding(.vertical, 12)
}
.frame(minWidth: 550, idealWidth: 600, minHeight: 500, idealHeight: 650)
.frame(minWidth: 700, idealWidth: 800, minHeight: 600, idealHeight: 750)
}
// MARK: - General Tab
@@ -106,6 +126,7 @@ struct SettingsView: View {
row("OpenRouter") {
SecureField("sk-or-...", text: $openrouterKey)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13))
.onAppear { openrouterKey = settingsService.openrouterAPIKey ?? "" }
.onChange(of: openrouterKey) {
settingsService.openrouterAPIKey = openrouterKey.isEmpty ? nil : openrouterKey
@@ -121,13 +142,13 @@ struct SettingsView: View {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("Logged in via Claude Pro/Max")
.font(.subheadline)
.font(.system(size: 14))
Spacer()
Button("Logout") {
oauthService.logout()
ProviderRegistry.shared.clearCache()
}
.font(.subheadline)
.font(.system(size: 14))
.foregroundStyle(.red)
}
} else if showOAuthCodeField {
@@ -144,11 +165,11 @@ struct SettingsView: View {
oauthCode = ""
oauthError = nil
}
.font(.subheadline)
.font(.system(size: 14))
}
if let error = oauthError {
Text(error)
.font(.caption)
.font(.system(size: 13))
.foregroundStyle(.red)
}
} else {
@@ -161,13 +182,13 @@ struct SettingsView: View {
Image(systemName: "person.circle")
Text("Login with Claude Pro/Max")
}
.font(.subheadline)
.font(.system(size: 14))
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
Text("or")
.font(.caption)
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
@@ -251,9 +272,6 @@ struct SettingsView: View {
))
.textFieldStyle(.roundedBorder)
}
row("") {
Toggle("Streaming Responses", isOn: $settingsService.streamEnabled)
}
divider()
@@ -274,7 +292,7 @@ struct SettingsView: View {
HStack {
Spacer().frame(width: labelWidth + 12)
Text("Controls which messages are written to ~/Library/Logs/oAI.log")
.font(.caption)
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
@@ -284,10 +302,41 @@ struct SettingsView: View {
@ViewBuilder
private var mcpTab: some View {
// Enable toggle
sectionHeader("MCP")
// Description header
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "folder.badge.gearshape")
.font(.title2)
.foregroundStyle(.blue)
Text("Model Context Protocol")
.font(.system(size: 18, weight: .semibold))
}
Text("MCP gives the AI controlled access to read and optionally write files on your computer. The AI can search, read, and analyze files in allowed folders to help with coding, analysis, and other tasks.")
.font(.system(size: 14))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 24)
.padding(.top, 12)
.padding(.bottom, 16)
divider()
// Enable toggle with status
sectionHeader("Status")
row("") {
Toggle("MCP Enabled (File Access)", isOn: $settingsService.mcpEnabled)
Toggle("Enable MCP", isOn: $settingsService.mcpEnabled)
}
HStack {
Spacer().frame(width: labelWidth + 12)
HStack(spacing: 4) {
Image(systemName: settingsService.mcpEnabled ? "checkmark.circle.fill" : "circle")
.foregroundStyle(settingsService.mcpEnabled ? .green : .secondary)
.font(.system(size: 13))
Text(settingsService.mcpEnabled ? "Active - AI can access allowed folders" : "Disabled - No file access")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
}
if settingsService.mcpEnabled {
@@ -297,12 +346,22 @@ struct SettingsView: View {
sectionHeader("Allowed Folders")
if mcpService.allowedFolders.isEmpty {
HStack {
Spacer().frame(width: labelWidth + 12)
Text("No folders added")
VStack(spacing: 8) {
Image(systemName: "folder.badge.plus")
.font(.system(size: 32))
.foregroundStyle(.tertiary)
Text("No folders added yet")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.secondary)
.font(.subheadline)
Text("Click 'Add Folder' below to grant AI access to a folder")
.font(.system(size: 13))
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
.background(Color.gray.opacity(0.05))
.cornerRadius(8)
.padding(.horizontal, labelWidth + 24)
} else {
ForEach(Array(mcpService.allowedFolders.enumerated()), id: \.offset) { index, folder in
HStack(spacing: 0) {
@@ -314,7 +373,7 @@ struct SettingsView: View {
Text((folder as NSString).lastPathComponent)
.font(.body)
Text(abbreviatePath(folder))
.font(.caption)
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
.padding(.leading, 6)
@@ -324,7 +383,7 @@ struct SettingsView: View {
} label: {
Image(systemName: "trash.fill")
.foregroundStyle(.red)
.font(.caption)
.font(.system(size: 13))
}
.buttonStyle(.plain)
}
@@ -340,7 +399,7 @@ struct SettingsView: View {
Image(systemName: "plus")
Text("Add Folder...")
}
.font(.subheadline)
.font(.system(size: 14))
}
.buttonStyle(.borderless)
Spacer()
@@ -362,20 +421,46 @@ struct SettingsView: View {
// 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)
VStack(alignment: .leading, spacing: 0) {
HStack {
Spacer().frame(width: labelWidth + 12)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.system(size: 12))
Text("Read access (always enabled)")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
Text("The AI can read and search files in allowed folders")
.font(.system(size: 12))
.foregroundStyle(.tertiary)
.padding(.leading, 18)
}
Spacer()
}
.padding(.bottom, 12)
HStack {
Spacer().frame(width: labelWidth + 12)
Text("Write Permissions (optional)")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
Spacer()
}
.padding(.bottom, 8)
HStack {
Spacer().frame(width: labelWidth + 12)
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)
}
Spacer()
}
}
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()
@@ -394,7 +479,7 @@ struct SettingsView: View {
HStack {
Spacer().frame(width: labelWidth + 12)
Text("When enabled, listing and searching skip gitignored files. Write operations always ignore .gitignore.")
.font(.caption)
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
@@ -411,7 +496,7 @@ struct SettingsView: View {
Slider(value: $settingsService.guiTextSize, in: 10...20, step: 1)
.frame(maxWidth: 200)
Text("\(Int(settingsService.guiTextSize)) pt")
.font(.caption)
.font(.system(size: 13))
.foregroundStyle(.secondary)
.frame(width: 40)
}
@@ -421,7 +506,7 @@ struct SettingsView: View {
Slider(value: $settingsService.dialogTextSize, in: 10...24, step: 1)
.frame(maxWidth: 200)
Text("\(Int(settingsService.dialogTextSize)) pt")
.font(.caption)
.font(.system(size: 13))
.foregroundStyle(.secondary)
.frame(width: 40)
}
@@ -431,18 +516,188 @@ struct SettingsView: View {
Slider(value: $settingsService.inputTextSize, in: 10...24, step: 1)
.frame(maxWidth: 200)
Text("\(Int(settingsService.inputTextSize)) pt")
.font(.caption)
.font(.system(size: 13))
.foregroundStyle(.secondary)
.frame(width: 40)
}
}
}
// MARK: - Advanced Tab
@ViewBuilder
private var advancedTab: some View {
sectionHeader("Response Generation")
row("") {
Toggle("Enable Streaming Responses", isOn: $settingsService.streamEnabled)
}
HStack {
Spacer().frame(width: labelWidth + 12)
Text("Stream responses as they're generated. Disable for single, complete responses.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
divider()
sectionHeader("Model Parameters")
// Max Tokens
row("Max Tokens") {
HStack(spacing: 8) {
Slider(value: Binding(
get: { Double(settingsService.maxTokens) },
set: { settingsService.maxTokens = Int($0) }
), in: 0...32000, step: 256)
.frame(maxWidth: 250)
Text(settingsService.maxTokens == 0 ? "Default" : "\(settingsService.maxTokens)")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.frame(width: 70, alignment: .leading)
}
}
HStack {
Spacer().frame(width: labelWidth + 12)
Text("Maximum tokens in the response. Set to 0 to use model default. Higher values allow longer responses but cost more.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
// Temperature
row("Temperature") {
HStack(spacing: 8) {
Slider(value: $settingsService.temperature, in: 0...2, step: 0.1)
.frame(maxWidth: 250)
Text(settingsService.temperature == 0.0 ? "Default" : String(format: "%.1f", settingsService.temperature))
.font(.system(size: 13))
.foregroundStyle(.secondary)
.frame(width: 70, alignment: .leading)
}
}
HStack {
Spacer().frame(width: labelWidth + 12)
VStack(alignment: .leading, spacing: 4) {
Text("Controls randomness. Set to 0 to use model default.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
Text("• Lower (0.0-0.7): More focused, deterministic")
.font(.system(size: 13))
.foregroundStyle(.secondary)
Text("• Higher (0.8-2.0): More creative, random")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
.fixedSize(horizontal: false, vertical: true)
}
divider()
sectionHeader("System Prompts")
// Default prompt (read-only)
VStack(alignment: .leading, spacing: 8) {
HStack {
Spacer().frame(width: labelWidth + 12)
HStack(spacing: 4) {
Text("Default Prompt")
.font(.system(size: 14))
.fontWeight(.medium)
Text("(always used)")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
}
HStack(alignment: .top, spacing: 0) {
Spacer().frame(width: labelWidth + 12)
ScrollView {
Text(defaultSystemPrompt)
.font(.system(size: 13, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
}
.frame(height: 160)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
)
}
HStack {
Spacer().frame(width: labelWidth + 12)
Text("This default prompt is always included to ensure accurate, helpful responses.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.bottom, 8)
// Custom prompt (editable)
VStack(alignment: .leading, spacing: 8) {
HStack {
Spacer().frame(width: labelWidth + 12)
HStack(spacing: 4) {
Text("Your Custom Prompt")
.font(.system(size: 14))
.fontWeight(.medium)
Text("(optional)")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
}
HStack(alignment: .top, spacing: 0) {
Spacer().frame(width: labelWidth + 12)
TextEditor(text: Binding(
get: { settingsService.systemPrompt ?? "" },
set: { settingsService.systemPrompt = $0.isEmpty ? nil : $0 }
))
.font(.system(size: 13, design: .monospaced))
.frame(height: 120)
.padding(8)
.background(Color(NSColor.textBackgroundColor))
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.secondary.opacity(0.3), lineWidth: 1)
)
}
HStack {
Spacer().frame(width: labelWidth + 12)
Text("Add additional instructions here. This will be appended to the default prompt. Leave empty if you don't need custom instructions.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
divider()
sectionHeader("Info")
HStack {
Spacer().frame(width: labelWidth + 12)
VStack(alignment: .leading, spacing: 8) {
Text("⚠️ These are advanced settings")
.font(.system(size: 13))
.foregroundStyle(.orange)
.fontWeight(.medium)
Text("Changing these values affects how the AI generates responses. The defaults work well for most use cases. Only adjust if you know what you're doing.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
// MARK: - Layout Helpers
private func sectionHeader(_ title: String) -> some View {
Text(title)
.font(.system(size: 13, weight: .semibold))
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
}
@@ -450,7 +705,7 @@ struct SettingsView: View {
private func row<Content: View>(_ label: String, @ViewBuilder content: () -> Content) -> some View {
HStack(alignment: .center, spacing: 12) {
Text(label)
.font(.body)
.font(.system(size: 14))
.frame(width: labelWidth, alignment: .trailing)
content()
}