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

10
oAI/Info.plist Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleHelpBookFolder</key>
<string>oAI.help</string>
<key>CFBundleHelpBookName</key>
<string>oAI Help</string>
</dict>
</plist>

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleIdentifier</key>
<string>com.rune.oAI.help</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>oAI Help</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>hbwr</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>HPDBookAccessPath</key>
<string>index.html</string>
<key>HPDBookIconPath</key>
<string>images/icon.png</string>
<key>HPDBookTitle</key>
<string>oAI Help</string>
<key>HPDBookType</key>
<string>3</string>
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -0,0 +1,550 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="AppleTitle" content="oAI Help">
<meta name="AppleIcon" content="images/icon.png">
<meta name="description" content="oAI - AI Chat Assistant for macOS">
<meta name="keywords" content="oAI, AI, chat, assistant, OpenAI, Anthropic, Claude, GPT, commands, help">
<title>oAI Help</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<header>
<img src="images/icon.png" alt="oAI Icon" class="app-icon">
<h1>oAI Help</h1>
<p class="subtitle">AI Chat Assistant for macOS</p>
</header>
<nav class="toc">
<h2>Contents</h2>
<ul>
<li><a href="#getting-started">Getting Started</a></li>
<li><a href="#providers">AI Providers &amp; API Keys</a></li>
<li><a href="#models">Selecting Models</a></li>
<li><a href="#sending-messages">Sending Messages</a></li>
<li><a href="#file-attachments">File Attachments</a></li>
<li><a href="#commands">Slash Commands</a></li>
<li><a href="#memory">Memory &amp; Context</a></li>
<li><a href="#online-mode">Online Mode (Web Search)</a></li>
<li><a href="#mcp">MCP (File Access)</a></li>
<li><a href="#conversations">Managing Conversations</a></li>
<li><a href="#keyboard-shortcuts">Keyboard Shortcuts</a></li>
<li><a href="#settings">Settings</a></li>
<li><a href="#system-prompts">System Prompts</a></li>
</ul>
</nav>
<main>
<!-- Getting Started -->
<section id="getting-started">
<h2>Getting Started</h2>
<p>oAI is a powerful AI chat assistant that connects to multiple AI providers including OpenAI, Anthropic, OpenRouter, and local models via Ollama.</p>
<div class="steps">
<h3>Quick Start</h3>
<ol>
<li><strong>Add an API key</strong>: Press <kbd>⌘,</kbd> to open Settings, then add your API key for your preferred provider</li>
<li><strong>Select a model</strong>: Press <kbd>⌘M</kbd> or type <code>/model</code> to choose an AI model</li>
<li><strong>Start chatting</strong>: Type your message and press <kbd>Return</kbd> to send</li>
</ol>
</div>
<div class="tip">
<strong>💡 Tip:</strong> Type <code>/</code> in the message input to see all available commands with autocomplete suggestions.
</div>
</section>
<!-- Providers -->
<section id="providers">
<h2>AI Providers &amp; API Keys</h2>
<p>oAI supports multiple AI providers. You'll need an API key from at least one provider to use the app.</p>
<h3>Supported Providers</h3>
<ul class="provider-list">
<li><strong>OpenRouter</strong> - Access to 300+ models through a single API (<a href="https://openrouter.ai">openrouter.ai</a>)</li>
<li><strong>Anthropic</strong> - Claude models (Opus, Sonnet, Haiku) (<a href="https://console.anthropic.com">console.anthropic.com</a>)</li>
<li><strong>OpenAI</strong> - GPT-4, GPT-4 Turbo, GPT-3.5 models (<a href="https://platform.openai.com">platform.openai.com</a>)</li>
<li><strong>Ollama</strong> - Run models locally on your Mac (<a href="https://ollama.ai">ollama.ai</a>)</li>
</ul>
<h3>Adding an API Key</h3>
<ol>
<li>Press <kbd>⌘,</kbd> or type <code>/config</code> to open Settings</li>
<li>Select the <strong>Providers</strong> tab</li>
<li>Enter your API key for the desired provider</li>
<li>Click <strong>Save</strong></li>
</ol>
<h3>Switching Providers</h3>
<p>Use the provider dropdown in the header or type:</p>
<code class="command">/provider anthropic</code>
<code class="command">/provider openai</code>
<code class="command">/provider openrouter</code>
</section>
<!-- Models -->
<section id="models">
<h2>Selecting Models</h2>
<p>Different models have different capabilities, speeds, and costs. Choose the right model for your task.</p>
<h3>Opening the Model Selector</h3>
<ul>
<li>Press <kbd>⌘M</kbd></li>
<li>Type <code>/model</code></li>
<li>Click the model name in the header</li>
</ul>
<h3>Model Information</h3>
<p>View details about any model:</p>
<code class="command">/info</code>
<p class="note">Shows information about the currently selected model including context length, pricing, and capabilities.</p>
<h3>Default Model</h3>
<p>Your selected model is automatically saved and will be restored when you restart the app.</p>
</section>
<!-- Sending Messages -->
<section id="sending-messages">
<h2>Sending Messages</h2>
<h3>Basic Usage</h3>
<ul>
<li>Type your message in the input field</li>
<li>Press <kbd>Return</kbd> to send (single-line messages)</li>
<li>Press <kbd>⇧Return</kbd> to add a new line without sending</li>
</ul>
<h3>During Generation</h3>
<p>While the AI is responding:</p>
<ul>
<li>Press <kbd>Esc</kbd> to cancel generation</li>
<li>Click the <strong>Stop</strong> button (red circle)</li>
<li>Cancelled responses show a <strong>⚠️ interrupted</strong> indicator</li>
</ul>
<h3>Retrying Messages</h3>
<p>If you're not satisfied with a response:</p>
<code class="command">/retry</code>
<p class="note">Resends your last message to generate a new response.</p>
</section>
<!-- File Attachments -->
<section id="file-attachments">
<h2>File Attachments</h2>
<p>Attach files to your messages for the AI to analyze. Supports images, PDFs, and text files.</p>
<h3>Attaching Files</h3>
<ul>
<li>Click the <strong>📎 paperclip icon</strong> to browse and select files</li>
<li>Type <code>@/path/to/file.txt</code> in your message</li>
<li>Type <code>@~/Documents/image.png</code> for files in your home directory</li>
</ul>
<h3>Supported File Types</h3>
<ul>
<li><strong>Images</strong>: PNG, JPEG, GIF, WebP, BMP, SVG (max 10 MB)</li>
<li><strong>Documents</strong>: PDF (max 10 MB)</li>
<li><strong>Text</strong>: TXT, code files, logs, etc. (max 50 KB)</li>
</ul>
<div class="tip">
<strong>💡 Tip:</strong> Large text files are automatically truncated to prevent token limits.
</div>
</section>
<!-- Commands -->
<section id="commands">
<h2>Slash Commands</h2>
<p>Commands start with <code>/</code> and provide quick access to features. Type <code>/</code> to see suggestions.</p>
<h3>Chat Commands</h3>
<dl class="commands">
<dt>/history</dt>
<dd>View command history with timestamps (European format: dd.MM.yyyy). Search by text or date to find previous messages</dd>
<dt>/clear</dt>
<dd>Clear all messages from the current session</dd>
<dt>/retry</dt>
<dd>Retry your last message to get a new response</dd>
<dt>/memory on|off</dt>
<dd>Toggle conversation memory. When off, only your latest message is sent (no conversation history)</dd>
<dt>/online on|off</dt>
<dd>Enable/disable web search. When on, the AI can search the web for current information</dd>
</dl>
<h3>Model &amp; Provider Commands</h3>
<dl class="commands">
<dt>/model</dt>
<dd>Open the model selector</dd>
<dt>/provider [name]</dt>
<dd>Switch AI provider or show current provider</dd>
<dt>/info</dt>
<dd>Display information about the current model</dd>
<dt>/credits</dt>
<dd>Check your account balance (OpenRouter only)</dd>
</dl>
<h3>Conversation Commands</h3>
<dl class="commands">
<dt>/save &lt;name&gt;</dt>
<dd>Save the current conversation</dd>
<dt>/load</dt>
<dd>Browse and load a saved conversation</dd>
<dt>/list</dt>
<dd>Show all saved conversations</dd>
<dt>/delete &lt;name&gt;</dt>
<dd>Delete a saved conversation</dd>
<dt>/export md|json</dt>
<dd>Export conversation as Markdown or JSON</dd>
</dl>
<h3>MCP Commands</h3>
<dl class="commands">
<dt>/mcp on|off</dt>
<dd>Enable/disable file access for the AI</dd>
<dt>/mcp add &lt;path&gt;</dt>
<dd>Grant AI access to a folder</dd>
<dt>/mcp remove &lt;path&gt;</dt>
<dd>Revoke AI access to a folder</dd>
<dt>/mcp list</dt>
<dd>Show all accessible folders</dd>
<dt>/mcp status</dt>
<dd>Display MCP status and permissions</dd>
<dt>/mcp write on|off</dt>
<dd>Enable/disable write permissions</dd>
</dl>
<h3>Settings Commands</h3>
<dl class="commands">
<dt>/config, /settings</dt>
<dd>Open the settings panel</dd>
<dt>/stats</dt>
<dd>Show session statistics (messages, tokens, cost)</dd>
</dl>
</section>
<!-- Memory -->
<section id="memory">
<h2>Memory &amp; Context</h2>
<p>Control how much conversation history the AI remembers.</p>
<h3>Memory Enabled (Default)</h3>
<p>When memory is <strong>on</strong>, the AI remembers all previous messages in your session. This allows for natural, flowing conversations.</p>
<h3>Memory Disabled</h3>
<p>When memory is <strong>off</strong>, only your latest message is sent. Each message is independent. Useful for:</p>
<ul>
<li>Quick, unrelated questions</li>
<li>Reducing token usage and cost</li>
<li>Avoiding context pollution</li>
</ul>
<h3>Toggle Memory</h3>
<code class="command">/memory on</code>
<code class="command">/memory off</code>
<div class="note">
<strong>Note:</strong> Memory state is shown in the header with a badge when enabled.
</div>
</section>
<!-- Online Mode -->
<section id="online-mode">
<h2>Online Mode (Web Search)</h2>
<p>Enable the AI to search the web for current information before responding.</p>
<h3>When to Use Online Mode</h3>
<ul>
<li>Questions about current events</li>
<li>Recent news or updates</li>
<li>Real-time data (weather, stocks, etc.)</li>
<li>Information beyond the AI's training cutoff</li>
</ul>
<h3>Enabling Online Mode</h3>
<code class="command">/online on</code>
<h3>How It Works</h3>
<p>When online mode is enabled:</p>
<ol>
<li>Your question is used to search the web</li>
<li>Relevant results are retrieved</li>
<li>Search results are added to your message context</li>
<li>The AI uses the web results to inform its response</li>
</ol>
<div class="note">
<strong>Note:</strong> Online mode is shown in the input bar with a 🌐 badge when active.
</div>
</section>
<!-- MCP -->
<section id="mcp">
<h2>MCP (File Access)</h2>
<p>MCP (Model Context Protocol) allows the AI to access, read, and optionally modify files on your computer.</p>
<div class="warning">
<strong>⚠️ Security:</strong> Only grant access to folders you trust the AI to work with. Review permissions carefully.
</div>
<h3>Setting Up MCP</h3>
<ol>
<li>Enable MCP: <code>/mcp on</code></li>
<li>Add a folder: <code>/mcp add ~/Projects/myapp</code></li>
<li>Ask the AI questions about your files</li>
</ol>
<h3>What the AI Can Do</h3>
<p><strong>Read permissions (always enabled when MCP is on):</strong></p>
<ul>
<li>Read file contents</li>
<li>List directory contents</li>
<li>Search for files</li>
<li>Get file information (size, modified date, etc.)</li>
</ul>
<p><strong>Write permissions (optional, disabled by default):</strong></p>
<ul>
<li>Write and edit files</li>
<li>Create new files</li>
<li>Delete files</li>
<li>Create directories</li>
<li>Move and copy files</li>
</ul>
<h3>Managing Permissions</h3>
<p>Toggle all write permissions:</p>
<code class="command">/mcp write on</code>
<code class="command">/mcp write off</code>
<p>For fine-grained control, open Settings → MCP to enable/disable individual permissions.</p>
<h3>Gitignore Respect</h3>
<p>When enabled in Settings, MCP will ignore files and folders listed in <code>.gitignore</code> files.</p>
<h3>Example Usage</h3>
<div class="example">
<p><strong>You:</strong> "What files are in my project folder?"</p>
<p><strong>AI:</strong> <em>(uses MCP to list files)</em> "I found 15 files including src/main.py, README.md..."</p>
</div>
</section>
<!-- Conversations -->
<section id="conversations">
<h2>Managing Conversations</h2>
<p>Save, load, and export your chat conversations.</p>
<h3>Saving Conversations</h3>
<code class="command">/save my-project-chat</code>
<p class="note">Saves all current messages under the specified name.</p>
<h3>Loading Conversations</h3>
<code class="command">/load</code>
<p class="note">Opens a list of saved conversations. Select one to load.</p>
<h3>Viewing Saved Conversations</h3>
<ul>
<li>Press <kbd>⌘L</kbd></li>
<li>Type <code>/list</code></li>
</ul>
<h3>Deleting Conversations</h3>
<code class="command">/delete old-chat</code>
<p class="warning"><strong>Warning:</strong> This action cannot be undone.</p>
<h3>Exporting Conversations</h3>
<p>Export to Markdown or JSON format:</p>
<code class="command">/export md</code>
<code class="command">/export json</code>
<p class="note">Files are saved to your Downloads folder.</p>
</section>
<!-- Keyboard Shortcuts -->
<section id="keyboard-shortcuts">
<h2>Keyboard Shortcuts</h2>
<p>Work faster with these keyboard shortcuts.</p>
<h3>General</h3>
<dl class="shortcuts">
<dt><kbd>⌘,</kbd></dt>
<dd>Open Settings</dd>
<dt><kbd>⌘/</kbd></dt>
<dd>Show in-app Help</dd>
<dt><kbd>⌘?</kbd></dt>
<dd>Open this Help (macOS Help)</dd>
<dt><kbd>⌘L</kbd></dt>
<dd>Browse Conversations</dd>
<dt><kbd>⌘H</kbd></dt>
<dd>Command History</dd>
<dt><kbd>⌘M</kbd></dt>
<dd>Model Selector</dd>
<dt><kbd>⌘K</kbd></dt>
<dd>Clear Chat</dd>
<dt><kbd>⇧⌘S</kbd></dt>
<dd>Show Statistics</dd>
</dl>
<h3>Message Input</h3>
<dl class="shortcuts">
<dt><kbd>Return</kbd></dt>
<dd>Send message (single-line messages)</dd>
<dt><kbd>⇧Return</kbd></dt>
<dd>New line (multi-line messages)</dd>
<dt><kbd>Esc</kbd></dt>
<dd>Cancel generation or close command dropdown</dd>
<dt><kbd></kbd> <kbd></kbd></dt>
<dd>Navigate command suggestions (when typing /)</dd>
<dt><kbd>Return</kbd></dt>
<dd>Select highlighted command (when dropdown is open)</dd>
</dl>
</section>
<!-- Settings -->
<section id="settings">
<h2>Settings</h2>
<p>Customize oAI to your preferences. Press <kbd>⌘,</kbd> to open Settings.</p>
<h3>Providers Tab</h3>
<ul>
<li>Add and manage API keys for different providers</li>
<li>Switch between providers</li>
<li>Set default models</li>
</ul>
<h3>MCP Tab</h3>
<ul>
<li>Manage folder access permissions</li>
<li>Enable/disable write operations</li>
<li>Configure gitignore respect</li>
</ul>
<h3>Appearance Tab</h3>
<ul>
<li>Adjust text sizes for input and dialog</li>
<li>Customize UI preferences</li>
</ul>
<h3>Advanced Tab</h3>
<ul>
<li>Enable/disable streaming responses</li>
<li>Set maximum tokens (response length limit)</li>
<li>Adjust temperature (creativity vs focus)</li>
<li>Configure system prompts (see below)</li>
</ul>
</section>
<!-- System Prompts -->
<section id="system-prompts">
<h2>System Prompts</h2>
<p>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.</p>
<div class="tip">
<strong>📚 Learn More:</strong> For a detailed explanation of system prompts and how they work, see <a href="https://en.wikipedia.org/wiki/Prompt_engineering" target="_blank">Prompt Engineering on Wikipedia</a> or the <a href="https://platform.openai.com/docs/guides/prompt-engineering" target="_blank">OpenAI Prompt Engineering Guide</a>.
</div>
<h3>What Are System Prompts?</h3>
<p>System prompts are special instructions sent to the AI with every conversation. Unlike your regular messages, system prompts:</p>
<ul>
<li>Are invisible to you in the chat interface</li>
<li>Set behavioral guidelines for the AI</li>
<li>Establish the AI's role, tone, and constraints</li>
<li>Are sent automatically with every message</li>
<li>Help ensure consistent, reliable responses</li>
</ul>
<h3>Default System Prompt</h3>
<p>oAI includes a carefully crafted default system prompt that emphasizes:</p>
<ul>
<li><strong>Accuracy First</strong> - Never invent information or make assumptions</li>
<li><strong>Ask for Clarification</strong> - Request details when requests are ambiguous</li>
<li><strong>Honest About Limitations</strong> - Clearly state when it cannot help</li>
<li><strong>Stay Grounded</strong> - Base responses on facts, not speculation</li>
<li><strong>Be Direct</strong> - Provide concise, relevant answers</li>
</ul>
<p class="note"><strong>Note:</strong> The default prompt is always active and cannot be disabled. It ensures the AI provides accurate, helpful responses and doesn't fabricate information.</p>
<h3>Custom System Prompt</h3>
<p>You can add your own custom instructions that will be appended to the default prompt. Use custom prompts to:</p>
<ul>
<li>Define a specific role (e.g., "You are a Python expert")</li>
<li>Set output format preferences (e.g., "Always provide code examples")</li>
<li>Add domain-specific knowledge or context</li>
<li>Establish tone or communication style</li>
<li>Create task-specific guidelines</li>
</ul>
<h3>How Prompts Are Combined</h3>
<p>When you send a message, oAI constructs the complete system prompt like this:</p>
<div class="example">
<p><strong>Complete System Prompt =</strong></p>
<ol>
<li>Default Prompt (always included)</li>
<li>+ Your Custom Prompt (if set)</li>
<li>+ MCP Instructions (if MCP is enabled)</li>
</ol>
<p><em>This combined prompt is sent with every message to ensure consistent behavior.</em></p>
</div>
<h3>Editing System Prompts</h3>
<p>To view or edit system prompts:</p>
<ol>
<li>Press <kbd>⌘,</kbd> to open Settings</li>
<li>Go to the <strong>Advanced</strong> tab</li>
<li>Scroll to the <strong>System Prompts</strong> section</li>
<li>View the default prompt (read-only)</li>
<li>Add your custom instructions in the editable field below</li>
</ol>
<h3>Best Practices</h3>
<ul>
<li><strong>Be Specific</strong> - Clear instructions produce better results</li>
<li><strong>Keep It Simple</strong> - Don't overcomplicate your custom prompt</li>
<li><strong>Test Changes</strong> - Try your prompt with a few messages to see if it works</li>
<li><strong>Start Empty</strong> - The default prompt works well; only add custom instructions if needed</li>
<li><strong>Avoid Contradictions</strong> - Don't contradict the default prompt's guidelines</li>
</ul>
<div class="warning">
<strong>⚠️ Important:</strong> Custom prompts affect all conversations. Changes apply immediately to new messages. If you experience unexpected behavior, try removing your custom prompt.
</div>
</section>
</main>
<footer>
<p>© 2026 oAI. For support or feedback, visit <a href="https://gitlab.pm/rune/oai-swift">gitlab.pm</a>.</p>
</footer>
</div>
</body>
</html>

