From 7265d224381c44f80652d1d81d76a79c08e8598e Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Thu, 12 Feb 2026 14:29:35 +0100 Subject: [PATCH] Bug gixes, features added, GUI updates and more --- oAI/Info.plist | 10 + oAI/Models/HistoryEntry.swift | 35 ++ oAI/Models/Message.swift | 12 +- oAI/Resources/oAI.help/Contents/Info.plist | 30 + .../Resources/en.lproj/images/icon.png | Bin 0 -> 19792 bytes .../Contents/Resources/en.lproj/index.html | 550 ++++++++++++++++++ .../Contents/Resources/en.lproj/styles.css | 389 +++++++++++++ oAI/Resources/oAI.help/README.md | 71 +++ oAI/Services/DatabaseService.swift | 89 +++ oAI/Services/SettingsService.swift | 13 + oAI/ViewModels/ChatViewModel.swift | 188 ++++-- oAI/Views/Main/ChatView.swift | 2 + oAI/Views/Main/ContentView.swift | 14 +- oAI/Views/Main/InputBar.swift | 49 +- oAI/Views/Main/MarkdownContentView.swift | 171 +++++- oAI/Views/Main/MessageRow.swift | 31 +- oAI/Views/Screens/ConversationListView.swift | 143 ++++- oAI/Views/Screens/HelpView.swift | 9 +- oAI/Views/Screens/HistoryView.swift | 144 +++++ oAI/Views/Screens/SettingsView.swift | 339 +++++++++-- oAI/oAIApp.swift | 21 + 21 files changed, 2187 insertions(+), 123 deletions(-) create mode 100644 oAI/Info.plist create mode 100644 oAI/Models/HistoryEntry.swift create mode 100644 oAI/Resources/oAI.help/Contents/Info.plist create mode 100644 oAI/Resources/oAI.help/Contents/Resources/en.lproj/images/icon.png create mode 100644 oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html create mode 100644 oAI/Resources/oAI.help/Contents/Resources/en.lproj/styles.css create mode 100644 oAI/Resources/oAI.help/README.md create mode 100644 oAI/Views/Screens/HistoryView.swift diff --git a/oAI/Info.plist b/oAI/Info.plist new file mode 100644 index 0000000..eee344e --- /dev/null +++ b/oAI/Info.plist @@ -0,0 +1,10 @@ + + + + + CFBundleHelpBookFolder + oAI.help + CFBundleHelpBookName + oAI Help + + diff --git a/oAI/Models/HistoryEntry.swift b/oAI/Models/HistoryEntry.swift new file mode 100644 index 0000000..20ec386 --- /dev/null +++ b/oAI/Models/HistoryEntry.swift @@ -0,0 +1,35 @@ +// +// HistoryEntry.swift +// oAI +// +// Command history entry model +// + +import Foundation + +struct HistoryEntry: Identifiable, Equatable { + let id = UUID() + let input: String + let timestamp: Date + + /// Format timestamp in European format (dd.MM.yyyy HH:mm:ss) + var formattedDate: String { + let formatter = DateFormatter() + formatter.dateFormat = "dd.MM.yyyy HH:mm:ss" + return formatter.string(from: timestamp) + } + + /// Short date without time (dd.MM.yyyy) + var shortDate: String { + let formatter = DateFormatter() + formatter.dateFormat = "dd.MM.yyyy" + return formatter.string(from: timestamp) + } + + /// Just the time (HH:mm:ss) + var timeOnly: String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + return formatter.string(from: timestamp) + } +} diff --git a/oAI/Models/Message.swift b/oAI/Models/Message.swift index 9667d2b..6229551 100644 --- a/oAI/Models/Message.swift +++ b/oAI/Models/Message.swift @@ -21,7 +21,9 @@ struct Message: Identifiable, Codable, Equatable { var cost: Double? let timestamp: Date let attachments: [FileAttachment]? - + var responseTime: TimeInterval? // Time taken to generate response in seconds + var wasInterrupted: Bool = false // Whether generation was cancelled + // Streaming state (not persisted) var isStreaming: Bool = false @@ -36,6 +38,8 @@ struct Message: Identifiable, Codable, Equatable { cost: Double? = nil, timestamp: Date = Date(), attachments: [FileAttachment]? = nil, + responseTime: TimeInterval? = nil, + wasInterrupted: Bool = false, isStreaming: Bool = false, generatedImages: [Data]? = nil ) { @@ -46,12 +50,14 @@ struct Message: Identifiable, Codable, Equatable { self.cost = cost self.timestamp = timestamp self.attachments = attachments + self.responseTime = responseTime + self.wasInterrupted = wasInterrupted self.isStreaming = isStreaming self.generatedImages = generatedImages } enum CodingKeys: String, CodingKey { - case id, role, content, tokens, cost, timestamp, attachments + case id, role, content, tokens, cost, timestamp, attachments, responseTime, wasInterrupted } static func == (lhs: Message, rhs: Message) -> Bool { @@ -59,6 +65,8 @@ struct Message: Identifiable, Codable, Equatable { lhs.content == rhs.content && lhs.tokens == rhs.tokens && lhs.cost == rhs.cost && + lhs.responseTime == rhs.responseTime && + lhs.wasInterrupted == rhs.wasInterrupted && lhs.isStreaming == rhs.isStreaming && lhs.generatedImages == rhs.generatedImages } diff --git a/oAI/Resources/oAI.help/Contents/Info.plist b/oAI/Resources/oAI.help/Contents/Info.plist new file mode 100644 index 0000000..c68a78d --- /dev/null +++ b/oAI/Resources/oAI.help/Contents/Info.plist @@ -0,0 +1,30 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleIdentifier + com.rune.oAI.help + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + oAI Help + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleSignature + hbwr + CFBundleVersion + 1 + HPDBookAccessPath + index.html + HPDBookIconPath + images/icon.png + HPDBookTitle + oAI Help + HPDBookType + 3 + + diff --git a/oAI/Resources/oAI.help/Contents/Resources/en.lproj/images/icon.png b/oAI/Resources/oAI.help/Contents/Resources/en.lproj/images/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6320d488a01574875bbed0d56b651fab32c2786e GIT binary patch literal 19792 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 VdZIXK{C_qx1Bj_^gSJb|e*nLONZtSd literal 0 HcmV?d00001 diff --git a/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html b/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html new file mode 100644 index 0000000..7f7c32f --- /dev/null +++ b/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html @@ -0,0 +1,550 @@ + + + + + + + + + + oAI Help + + + +
+
+ oAI Icon +

