345 lines
13 KiB
Swift
345 lines
13 KiB
Swift
import Cocoa
|
|
import Foundation
|
|
|
|
// MARK: - Configuration & Settings
|
|
class TerminalSettings {
|
|
static let shared = TerminalSettings()
|
|
|
|
var theme: Theme = .dracula
|
|
var fontSize: CGFloat = 14
|
|
var fontName: String = "Menlo"
|
|
var opacity: CGFloat = 1.0
|
|
var aiProvider: AIProvider = .none
|
|
var openAIKey: String = ""
|
|
var ollamaURL: String = "http://localhost:11434"
|
|
var ollamaModel: String = "llama2"
|
|
|
|
enum AIProvider: String {
|
|
case none = "None"
|
|
case openai = "OpenAI"
|
|
case ollama = "Ollama"
|
|
}
|
|
|
|
struct Theme {
|
|
let name: String
|
|
let background: NSColor
|
|
let foreground: NSColor
|
|
let cursor: NSColor
|
|
let selection: NSColor
|
|
|
|
static let dracula = Theme(
|
|
name: "Dracula",
|
|
background: NSColor(red: 0.16, green: 0.16, blue: 0.21, alpha: 1.0),
|
|
foreground: NSColor(red: 0.95, green: 0.95, blue: 0.95, alpha: 1.0),
|
|
cursor: NSColor(red: 0.97, green: 0.97, blue: 0.95, alpha: 1.0),
|
|
selection: NSColor(red: 0.27, green: 0.28, blue: 0.35, alpha: 1.0)
|
|
)
|
|
|
|
static let solarizedDark = Theme(
|
|
name: "Solarized Dark",
|
|
background: NSColor(red: 0.0, green: 0.17, blue: 0.21, alpha: 1.0),
|
|
foreground: NSColor(red: 0.51, green: 0.58, blue: 0.59, alpha: 1.0),
|
|
cursor: NSColor(red: 0.51, green: 0.58, blue: 0.59, alpha: 1.0),
|
|
selection: NSColor(red: 0.03, green: 0.21, blue: 0.26, alpha: 1.0)
|
|
)
|
|
|
|
static let nord = Theme(
|
|
name: "Nord",
|
|
background: NSColor(red: 0.18, green: 0.20, blue: 0.25, alpha: 1.0),
|
|
foreground: NSColor(red: 0.85, green: 0.87, blue: 0.91, alpha: 1.0),
|
|
cursor: NSColor(red: 0.85, green: 0.87, blue: 0.91, alpha: 1.0),
|
|
selection: NSColor(red: 0.26, green: 0.30, blue: 0.37, alpha: 1.0)
|
|
)
|
|
|
|
static let classic = Theme(
|
|
name: "Classic",
|
|
background: .black,
|
|
foreground: NSColor(red: 0.0, green: 1.0, blue: 0.0, alpha: 1.0),
|
|
cursor: NSColor(red: 0.0, green: 1.0, blue: 0.0, alpha: 1.0),
|
|
selection: NSColor(red: 0.2, green: 0.2, blue: 0.2, alpha: 1.0)
|
|
)
|
|
|
|
static let allThemes: [Theme] = [.classic, .dracula, .solarizedDark, .nord]
|
|
}
|
|
}
|
|
|
|
// MARK: - Command Snippet Manager
|
|
class SnippetManager {
|
|
static let shared = SnippetManager()
|
|
private var snippets: [Snippet] = []
|
|
|
|
struct Snippet: Codable {
|
|
let id: UUID
|
|
var name: String
|
|
var command: String
|
|
var description: String
|
|
var tags: [String]
|
|
var category: String
|
|
|
|
init(name: String, command: String, description: String = "", tags: [String] = [], category: String = "General") {
|
|
self.id = UUID()
|
|
self.name = name
|
|
self.command = command
|
|
self.description = description
|
|
self.tags = tags
|
|
self.category = category
|
|
}
|
|
}
|
|
|
|
init() {
|
|
loadSnippets()
|
|
if snippets.isEmpty {
|
|
addDefaultSnippets()
|
|
}
|
|
}
|
|
|
|
func addSnippet(_ snippet: Snippet) {
|
|
snippets.append(snippet)
|
|
saveSnippets()
|
|
}
|
|
|
|
func removeSnippet(_ id: UUID) {
|
|
snippets.removeAll { $0.id == id }
|
|
saveSnippets()
|
|
}
|
|
|
|
func searchSnippets(_ query: String) -> [Snippet] {
|
|
if query.isEmpty { return snippets }
|
|
return snippets.filter {
|
|
$0.name.localizedCaseInsensitiveContains(query) ||
|
|
$0.command.localizedCaseInsensitiveContains(query) ||
|
|
$0.tags.contains { $0.localizedCaseInsensitiveContains(query) }
|
|
}
|
|
}
|
|
|
|
private func addDefaultSnippets() {
|
|
snippets = [
|
|
Snippet(name: "List files", command: "ls -lah", description: "List all files with details", tags: ["files", "list"], category: "Files"),
|
|
Snippet(name: "Find large files", command: "find . -type f -size +100M", description: "Find files larger than 100MB", tags: ["find", "files"], category: "Files"),
|
|
Snippet(name: "Git status", command: "git status", description: "Show git repository status", tags: ["git"], category: "Git"),
|
|
Snippet(name: "Git commit", command: "git add . && git commit -m \"\"", description: "Stage and commit changes", tags: ["git"], category: "Git"),
|
|
Snippet(name: "Docker ps", command: "docker ps -a", description: "List all containers", tags: ["docker"], category: "Docker"),
|
|
Snippet(name: "Kill port", command: "lsof -ti:8080 | xargs kill -9", description: "Kill process on port 8080", tags: ["network", "kill"], category: "Network")
|
|
]
|
|
saveSnippets()
|
|
}
|
|
|
|
private func saveSnippets() {
|
|
if let data = try? JSONEncoder().encode(snippets) {
|
|
UserDefaults.standard.set(data, forKey: "terminal_snippets")
|
|
}
|
|
}
|
|
|
|
private func loadSnippets() {
|
|
if let data = UserDefaults.standard.data(forKey: "terminal_snippets"),
|
|
let loaded = try? JSONDecoder().decode([Snippet].self, from: data) {
|
|
snippets = loaded
|
|
}
|
|
}
|
|
|
|
func getAllSnippets() -> [Snippet] {
|
|
return snippets
|
|
}
|
|
}
|
|
|
|
// MARK: - Command History Manager
|
|
class HistoryManager {
|
|
static let shared = HistoryManager()
|
|
private var history: [String] = []
|
|
private let maxHistory = 1000
|
|
|
|
init() {
|
|
loadHistory()
|
|
}
|
|
|
|
func addCommand(_ command: String) {
|
|
guard !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
|
history.append(command)
|
|
if history.count > maxHistory {
|
|
history.removeFirst()
|
|
}
|
|
saveHistory()
|
|
}
|
|
|
|
func searchHistory(_ query: String) -> [String] {
|
|
if query.isEmpty { return Array(history.reversed().prefix(50)) }
|
|
return history.reversed().filter { $0.localizedCaseInsensitiveContains(query) }
|
|
}
|
|
|
|
func getRecentCommands(_ count: Int = 10) -> [String] {
|
|
return Array(history.reversed().prefix(count))
|
|
}
|
|
|
|
private func saveHistory() {
|
|
UserDefaults.standard.set(history, forKey: "terminal_history")
|
|
}
|
|
|
|
private func loadHistory() {
|
|
history = UserDefaults.standard.stringArray(forKey: "terminal_history") ?? []
|
|
}
|
|
}
|
|
|
|
// MARK: - AI Service
|
|
protocol AIService {
|
|
func generateCommand(from prompt: String, completion: @escaping (Result<String, Error>) -> Void)
|
|
func explainError(_ error: String, completion: @escaping (Result<String, Error>) -> Void)
|
|
func suggestNextCommand(history: [String], completion: @escaping (Result<String, Error>) -> Void)
|
|
}
|
|
|
|
class OpenAIService: AIService {
|
|
private let apiKey: String
|
|
|
|
init(apiKey: String) {
|
|
self.apiKey = apiKey
|
|
}
|
|
|
|
func generateCommand(from prompt: String, completion: @escaping (Result<String, Error>) -> Void) {
|
|
let systemPrompt = "You are a terminal command expert. Generate only the command, no explanations. Respond with just the command."
|
|
makeRequest(system: systemPrompt, user: prompt, completion: completion)
|
|
}
|
|
|
|
func explainError(_ error: String, completion: @escaping (Result<String, Error>) -> Void) {
|
|
let systemPrompt = "You are a terminal expert. Explain this error briefly and suggest a fix."
|
|
makeRequest(system: systemPrompt, user: error, completion: completion)
|
|
}
|
|
|
|
func suggestNextCommand(history: [String], completion: @escaping (Result<String, Error>) -> Void) {
|
|
let systemPrompt = "Based on command history, suggest the next logical command. Respond with just the command."
|
|
let historyText = history.suffix(5).joined(separator: "\n")
|
|
makeRequest(system: systemPrompt, user: "Recent commands:\n\(historyText)", completion: completion)
|
|
}
|
|
|
|
private func makeRequest(system: String, user: String, completion: @escaping (Result<String, Error>) -> Void) {
|
|
guard let url = URL(string: "https://api.openai.com/v1/chat/completions") else { return }
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
|
|
let body: [String: Any] = [
|
|
"model": "gpt-3.5-turbo",
|
|
"messages": [
|
|
["role": "system", "content": system],
|
|
["role": "user", "content": user]
|
|
],
|
|
"max_tokens": 150,
|
|
"temperature": 0.7
|
|
]
|
|
|
|
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
|
|
|
URLSession.shared.dataTask(with: request) { data, response, error in
|
|
if let error = error {
|
|
completion(.failure(error))
|
|
return
|
|
}
|
|
|
|
guard let data = data,
|
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let choices = json["choices"] as? [[String: Any]],
|
|
let firstChoice = choices.first,
|
|
let message = firstChoice["message"] as? [String: Any],
|
|
let content = message["content"] as? String else {
|
|
completion(.failure(NSError(domain: "OpenAI", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid response"])))
|
|
return
|
|
}
|
|
|
|
completion(.success(content.trimmingCharacters(in: .whitespacesAndNewlines)))
|
|
}.resume()
|
|
}
|
|
}
|
|
|
|
class OllamaService: AIService {
|
|
private let baseURL: String
|
|
private let model: String
|
|
|
|
init(baseURL: String, model: String) {
|
|
self.baseURL = baseURL
|
|
self.model = model
|
|
}
|
|
|
|
func generateCommand(from prompt: String, completion: @escaping (Result<String, Error>) -> Void) {
|
|
let fullPrompt = "Generate only the terminal command for: \(prompt). Respond with just the command, no explanation."
|
|
makeRequest(prompt: fullPrompt, completion: completion)
|
|
}
|
|
|
|
func explainError(_ error: String, completion: @escaping (Result<String, Error>) -> Void) {
|
|
let fullPrompt = "Explain this terminal error briefly and suggest a fix:\n\(error)"
|
|
makeRequest(prompt: fullPrompt, completion: completion)
|
|
}
|
|
|
|
func suggestNextCommand(history: [String], completion: @escaping (Result<String, Error>) -> Void) {
|
|
let historyText = history.suffix(5).joined(separator: "\n")
|
|
let fullPrompt = "Based on these recent commands, suggest the next logical command:\n\(historyText)\nRespond with just the command."
|
|
makeRequest(prompt: fullPrompt, completion: completion)
|
|
}
|
|
|
|
private func makeRequest(prompt: String, completion: @escaping (Result<String, Error>) -> Void) {
|
|
guard let url = URL(string: "\(baseURL)/api/generate") else { return }
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
|
|
let body: [String: Any] = [
|
|
"model": model,
|
|
"prompt": prompt,
|
|
"stream": false
|
|
]
|
|
|
|
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
|
|
|
URLSession.shared.dataTask(with: request) { data, response, error in
|
|
if let error = error {
|
|
completion(.failure(error))
|
|
return
|
|
}
|
|
|
|
guard let data = data,
|
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let response = json["response"] as? String else {
|
|
completion(.failure(NSError(domain: "Ollama", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid response"])))
|
|
return
|
|
}
|
|
|
|
completion(.success(response.trimmingCharacters(in: .whitespacesAndNewlines)))
|
|
}.resume()
|
|
}
|
|
}
|
|
|
|
class AIManager {
|
|
static let shared = AIManager()
|
|
private var service: AIService?
|
|
|
|
func updateService() {
|
|
let settings = TerminalSettings.shared
|
|
switch settings.aiProvider {
|
|
case .openai:
|
|
if !settings.openAIKey.isEmpty {
|
|
service = OpenAIService(apiKey: settings.openAIKey)
|
|
}
|
|
case .ollama:
|
|
service = OllamaService(baseURL: settings.ollamaURL, model: settings.ollamaModel)
|
|
case .none:
|
|
service = nil
|
|
}
|
|
}
|
|
|
|
func isAvailable() -> Bool {
|
|
return service != nil
|
|
}
|
|
|
|
func generateCommand(from prompt: String, completion: @escaping (Result<String, Error>) -> Void) {
|
|
service?.generateCommand(from: prompt, completion: completion)
|
|
}
|
|
|
|
func explainError(_ error: String, completion: @escaping (Result<String, Error>) -> Void) {
|
|
service?.explainError(error, completion: completion)
|
|
}
|
|
|
|
func suggestNextCommand(history: [String], completion: @escaping (Result<String, Error>) -> Void) {
|
|
service?.suggestNextCommand(history: history, completion: completion)
|
|
}
|
|
}
|