494 lines
20 KiB
Swift
494 lines
20 KiB
Swift
//
|
|
// 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 <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"]
|
|
),
|
|
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 <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: "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 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(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()
|
|
}
|