oAI Help

+

AI Chat Assistant for macOS

+
+ + + +
+ +
+

Getting Started

+

oAI is a powerful AI chat assistant that connects to multiple AI providers including OpenAI, Anthropic, OpenRouter, and local models via Ollama.

+ +
+

Quick Start

+
    +
  1. Add an API key: Press ⌘, to open Settings, then add your API key for your preferred provider
  2. +
  3. Select a model: Press ⌘M or type /model to choose an AI model
  4. +
  5. Start chatting: Type your message and press Return to send
  6. +
+
+ +
+ πŸ’‘ Tip: Type / in the message input to see all available commands with autocomplete suggestions. +
+
+ + +
+

AI Providers & API Keys

+

oAI supports multiple AI providers. You'll need an API key from at least one provider to use the app.

+ +

Supported Providers

+ + +

Adding an API Key

+
    +
  1. Press ⌘, or type /config to open Settings
  2. +
  3. Select the Providers tab
  4. +
  5. Enter your API key for the desired provider
  6. +
  7. Click Save
  8. +
+ +

Switching Providers

+

Use the provider dropdown in the header or type:

+ /provider anthropic + /provider openai + /provider openrouter +
+ + +
+

Selecting Models

+

Different models have different capabilities, speeds, and costs. Choose the right model for your task.

+ +

Opening the Model Selector

+
    +
  • Press ⌘M
  • +
  • Type /model
  • +
  • Click the model name in the header
  • +
+ +

Model Information

+

View details about any model:

+ /info +

Shows information about the currently selected model including context length, pricing, and capabilities.

+ +

Default Model

+

Your selected model is automatically saved and will be restored when you restart the app.

+
+ + +
+

Sending Messages

+ +

Basic Usage

+
    +
  • Type your message in the input field
  • +
  • Press Return to send (single-line messages)
  • +
  • Press ⇧Return to add a new line without sending
  • +
+ +

During Generation

+

While the AI is responding:

+
    +
  • Press Esc to cancel generation
  • +
  • Click the Stop button (red circle)
  • +
  • Cancelled responses show a ⚠️ interrupted indicator
  • +
+ +

Retrying Messages

+

If you're not satisfied with a response:

+ /retry +

Resends your last message to generate a new response.

+
+ + +
+

File Attachments

+

Attach files to your messages for the AI to analyze. Supports images, PDFs, and text files.

+ +

Attaching Files

+
    +
  • Click the πŸ“Ž paperclip icon to browse and select files
  • +
  • Type @/path/to/file.txt in your message
  • +
  • Type @~/Documents/image.png for files in your home directory
  • +
+ +

Supported File Types

+
    +
  • Images: PNG, JPEG, GIF, WebP, BMP, SVG (max 10 MB)
  • +
  • Documents: PDF (max 10 MB)
  • +
  • Text: TXT, code files, logs, etc. (max 50 KB)
  • +
+ +
+ πŸ’‘ Tip: Large text files are automatically truncated to prevent token limits. +
+
+ + +
+

Slash Commands

+

Commands start with / and provide quick access to features. Type / to see suggestions.

+ +

Chat Commands

+
+
/history
+
View command history with timestamps (European format: dd.MM.yyyy). Search by text or date to find previous messages
+ +
/clear
+
Clear all messages from the current session
+ +
/retry
+
Retry your last message to get a new response
+ +
/memory on|off
+
Toggle conversation memory. When off, only your latest message is sent (no conversation history)
+ +
/online on|off
+
Enable/disable web search. When on, the AI can search the web for current information
+
+ +

Model & Provider Commands

+
+
/model
+
Open the model selector
+ +
/provider [name]
+
Switch AI provider or show current provider
+ +
/info
+
Display information about the current model
+ +
/credits
+
Check your account balance (OpenRouter only)
+
+ +

Conversation Commands

+
+
/save <name>
+
Save the current conversation
+ +
/load
+
Browse and load a saved conversation
+ +
/list
+
Show all saved conversations
+ +
/delete <name>
+
Delete a saved conversation
+ +
/export md|json
+
Export conversation as Markdown or JSON
+
+ +

MCP Commands

+
+
/mcp on|off
+
Enable/disable file access for the AI
+ +
/mcp add <path>
+
Grant AI access to a folder
+ +
/mcp remove <path>
+
Revoke AI access to a folder
+ +
/mcp list
+
Show all accessible folders
+ +
/mcp status
+
Display MCP status and permissions
+ +
/mcp write on|off
+
Enable/disable write permissions
+
+ +