View File

@@ -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;
}
}

View File

@@ -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
<meta name="AppleTitle" content="Page Title">
<meta name="keywords" content="keyword1, keyword2">
```

View File

@@ -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)
}
}
}
}

View File

@@ -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 {

View File

@@ -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<Void, Never>?
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..<maxIterations {
if Task.isCancelled { break }
if Task.isCancelled {
wasCancelled = true
break
}
let response = try await provider.chatWithToolMessages(
model: effectiveModelId,
@@ -779,7 +869,10 @@ class ChatViewModel {
// Execute each tool and append results
for tc in toolCalls {
if Task.isCancelled { break }
if Task.isCancelled {
wasCancelled = true
break
}
let result = mcp.executeTool(name: tc.functionName, arguments: tc.arguments)
let resultJSON: String
@@ -806,13 +899,22 @@ class ChatViewModel {
}
}
// Check for cancellation one more time after loop exits
if Task.isCancelled {
wasCancelled = true
}
// Display the final response as an assistant message
let responseTime = Date().timeIntervalSince(startTime)
let assistantMessage = Message(
role: .assistant,
content: finalContent,
tokens: totalUsage?.completionTokens,
cost: nil,
timestamp: Date()
timestamp: Date(),
attachments: nil,
responseTime: responseTime,
wasInterrupted: wasCancelled
)
messages.append(assistantMessage)
@@ -834,8 +936,26 @@ class ChatViewModel {
streamingTask = nil
} catch {
Log.api.error("Tool generation failed: \(error.localizedDescription)")
showSystemMessage("\(friendlyErrorMessage(from: error))")
let responseTime = Date().timeIntervalSince(startTime)
// Check if this was a cancellation
let isCancellation = Task.isCancelled || wasCancelled || error is CancellationError
if isCancellation {
// Create an interrupted message
let assistantMessage = Message(
role: .assistant,
content: "",
timestamp: Date(),
responseTime: responseTime,
wasInterrupted: true
)
messages.append(assistantMessage)
} else {
Log.api.error("Tool generation failed: \(error.localizedDescription)")
showSystemMessage("\(friendlyErrorMessage(from: error))")
}
isGenerating = false
streamingTask = nil
}

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()
}

View File

@@ -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
}