// // HelpView.swift // oAI // // Help and commands reference with expandable detail and search // import SwiftUI // MARK: - Data Model struct CommandDetail: Identifiable { let id = UUID() let command: String let brief: String let detail: String let examples: [String] } struct CommandCategory: Identifiable { let id = UUID() let name: String 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"] ), 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"] ), 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"] ), 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 ", 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"] ), 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"] ), CommandDetail( command: "/list", brief: "List saved conversations", detail: "Opens the conversation list showing all saved conversations with their dates and message counts.", examples: ["/list"] ), CommandDetail( command: "/delete ", 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 ", 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 ", 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: "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"] ), 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"] ), ]), ] 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 var filteredCategories: [CommandCategory] { if searchText.isEmpty { return helpCategories } let q = searchText.lowercased() return helpCategories.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(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(command.brief) .font(.callout) .foregroundStyle(.secondary) .lineLimit(1) Spacer() 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(command.detail) .font(.callout) .foregroundStyle(.primary) .fixedSize(horizontal: false, vertical: true) if !command.examples.isEmpty { VStack(alignment: .leading, spacing: 6) { Text(command.examples.count == 1 ? "Example" : "Examples") .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() }