Settings Commands

+
+
/config, /settings
+
Open the settings panel
+ +
/stats
+
Show session statistics (messages, tokens, cost)
+
+
+ + +
+

Memory & Context

+

Control how much conversation history the AI remembers.

+ +

Memory Enabled (Default)

+

When memory is on, the AI remembers all previous messages in your session. This allows for natural, flowing conversations.

+ +

Memory Disabled

+

When memory is off, only your latest message is sent. Each message is independent. Useful for:

+
    +
  • Quick, unrelated questions
  • +
  • Reducing token usage and cost
  • +
  • Avoiding context pollution
  • +
+ +

Toggle Memory

+ /memory on + /memory off + +
+ Note: Memory state is shown in the header with a badge when enabled. +
+
+ + +
+

Online Mode (Web Search)

+

Enable the AI to search the web for current information before responding.

+ +

When to Use Online Mode

+
    +
  • Questions about current events
  • +
  • Recent news or updates
  • +
  • Real-time data (weather, stocks, etc.)
  • +
  • Information beyond the AI's training cutoff
  • +
+ +

Enabling Online Mode

+ /online on + +

How It Works

+

When online mode is enabled:

+
    +
  1. Your question is used to search the web
  2. +
  3. Relevant results are retrieved
  4. +
  5. Search results are added to your message context
  6. +
  7. The AI uses the web results to inform its response
  8. +
+ +
+ Note: Online mode is shown in the input bar with a 🌐 badge when active. +
+
+ + +
+

MCP (File Access)

+

MCP (Model Context Protocol) allows the AI to access, read, and optionally modify files on your computer.

+ +
+ ⚠️ Security: Only grant access to folders you trust the AI to work with. Review permissions carefully. +
+ +

Setting Up MCP

+
    +
  1. Enable MCP: /mcp on
  2. +
  3. Add a folder: /mcp add ~/Projects/myapp
  4. +
  5. Ask the AI questions about your files
  6. +
+ +

What the AI Can Do

+

Read permissions (always enabled when MCP is on):

+
    +
  • Read file contents
  • +
  • List directory contents
  • +
  • Search for files
  • +
  • Get file information (size, modified date, etc.)
  • +
+ +

Write permissions (optional, disabled by default):

+
    +
  • Write and edit files
  • +
  • Create new files
  • +
  • Delete files
  • +
  • Create directories
  • +
  • Move and copy files
  • +
+ +

Managing Permissions

+

Toggle all write permissions:

+ /mcp write on + /mcp write off + +

For fine-grained control, open Settings β†’ MCP to enable/disable individual permissions.

+ +

Gitignore Respect

+

When enabled in Settings, MCP will ignore files and folders listed in .gitignore files.

+ +

Example Usage

+
+

You: "What files are in my project folder?"

+

AI: (uses MCP to list files) "I found 15 files including src/main.py, README.md..."

+
+
+ + +
+

Managing Conversations

+

Save, load, and export your chat conversations.

+ +

Saving Conversations

+ /save my-project-chat +

Saves all current messages under the specified name.

+ +

Loading Conversations

+ /load +

Opens a list of saved conversations. Select one to load.

+ +

Viewing Saved Conversations

+
    +
  • Press ⌘L
  • +
  • Type /list
  • +
+ +

Deleting Conversations

+ /delete old-chat +

Warning: This action cannot be undone.

+ +

Exporting Conversations

+

Export to Markdown or JSON format:

+ /export md + /export json +

Files are saved to your Downloads folder.

+
+ + +
+

Keyboard Shortcuts

+

Work faster with these keyboard shortcuts.

+ +

General

+
+
⌘,
+
Open Settings
+ +
⌘/
+
Show in-app Help
+ +
⌘?
+
Open this Help (macOS Help)
+ +
⌘L
+
Browse Conversations
+ +
⌘H
+
Command History
+ +
⌘M
+
Model Selector
+ +
⌘K
+
Clear Chat
+ +
β‡§βŒ˜S
+
Show Statistics
+
+ +

Message Input

+
+
Return
+
Send message (single-line messages)
+ +
⇧Return
+
New line (multi-line messages)
+ +
Esc
+
Cancel generation or close command dropdown
+ +
↑ ↓
+
Navigate command suggestions (when typing /)
+ +
Return
+
Select highlighted command (when dropdown is open)
+
+
+ + +
+

Settings

+

Customize oAI to your preferences. Press ⌘, to open Settings.

+ +

Providers Tab

+
    +
  • Add and manage API keys for different providers
  • +
  • Switch between providers
  • +
  • Set default models
  • +
+ +

MCP Tab

+
    +
  • Manage folder access permissions
  • +
  • Enable/disable write operations
  • +
  • Configure gitignore respect
  • +
+ +

Appearance Tab

+
    +
  • Adjust text sizes for input and dialog
  • +
  • Customize UI preferences
  • +
+ +

Advanced Tab

+
    +
  • Enable/disable streaming responses
  • +
  • Set maximum tokens (response length limit)
  • +
  • Adjust temperature (creativity vs focus)
  • +
  • Configure system prompts (see below)
  • +
+
+ + +
+

System Prompts

+

System prompts are instructions that define how the AI should behave and respond. They set the "personality" and guidelines for the AI before it sees any user messages.

