Files
oai-swift/oAI/Views/Screens/HelpView.swift
T
runeandClaude Sonnet 5 bd686873c4 Update commercial licensing contact URL to oai.pm
Consolidates the mac.oai.pm subdomain references (introduced in the
PolyForm Noncommercial relicense) to the root oai.pm domain, across
the LICENSE file, README, and all Swift source file headers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 11:33:53 +02:00

536 lines
22 KiB
Swift

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