+ +
+ πŸ“š Learn More: For a detailed explanation of system prompts and how they work, see Prompt Engineering on Wikipedia or the OpenAI Prompt Engineering Guide. +
+ +

What Are System Prompts?

+

System prompts are special instructions sent to the AI with every conversation. Unlike your regular messages, system prompts:

+
    +
  • Are invisible to you in the chat interface
  • +
  • Set behavioral guidelines for the AI
  • +
  • Establish the AI's role, tone, and constraints
  • +
  • Are sent automatically with every message
  • +
  • Help ensure consistent, reliable responses
  • +
+ +

Default System Prompt

+

oAI includes a carefully crafted default system prompt that emphasizes:

+
    +
  • Accuracy First - Never invent information or make assumptions
  • +
  • Ask for Clarification - Request details when requests are ambiguous
  • +
  • Honest About Limitations - Clearly state when it cannot help
  • +
  • Stay Grounded - Base responses on facts, not speculation
  • +
  • Be Direct - Provide concise, relevant answers
  • +
+ +

Note: The default prompt is always active and cannot be disabled. It ensures the AI provides accurate, helpful responses and doesn't fabricate information.

+ +

Custom System Prompt

+

You can add your own custom instructions that will be appended to the default prompt. Use custom prompts to:

+
    +
  • Define a specific role (e.g., "You are a Python expert")
  • +
  • Set output format preferences (e.g., "Always provide code examples")
  • +
  • Add domain-specific knowledge or context
  • +
  • Establish tone or communication style
  • +
  • Create task-specific guidelines
  • +
+ +

How Prompts Are Combined

+

When you send a message, oAI constructs the complete system prompt like this:

+ +
+

Complete System Prompt =

+
    +
  1. Default Prompt (always included)
  2. +
  3. + Your Custom Prompt (if set)
  4. +
  5. + MCP Instructions (if MCP is enabled)
  6. +
+

This combined prompt is sent with every message to ensure consistent behavior.

+
+ +

Editing System Prompts

+

To view or edit system prompts:

+
    +
  1. Press ⌘, to open Settings
  2. +
  3. Go to the Advanced tab
  4. +
  5. Scroll to the System Prompts section
  6. +
  7. View the default prompt (read-only)
  8. +
  9. Add your custom instructions in the editable field below
  10. +
+ +

Best Practices

+
    +
  • Be Specific - Clear instructions produce better results
  • +
  • Keep It Simple - Don't overcomplicate your custom prompt
  • +
  • Test Changes - Try your prompt with a few messages to see if it works
  • +
  • Start Empty - The default prompt works well; only add custom instructions if needed
  • +
  • Avoid Contradictions - Don't contradict the default prompt's guidelines
  • +
+ +
+ ⚠️ Important: Custom prompts affect all conversations. Changes apply immediately to new messages. If you experience unexpected behavior, try removing your custom prompt. +
+
+
+ +
+

Β© 2026 oAI. For support or feedback, visit gitlab.pm.

+
+
+ + diff --git a/oAI/Resources/oAI.help/Contents/Resources/en.lproj/styles.css b/oAI/Resources/oAI.help/Contents/Resources/en.lproj/styles.css new file mode 100644 index 0000000..f7e372a --- /dev/null +++ b/oAI/Resources/oAI.help/Contents/Resources/en.lproj/styles.css @@ -0,0 +1,389 @@ +/* oAI Help Stylesheet - Apple Human Interface Guidelines */ + +:root { + --primary-color: #007AFF; + --secondary-color: #5856D6; + --success-color: #34C759; + --warning-color: #FF9500; + --error-color: #FF3B30; + --text-primary: #000000; + --text-secondary: #6E6E73; + --background: #FFFFFF; + --surface: #F5F5F7; + --border: #D1D1D6; + --code-bg: #F5F5F7; + --tip-bg: #E3F2FD; + --warning-bg: #FFF3E0; +} + +@media (prefers-color-scheme: dark) { + :root { + --primary-color: #0A84FF; + --secondary-color: #5E5CE6; + --success-color: #32D74B; + --warning-color: #FF9F0A; + --error-color: #FF453A; + --text-primary: #FFFFFF; + --text-secondary: #A1A1A6; + --background: #1C1C1E; + --surface: #2C2C2E; + --border: #38383A; + --code-bg: #2C2C2E; + --tip-bg: #1A2631; + --warning-bg: #2B2116; + } +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 15px; + line-height: 1.6; + color: var(--text-primary); + background: var(--background); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.container { + max-width: 800px; + margin: 0 auto; + padding: 40px 20px; +} + +/* Header */ +header { + text-align: center; + margin-bottom: 48px; + padding-bottom: 24px; + border-bottom: 1px solid var(--border); +} + +.app-icon { + width: 96px; + height: 96px; + margin: 0 auto 24px; + display: block; + border-radius: 20px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +header h1 { + font-size: 48px; + font-weight: 700; + letter-spacing: -0.5px; + margin-bottom: 8px; +} + +.subtitle { + font-size: 19px; + color: var(--text-secondary); + font-weight: 400; +} + +/* Table of Contents */ +nav.toc { + background: var(--surface); + border-radius: 12px; + padding: 24px; + margin-bottom: 40px; + border: 1px solid var(--border); +} + +nav.toc h2 { + font-size: 17px; + font-weight: 600; + margin-bottom: 16px; +} + +nav.toc ul { + list-style: none; +} + +nav.toc li { + margin-bottom: 8px; +} + +nav.toc a { + color: var(--primary-color); + text-decoration: none; + font-size: 15px; + transition: opacity 0.2s; +} + +nav.toc a:hover { + opacity: 0.7; + text-decoration: underline; +} + +/* Main Content */ +main { + margin-bottom: 48px; +} + +section { + margin-bottom: 56px; + scroll-margin-top: 20px; +} + +h2 { + font-size: 32px; + font-weight: 700; + margin-bottom: 16px; + letter-spacing: -0.3px; +} + +h3 { + font-size: 22px; + font-weight: 600; + margin-top: 32px; + margin-bottom: 12px; +} + +p { + margin-bottom: 16px; + color: var(--text-primary); +} + +/* Lists */ +ul, ol { + margin-bottom: 16px; + margin-left: 24px; +} + +li { + margin-bottom: 8px; +} + +ul.provider-list { + list-style: none; + margin-left: 0; +} + +ul.provider-list li { + padding-left: 24px; + position: relative; +} + +ul.provider-list li::before { + content: "β€’"; + color: var(--primary-color); + font-weight: bold; + position: absolute; + left: 0; +} + +/* Steps */ +.steps { + background: var(--surface); + border-radius: 12px; + padding: 20px; + margin: 24px 0; +} + +.steps h3 { + margin-top: 0; + margin-bottom: 16px; + font-size: 17px; +} + +.steps ol { + margin-bottom: 0; +} + +.steps li { + margin-bottom: 12px; +} + +.steps li:last-child { + margin-bottom: 0; +} + +/* Code and Commands */ +code { + font-family: "SF Mono", Monaco, Menlo, Consolas, "Courier New", monospace; + font-size: 14px; + background: var(--code-bg); + padding: 2px 6px; + border-radius: 4px; + border: 1px solid var(--border); +} + +code.command { + display: block; + padding: 12px; + margin: 8px 0; + background: var(--code-bg); + border: 1px solid var(--border); + border-radius: 8px; + font-size: 14px; +} + +/* Keyboard Shortcuts */ +kbd { + display: inline-block; + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif; + font-size: 13px; + font-weight: 600; + padding: 3px 8px; + background: linear-gradient(to bottom, var(--surface) 0%, var(--background) 100%); + border: 1px solid var(--border); + border-radius: 4px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + white-space: nowrap; +} + +/* Definition Lists (for commands) */ +dl.commands, +dl.shortcuts { + margin: 24px 0; +} + +dl.commands dt, +dl.shortcuts dt { + font-family: "SF Mono", Monaco, Menlo, monospace; + font-size: 14px; + font-weight: 600; + background: var(--code-bg); + padding: 10px 12px; + border-radius: 8px 8px 0 0; + border: 1px solid var(--border); + border-bottom: none; + margin-top: 16px; +} + +dl.commands dt:first-child, +dl.shortcuts dt:first-child { + margin-top: 0; +} + +dl.commands dd, +dl.shortcuts dd { + background: var(--surface); + padding: 12px; + border-radius: 0 0 8px 8px; + border: 1px solid var(--border); + margin-bottom: 0; + color: var(--text-primary); +} + +/* Callout Boxes */ +.tip, +.note, +.warning { + padding: 16px; + border-radius: 10px; + margin: 20px 0; + border-left: 4px solid; +} + +.tip { + background: var(--tip-bg); + border-left-color: var(--primary-color); +} + +.note { + background: var(--surface); + border-left-color: var(--secondary-color); +} + +.warning { + background: var(--warning-bg); + border-left-color: var(--warning-color); +} + +.tip strong, +.note strong, +.warning strong { + display: block; + margin-bottom: 4px; +} + +/* Examples */ +.example { + background: var(--surface); + border-radius: 10px; + padding: 16px; + margin: 20px 0; + border: 1px solid var(--border); +} + +.example p { + margin-bottom: 8px; +} + +.example p:last-child { + margin-bottom: 0; +} + +.example em { + color: var(--text-secondary); + font-style: italic; +} + +/* Links */ +a { + color: var(--primary-color); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +/* Footer */ +footer { + text-align: center; + padding-top: 32px; + border-top: 1px solid var(--border); + color: var(--text-secondary); + font-size: 13px; +} + +footer a { + color: var(--primary-color); +} + +/* Responsive */ +@media (max-width: 768px) { + .container { + padding: 24px 16px; + } + + header h1 { + font-size: 36px; + } + + h2 { + font-size: 28px; + } + + h3 { + font-size: 20px; + } +} + +/* Print Styles */ +@media print { + body { + background: white; + color: black; + } + + .container { + max-width: 100%; + } + + section { + page-break-inside: avoid; + } + + nav.toc { + page-break-after: always; + } +} diff --git a/oAI/Resources/oAI.help/README.md b/oAI/Resources/oAI.help/README.md new file mode 100644 index 0000000..a599629 --- /dev/null +++ b/oAI/Resources/oAI.help/README.md @@ -0,0 +1,71 @@ +# oAI Help Book + +This folder contains the Apple Help Book for oAI. + +## Adding to Xcode Project + +1. **Add the help folder to your project:** + - In Xcode, right-click on the `Resources` folder in the Project Navigator + - Select "Add Files to 'oAI'..." + - Select the `oAI.help` folder + - **Important:** Check "Create folder references" (NOT "Create groups") + - Click "Add" + +2. **Configure the help book in build settings:** + - Select your oAI target in Xcode + - Go to the "Info" tab + - Add a new key: `CFBundleHelpBookName` with value: `oAI Help` + - Add another key: `CFBundleHelpBookFolder` with value: `oAI.help` + +3. **Build and test:** + - Build the app (⌘B) + - Run the app (⌘R) + - Press ⌘? or select Help β†’ oAI Help to open the help + +## Structure + +``` +oAI.help/ +β”œβ”€β”€ Contents/ +β”‚ β”œβ”€β”€ Info.plist # Help book metadata +β”‚ └── Resources/ +β”‚ └── en.lproj/ +β”‚ β”œβ”€β”€ index.html # Main help page +β”‚ └── styles.css # Stylesheet +└── README.md # This file +``` + +## Features + +- βœ… Comprehensive coverage of all oAI features +- βœ… Searchable via macOS Help menu search +- βœ… Dark mode support +- βœ… Organized by topic with table of contents +- βœ… Keyboard shortcuts reference +- βœ… Command reference with examples +- βœ… Follows Apple Human Interface Guidelines + +## Customization + +To update the help content: +1. Edit `Contents/Resources/en.lproj/index.html` +2. Update styles in `Contents/Resources/en.lproj/styles.css` +3. Rebuild the app + +## Troubleshooting + +If help doesn't open: +- Make sure you added the folder as a "folder reference" (blue folder icon in Xcode) +- Check that `CFBundleHelpBookName` and `CFBundleHelpBookFolder` are set in Info +- Clean build folder (β‡§βŒ˜K) and rebuild + +## Adding More Pages + +To add additional help pages: +1. Create new HTML files in `Contents/Resources/en.lproj/` +2. Link to them from `index.html` +3. Include proper meta tags for searchability: + ```html + + + ``` diff --git a/oAI/Services/DatabaseService.swift b/oAI/Services/DatabaseService.swift index 6094f08..52ce4bf 100644 --- a/oAI/Services/DatabaseService.swift +++ b/oAI/Services/DatabaseService.swift @@ -40,6 +40,14 @@ struct SettingRecord: Codable, FetchableRecord, PersistableRecord, Sendable { var value: String } +struct HistoryRecord: Codable, FetchableRecord, PersistableRecord, Sendable { + static let databaseTableName = "command_history" + + var id: String + var input: String + var timestamp: String +} + // MARK: - DatabaseService final class DatabaseService: Sendable { @@ -48,6 +56,9 @@ final class DatabaseService: Sendable { private let dbQueue: DatabaseQueue private let isoFormatter: ISO8601DateFormatter + // Command history limit - keep most recent 5000 entries + private static let maxHistoryEntries = 5000 + nonisolated private init() { isoFormatter = ISO8601DateFormatter() isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] @@ -102,6 +113,20 @@ final class DatabaseService: Sendable { } } + migrator.registerMigration("v3") { db in + try db.create(table: "command_history") { t in + t.primaryKey("id", .text) + t.column("input", .text).notNull() + t.column("timestamp", .text).notNull() + } + + try db.create( + index: "command_history_on_timestamp", + on: "command_history", + columns: ["timestamp"] + ) + } + return migrator } @@ -315,4 +340,68 @@ final class DatabaseService: Sendable { return true } } + + // MARK: - Command History Operations + + nonisolated func saveCommandHistory(input: String) { + let now = Date() + let record = HistoryRecord( + id: UUID().uuidString, + input: input, + timestamp: isoFormatter.string(from: now) + ) + + try? dbQueue.write { db in + try record.insert(db) + + // Clean up old entries if we exceed the limit + let count = try HistoryRecord.fetchCount(db) + if count > Self.maxHistoryEntries { + // Delete oldest entries to get back to limit + let excess = count - Self.maxHistoryEntries + try db.execute( + sql: """ + DELETE FROM command_history + WHERE id IN ( + SELECT id FROM command_history + ORDER BY timestamp ASC + LIMIT ? + ) + """, + arguments: [excess] + ) + } + } + } + + nonisolated func loadCommandHistory() throws -> [(input: String, timestamp: Date)] { + try dbQueue.read { db in + let records = try HistoryRecord + .order(Column("timestamp").desc) + .fetchAll(db) + + return records.compactMap { record in + guard let date = isoFormatter.date(from: record.timestamp) else { + return nil + } + return (input: record.input, timestamp: date) + } + } + } + + nonisolated func searchCommandHistory(query: String) throws -> [(input: String, timestamp: Date)] { + try dbQueue.read { db in + let records = try HistoryRecord + .filter(Column("input").like("%\(query)%")) + .order(Column("timestamp").desc) + .fetchAll(db) + + return records.compactMap { record in + guard let date = isoFormatter.date(from: record.timestamp) else { + return nil + } + return (input: record.input, timestamp: date) + } + } + } } diff --git a/oAI/Services/SettingsService.swift b/oAI/Services/SettingsService.swift index 79e2bb9..f2cbdb4 100644 --- a/oAI/Services/SettingsService.swift +++ b/oAI/Services/SettingsService.swift @@ -89,6 +89,19 @@ class SettingsService { } } + var systemPrompt: String? { + get { cache["systemPrompt"] } + set { + if let value = newValue, !value.isEmpty { + cache["systemPrompt"] = value + DatabaseService.shared.setSetting(key: "systemPrompt", value: value) + } else { + cache.removeValue(forKey: "systemPrompt") + DatabaseService.shared.deleteSetting(key: "systemPrompt") + } + } + } + // MARK: - Feature Settings var onlineMode: Bool { diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index fcd4d2c..32b1546 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -32,16 +32,43 @@ class ChatViewModel { var showStats: Bool = false var showHelp: Bool = false var showCredits: Bool = false + var showHistory: Bool = false var modelInfoTarget: ModelInfo? = nil + var commandHistory: [String] = [] + var historyIndex: Int = 0 // MARK: - Private State - - private var commandHistory: [String] = [] - private var historyIndex: Int = -1 + private var streamingTask: Task? private let settings = SettingsService.shared private let providerRegistry = ProviderRegistry.shared - + + // 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. +""" + + /// Builds the complete system prompt by combining default + custom + private var effectiveSystemPrompt: String { + var prompt = defaultSystemPrompt + if let customPrompt = settings.systemPrompt, !customPrompt.isEmpty { + prompt += "\n\n---\n\nAdditional Instructions:\n" + customPrompt + } + return prompt + } + // MARK: - Initialization init() { @@ -50,6 +77,17 @@ class ChatViewModel { self.onlineMode = settings.onlineMode self.memoryEnabled = settings.memoryEnabled self.mcpEnabled = settings.mcpEnabled + + // Load command history from database + if let history = try? DatabaseService.shared.loadCommandHistory() { + self.commandHistory = history.map { $0.input } + self.historyIndex = self.commandHistory.count + } + + // Load models on startup + Task { + await loadAvailableModels() + } } // MARK: - Public Methods @@ -102,7 +140,12 @@ class ChatViewModel { let models = try await provider.listModels() availableModels = models - if selectedModel == nil, let firstModel = models.first { + + // Select model priority: saved default > current selection > first available + if let defaultModelId = settings.defaultModel, + let defaultModel = models.first(where: { $0.id == defaultModelId }) { + selectedModel = defaultModel + } else if selectedModel == nil, let firstModel = models.first { selectedModel = firstModel } isLoadingModels = false @@ -116,18 +159,22 @@ class ChatViewModel { func sendMessage() { guard !inputText.trimmingCharacters(in: .whitespaces).isEmpty else { return } - + let trimmedInput = inputText.trimmingCharacters(in: .whitespaces) - - // Check if it's a slash command - if trimmedInput.hasPrefix("/") { - handleCommand(trimmedInput) + + // Handle slash escape: "//" becomes "/" + var effectiveInput = trimmedInput + if effectiveInput.hasPrefix("//") { + effectiveInput = String(effectiveInput.dropFirst()) + } else if effectiveInput.hasPrefix("/") { + // Check if it's a slash command + handleCommand(effectiveInput) inputText = "" return } // Parse file attachments - let (cleanText, filePaths) = trimmedInput.parseFileAttachments() + let (cleanText, filePaths) = effectiveInput.parseFileAttachments() // Read file attachments from disk let attachments: [FileAttachment]? = filePaths.isEmpty ? nil : readFileAttachments(filePaths) @@ -141,17 +188,18 @@ class ChatViewModel { timestamp: Date(), attachments: attachments ) - + messages.append(userMessage) sessionStats.addMessage(inputTokens: userMessage.tokens, outputTokens: nil, cost: nil) - - // Clear input - inputText = "" - - // Add to command history + + // Add to command history (in-memory and database) commandHistory.append(trimmedInput) historyIndex = commandHistory.count - + DatabaseService.shared.saveCommandHistory(input: trimmedInput) + + // Clear input + inputText = "" + // Generate real AI response generateAIResponse(to: cleanText, attachments: userMessage.attachments) } @@ -220,6 +268,9 @@ class ChatViewModel { case "/help": showHelp = true + case "/history": + showHistory = true + case "/model": showModelSelector = true @@ -367,6 +418,8 @@ class ChatViewModel { // Start streaming streamingTask = Task { + let startTime = Date() + var messageId: UUID? do { // Create empty assistant message for streaming let assistantMessage = Message( @@ -378,7 +431,8 @@ class ChatViewModel { attachments: nil, isStreaming: true ) - + messageId = assistantMessage.id + // Already on MainActor messages.append(assistantMessage) @@ -411,14 +465,12 @@ class ChatViewModel { maxTokens: settings.maxTokens > 0 ? settings.maxTokens : nil, temperature: settings.temperature > 0 ? settings.temperature : nil, topP: nil, - systemPrompt: nil, + systemPrompt: effectiveSystemPrompt, tools: nil, onlineMode: onlineMode, imageGeneration: isImageGen ) - let messageId = assistantMessage.id - if isImageGen { // Image generation: use non-streaming request // Image models don't reliably support streaming @@ -435,11 +487,13 @@ class ChatViewModel { imageGeneration: true ) let response = try await provider.chat(request: nonStreamRequest) + let responseTime = Date().timeIntervalSince(startTime) if let index = messages.firstIndex(where: { $0.id == messageId }) { messages[index].content = response.content messages[index].isStreaming = false messages[index].generatedImages = response.generatedImages + messages[index].responseTime = responseTime if let usage = response.usage { messages[index].tokens = usage.completionTokens @@ -455,9 +509,13 @@ class ChatViewModel { // Regular text: stream response var fullContent = "" var totalTokens: ChatResponse.Usage? = nil + var wasCancelled = false for try await chunk in provider.streamChat(request: chatRequest) { - if Task.isCancelled { break } + if Task.isCancelled { + wasCancelled = true + break + } if let content = chunk.deltaContent { fullContent += content @@ -471,9 +529,19 @@ class ChatViewModel { } } + // Check for cancellation one more time after loop exits + // (in case it was cancelled after the last chunk) + if Task.isCancelled { + wasCancelled = true + } + + let responseTime = Date().timeIntervalSince(startTime) + if let index = messages.firstIndex(where: { $0.id == messageId }) { messages[index].content = fullContent messages[index].isStreaming = false + messages[index].responseTime = responseTime + messages[index].wasInterrupted = wasCancelled if let usage = totalTokens { messages[index].tokens = usage.completionTokens @@ -491,13 +559,27 @@ class ChatViewModel { streamingTask = nil } catch { - // Remove the empty streaming message - if let index = messages.lastIndex(where: { $0.role == .assistant && $0.content.isEmpty }) { - messages.remove(at: index) + let responseTime = Date().timeIntervalSince(startTime) + + // Check if this was a cancellation (either by checking Task state or error type) + let isCancellation = Task.isCancelled || error is CancellationError + + if isCancellation, let msgId = messageId { + // Mark the message as interrupted instead of removing it + if let index = messages.firstIndex(where: { $0.id == msgId }) { + messages[index].isStreaming = false + messages[index].wasInterrupted = true + messages[index].responseTime = responseTime + } + } else if let msgId = messageId { + // For real errors, remove the empty streaming message + if let index = messages.firstIndex(where: { $0.id == msgId && $0.content.isEmpty }) { + messages.remove(at: index) + } + Log.api.error("Generation failed: \(error.localizedDescription)") + showSystemMessage("❌ \(friendlyErrorMessage(from: error))") } - Log.api.error("Generation failed: \(error.localizedDescription)") - showSystemMessage("❌ \(friendlyErrorMessage(from: error))") isGenerating = false streamingTask = nil } @@ -682,6 +764,8 @@ class ChatViewModel { streamingTask?.cancel() streamingTask = Task { + let startTime = Date() + var wasCancelled = false do { let tools = mcp.getToolSchemas() @@ -702,7 +786,10 @@ class ChatViewModel { if !writeCapabilities.isEmpty { capabilities += " You can also \(writeCapabilities.joined(separator: ", "))." } - let systemContent = "You have access to the user's filesystem through tool calls. \(capabilities) The user has granted you access to these folders:\n - \(folderList)\n\nWhen the user asks about their files, use the tools proactively with the allowed paths. Always use absolute paths." + var systemContent = "You have access to the user's filesystem through tool calls. \(capabilities) The user has granted you access to these folders:\n - \(folderList)\n\nWhen the user asks about their files, use the tools proactively with the allowed paths. Always use absolute paths." + + // Append the complete system prompt (default + custom) + systemContent += "\n\n---\n\n" + effectiveSystemPrompt var messagesToSend: [Message] = memoryEnabled ? messages.filter { $0.role != .system } @@ -736,7 +823,10 @@ class ChatViewModel { var totalUsage: ChatResponse.Usage? for iteration in 0.. = [ - "/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, diff --git a/oAI/Views/Main/MarkdownContentView.swift b/oAI/Views/Main/MarkdownContentView.swift index 61ae1e6..e13c5eb 100644 --- a/oAI/Views/Main/MarkdownContentView.swift +++ b/oAI/Views/Main/MarkdownContentView.swift @@ -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.. 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 { diff --git a/oAI/Views/Main/MessageRow.swift b/oAI/Views/Main/MessageRow.swift index 4d19c38..c22397d 100644 --- a/oAI/Views/Main/MessageRow.swift +++ b/oAI/Views/Main/MessageRow.swift @@ -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( diff --git a/oAI/Views/Screens/ConversationListView.swift b/oAI/Views/Screens/ConversationListView.swift index ca17062..2f16c86 100644 --- a/oAI/Views/Screens/ConversationListView.swift +++ b/oAI/Views/Screens/ConversationListView.swift @@ -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 = [] + @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) } } diff --git a/oAI/Views/Screens/HelpView.swift b/oAI/Views/Screens/HelpView.swift index 8819dcb..0e85ac2 100644 --- a/oAI/Views/Screens/HelpView.swift +++ b/oAI/Views/Screens/HelpView.swift @@ -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 diff --git a/oAI/Views/Screens/HistoryView.swift b/oAI/Views/Screens/HistoryView.swift new file mode 100644 index 0000000..04a8e06 --- /dev/null +++ b/oAI/Views/Screens/HistoryView.swift @@ -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)") + } +} diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift index d87a0a6..0a91350 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -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(_ 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() } diff --git a/oAI/oAIApp.swift b/oAI/oAIApp.swift index 353a4f0..4dc659e 100644 --- a/oAI/oAIApp.swift +++ b/oAI/oAIApp.swift @@ -6,6 +6,9 @@ // import SwiftUI +#if os(macOS) +import AppKit +#endif @main struct oAIApp: App { @@ -32,7 +35,25 @@ struct oAIApp: App { showAbout = true } } + + CommandGroup(replacing: .help) { + Button("oAI Help") { + openHelp() + } + .keyboardShortcut("?", modifiers: .command) + } } #endif } + + #if os(macOS) + private func openHelp() { + if let helpBookURL = Bundle.main.url(forResource: "oAI.help", withExtension: nil) { + NSWorkspace.shared.open(helpBookURL.appendingPathComponent("Contents/Resources/en.lproj/index.html")) + } else { + // Fallback to Apple Help if help book not found + NSHelpManager.shared.openHelpAnchor("", inBook: Bundle.main.object(forInfoDictionaryKey: "CFBundleHelpBookName") as? String) + } + } + #endif }