import Cocoa // MARK: - Terminal Session class TerminalSession: NSObject, NSTextViewDelegate, NSMenuDelegate { var textView: NSTextView! var scrollView: NSScrollView! // Store scroll view as property var shellTask: Process? var inputPipe: Pipe? var outputPipe: Pipe? var promptPosition = 0 var sessionID: UUID var title: String var currentWorkingDirectory: String = "~" private var currentSuggestion: String? private var historyIndex: Int = -1 private var historyBuffer: [String] = [] private var currentInput: String = "" init(title: String = "Terminal") { self.sessionID = UUID() self.title = title super.init() } func setupTextView(frame: NSRect) { scrollView = NSScrollView(frame: frame) scrollView.autoresizingMask = [.width, .height] scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = false textView = NSTextView(frame: scrollView.bounds) textView.autoresizingMask = [.width, .height] textView.isEditable = true textView.delegate = self textView.isRichText = true textView.allowsUndo = true textView.usesFindBar = true applyTheme() scrollView.documentView = textView // Setup context menu setupContextMenu() // Setup suggestion label setupSuggestionLabel() } private func setupContextMenu() { let menu = NSMenu() menu.delegate = self let copyItem = NSMenuItem(title: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "") let pasteItem = NSMenuItem(title: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "") let selectAllItem = NSMenuItem(title: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "") menu.addItem(copyItem) menu.addItem(pasteItem) menu.addItem(selectAllItem) menu.addItem(NSMenuItem.separator()) let saveSnippetItem = NSMenuItem(title: "Save as Snippet", action: #selector(saveSelectionAsSnippet), keyEquivalent: "") saveSnippetItem.target = self menu.addItem(saveSnippetItem) textView.menu = menu } @objc private func saveSelectionAsSnippet() { let selectedRange = textView.selectedRange() guard selectedRange.length > 0 else { return } let selectedText = (textView.string as NSString).substring(with: selectedRange).trimmingCharacters(in: .whitespacesAndNewlines) guard !selectedText.isEmpty else { return } let alert = NSAlert() alert.messageText = "Save as Snippet" alert.informativeText = "Enter details for this snippet:" let nameField = NSTextField(frame: NSRect(x: 0, y: 60, width: 300, height: 24)) nameField.placeholderString = "Snippet name" let categoryField = NSTextField(frame: NSRect(x: 0, y: 30, width: 300, height: 24)) categoryField.placeholderString = "Category (optional)" let descField = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 24)) descField.placeholderString = "Description (optional)" let commandLabel = NSTextField(labelWithString: "Command: \(selectedText)") commandLabel.frame = NSRect(x: 0, y: 90, width: 300, height: 20) commandLabel.font = NSFont.systemFont(ofSize: 11) commandLabel.textColor = .secondaryLabelColor commandLabel.lineBreakMode = .byTruncatingTail let view = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 115)) view.addSubview(commandLabel) view.addSubview(nameField) view.addSubview(categoryField) view.addSubview(descField) alert.accessoryView = view alert.addButton(withTitle: "Save") alert.addButton(withTitle: "Cancel") if alert.runModal() == .alertFirstButtonReturn { let snippet = SnippetManager.Snippet( name: nameField.stringValue.isEmpty ? "Untitled" : nameField.stringValue, command: selectedText, description: descField.stringValue, category: categoryField.stringValue.isEmpty ? "General" : categoryField.stringValue ) SnippetManager.shared.addSnippet(snippet) // Show confirmation let confirmation = NSAlert() confirmation.messageText = "Snippet Saved" confirmation.informativeText = "Your snippet has been saved. Access it with Cmd+P." confirmation.alertStyle = .informational confirmation.addButton(withTitle: "OK") confirmation.runModal() } } private func setupSuggestionLabel() { // We'll use a different approach - inline gray text // No separate label needed } func applyTheme() { let theme = TerminalSettings.shared.theme textView?.backgroundColor = theme.background textView?.textColor = theme.foreground textView?.insertionPointColor = theme.cursor textView?.selectedTextAttributes = [ .backgroundColor: theme.selection ] textView?.font = NSFont(name: TerminalSettings.shared.fontName, size: TerminalSettings.shared.fontSize) ?? NSFont.monospacedSystemFont(ofSize: TerminalSettings.shared.fontSize, weight: .regular) } func startShell() { shellTask = Process() inputPipe = Pipe() outputPipe = Pipe() shellTask?.executableURL = URL(fileURLWithPath: "/bin/zsh") shellTask?.arguments = ["-l"] // Login shell for proper initialization // Better environment setup var env = ProcessInfo.processInfo.environment env["TERM"] = "xterm-256color" env["PS1"] = "$ " env["PROMPT"] = "$ " env["DISABLE_AUTO_TITLE"] = "true" shellTask?.environment = env shellTask?.standardInput = inputPipe shellTask?.standardOutput = outputPipe shellTask?.standardError = outputPipe outputPipe?.fileHandleForReading.readabilityHandler = { [weak self] handle in let data = handle.availableData if data.count > 0, let output = String(data: data, encoding: .utf8) { DispatchQueue.main.async { self?.appendText(output) } } } do { try shellTask?.run() appendText("Enhanced Terminal v3.0\n") appendText("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") appendText("Features:\n") appendText(" • Cmd+T: New tab\n") appendText(" • Cmd+R: Search history (fuzzy search)\n") appendText(" • Cmd+K: AI command generator\n") appendText(" • Cmd+P: Command snippets\n") appendText(" • Tab: Accept auto-suggestion\n") appendText(" • Type 'help' for more commands\n") appendText("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n") // Wait a bit for shell to initialize, then show prompt DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.showPrompt() // Run startup commands for command in TerminalSettings.shared.startupCommands { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self?.executeCommand(command) } } } } catch { appendText("Error starting shell: \(error)\n") } } func showPrompt() { appendText("$ ") promptPosition = textView.string.count // Move cursor to end and make text view first responder textView.setSelectedRange(NSRange(location: textView.string.count, length: 0)) textView.window?.makeFirstResponder(textView) } func appendText(_ text: String) { let cleaned = cleanANSI(text) let theme = TerminalSettings.shared.theme let attr = NSAttributedString(string: cleaned, attributes: [ .foregroundColor: theme.foreground, .font: NSFont(name: TerminalSettings.shared.fontName, size: TerminalSettings.shared.fontSize) ?? NSFont.monospacedSystemFont(ofSize: TerminalSettings.shared.fontSize, weight: .regular) ]) textView.textStorage?.append(attr) textView.scrollToEndOfDocument(nil) } private func cleanANSI(_ text: String) -> String { var cleaned = text // Remove ANSI escape sequences let patterns = [ "\u{1B}\\[[0-9;]*[a-zA-Z]", "\u{1B}\\][0-9];[^\u{07}]*\u{07}", "\\]633;[^\u{07}]*\u{07}", "\\]697;[^\u{07}]*\u{07}", "\\]1337;[^\u{07}]*\u{07}", "\\]1;[^\u{07}]*\u{07}", "\\]2;[^\u{07}]*\u{07}", "\\]7;[^\u{07}]*\\\\", "\u{1B}\\]0;[^\u{07}]*\u{07}" ] for pattern in patterns { cleaned = cleaned.replacingOccurrences(of: pattern, with: "", options: .regularExpression) } return cleaned } func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { // Handle up arrow - previous command in history if commandSelector == #selector(NSResponder.moveUp(_:)) { navigateHistory(direction: .previous) return true } // Handle down arrow - next command in history if commandSelector == #selector(NSResponder.moveDown(_:)) { navigateHistory(direction: .next) return true } return false } private enum HistoryDirection { case previous, next } private func navigateHistory(direction: HistoryDirection) { // Load history if not already loaded if historyBuffer.isEmpty { historyBuffer = HistoryManager.shared.getRecentCommands(100) } guard !historyBuffer.isEmpty else { return } // Save current input if we're starting to navigate if historyIndex == -1 { currentInput = getCurrentCommand() } // Navigate history switch direction { case .previous: if historyIndex < historyBuffer.count - 1 { historyIndex += 1 } case .next: if historyIndex > -1 { historyIndex -= 1 } } // Update command line let newCommand: String if historyIndex == -1 { // Back to original input newCommand = currentInput } else { // Show history command newCommand = historyBuffer[historyIndex] } // Replace current command with history command replaceCurrentCommand(with: newCommand) } private func replaceCurrentCommand(with newCommand: String) { let currentLength = textView.string.count let commandLength = currentLength - promptPosition if commandLength > 0 { let range = NSRange(location: promptPosition, length: commandLength) textView.textStorage?.replaceCharacters(in: range, with: newCommand) } else { textView.insertText(newCommand, replacementRange: NSRange(location: promptPosition, length: 0)) } // Move cursor to end textView.setSelectedRange(NSRange(location: textView.string.count, length: 0)) } func textView(_ textView: NSTextView, shouldChangeTextIn range: NSRange, replacementString: String?) -> Bool { // Don't allow editing before the prompt if range.location < promptPosition { return false } // Handle Enter key if replacementString == "\n" { let command = getCurrentCommand() executeCommand(command) hideSuggestion() return false } // Handle Tab key for accepting suggestions if replacementString == "\t" { if let suggestion = currentSuggestion { acceptSuggestion(suggestion) return false } } // Handle backspace/delete - need to update suggestions if replacementString == "" { // Allow the deletion let result = true // Update suggestions after deletion DispatchQueue.main.async { [weak self] in self?.updateSuggestion() } return result } // For regular character input, update suggestions after the character is added let result = true DispatchQueue.main.async { [weak self] in self?.updateSuggestion() } return result } private func getCurrentCommand() -> String { guard promptPosition <= textView.string.count else { return "" } let startIndex = textView.string.index(textView.string.startIndex, offsetBy: promptPosition) return String(textView.string[startIndex...]) } private func updateSuggestion() { // First, always hide any existing suggestion hideSuggestion() guard TerminalSettings.shared.enableAutoSuggestions else { return } let partial = getCurrentCommand().trimmingCharacters(in: .whitespaces) guard !partial.isEmpty else { return } if let suggestion = HistoryManager.shared.getSuggestion(for: partial) { showSuggestion(suggestion, for: partial) } } private func showSuggestion(_ fullCommand: String, for partial: String) { currentSuggestion = fullCommand // Get the remaining part of the suggestion let remaining = String(fullCommand.dropFirst(partial.count)) guard !remaining.isEmpty else { return } // Append the suggestion in gray at the current cursor position let font = NSFont(name: TerminalSettings.shared.fontName, size: TerminalSettings.shared.fontSize) ?? NSFont.monospacedSystemFont(ofSize: TerminalSettings.shared.fontSize, weight: .regular) let suggestionAttr = NSAttributedString(string: remaining, attributes: [ .foregroundColor: NSColor.gray.withAlphaComponent(0.5), .font: font ]) let currentLength = textView.string.count textView.textStorage?.append(suggestionAttr) // Move cursor back to before the suggestion textView.setSelectedRange(NSRange(location: currentLength, length: 0)) NSLog("Showing suggestion: '\(remaining)' for partial: '\(partial)'") } private func hideSuggestion() { guard let suggestion = currentSuggestion else { return } // Find and remove any gray text at the end let textLength = textView.string.count guard textLength > 0, let storage = textView.textStorage else { currentSuggestion = nil return } // Scan backwards from the end to find gray text var removeLength = 0 for i in stride(from: textLength - 1, through: 0, by: -1) { let attributes = storage.attributes(at: i, effectiveRange: nil) if let color = attributes[.foregroundColor] as? NSColor, color.alphaComponent < 1.0 { removeLength += 1 } else { break } } if removeLength > 0 { let removeRange = NSRange(location: textLength - removeLength, length: removeLength) storage.deleteCharacters(in: removeRange) NSLog("Removed \(removeLength) chars of gray suggestion") } currentSuggestion = nil } private func acceptSuggestion(_ suggestion: String) { guard let suggestion = currentSuggestion else { return } // Get the current command and calculate remaining let partial = getCurrentCommand() let remaining = String(suggestion.dropFirst(partial.count)) // Remove the gray suggestion first by scanning for gray text let textLength = textView.string.count var removeLength = 0 if let storage = textView.textStorage { for i in stride(from: textLength - 1, through: 0, by: -1) { let attributes = storage.attributes(at: i, effectiveRange: nil) if let color = attributes[.foregroundColor] as? NSColor, color.alphaComponent < 1.0 { removeLength += 1 } else { break } } if removeLength > 0 { let removeRange = NSRange(location: textLength - removeLength, length: removeLength) storage.deleteCharacters(in: removeRange) } } // Add it back in normal color let theme = TerminalSettings.shared.theme let font = NSFont(name: TerminalSettings.shared.fontName, size: TerminalSettings.shared.fontSize) ?? NSFont.monospacedSystemFont(ofSize: TerminalSettings.shared.fontSize, weight: .regular) let normalAttr = NSAttributedString(string: remaining, attributes: [ .foregroundColor: theme.foreground, .font: font ]) textView.textStorage?.append(normalAttr) textView.setSelectedRange(NSRange(location: textView.string.count, length: 0)) currentSuggestion = nil NSLog("Accepted suggestion: '\(suggestion)'") } func executeCommand(_ command: String) { appendText("\n") let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) // Reset history navigation historyIndex = -1 currentInput = "" historyBuffer = [] // Reload on next navigation // Check for aliases - match the first word var actualCommand = trimmed let components = trimmed.split(separator: " ", maxSplits: 1) if let firstWord = components.first { let firstWordStr = String(firstWord) if let aliasCommand = TerminalSettings.shared.commandAliases[firstWordStr] { // Replace the alias with the actual command, keeping any arguments if components.count > 1 { actualCommand = aliasCommand + " " + String(components[1]) } else { actualCommand = aliasCommand } appendText("(alias: \(firstWordStr) → \(aliasCommand))\n") } } // Track directory changes if actualCommand.hasPrefix("cd ") { let path = String(actualCommand.dropFirst(3)).trimmingCharacters(in: .whitespaces) if !path.isEmpty { currentWorkingDirectory = path } } // Handle built-in commands if actualCommand == "exit" { NSApp.terminate(nil) return } if actualCommand == "clear" { textView.string = "" showPrompt() return } if actualCommand == "help" { showHelp() showPrompt() return } if actualCommand == "aliases" { showAliases() showPrompt() return } // Add to history if !trimmed.isEmpty { HistoryManager.shared.addCommand(trimmed) // Send to shell if let data = (actualCommand + "\n").data(using: .utf8) { inputPipe?.fileHandleForWriting.write(data) } } // Show prompt after a delay DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in self?.showPrompt() } } private func showHelp() { appendText(""" Enhanced Terminal Commands: ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Keyboard Shortcuts: • Cmd+T - New tab • Cmd+W - Close tab • Cmd+R - Search command history (fuzzy search) • Cmd+K - AI command generator • Cmd+P - Command snippets • Cmd+, - Settings • Tab - Accept auto-suggestion • Cmd+Plus - Increase font size • Cmd+Minus - Decrease font size Built-in Commands: • clear - Clear screen • help - Show this help • aliases - Show command aliases • exit - Quit application ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ """) } private func showAliases() { let aliases = TerminalSettings.shared.commandAliases if aliases.isEmpty { appendText("No aliases defined. Add them in Settings (Cmd+,)\n") } else { appendText("Command Aliases:\n") appendText("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") for (alias, command) in aliases.sorted(by: { $0.key < $1.key }) { appendText(" \(alias) → \(command)\n") } appendText("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") } } func insertCommand(_ command: String) { textView.insertText(command, replacementRange: NSRange(location: textView.string.count, length: 0)) textView.window?.makeFirstResponder(textView) } func cleanup() { shellTask?.terminate() outputPipe?.fileHandleForReading.readabilityHandler = nil } // MARK: - NSMenuDelegate func menuNeedsUpdate(_ menu: NSMenu) { // Enable "Save as Snippet" only if there's selected text if let saveSnippetItem = menu.items.first(where: { $0.action == #selector(saveSelectionAsSnippet) }) { let hasSelection = textView.selectedRange().length > 0 saveSnippetItem.isEnabled = hasSelection } } deinit { cleanup() } } // MARK: - Tab Manager class TabManager { var tabs: [TerminalSession] = [] var currentIndex: Int = 0 struct SessionData: Codable { let title: String let sessionID: String let workingDirectory: String? } func addTab(title: String = "Terminal") -> TerminalSession { let session = TerminalSession(title: "\(title) \(tabs.count + 1)") tabs.append(session) currentIndex = tabs.count - 1 saveSession() return session } func removeTab(at index: Int) { guard index < tabs.count else { return } tabs[index].cleanup() tabs.remove(at: index) if currentIndex >= tabs.count { currentIndex = max(0, tabs.count - 1) } saveSession() } func selectTab(at index: Int) { guard index < tabs.count else { return } currentIndex = index } func getCurrentSession() -> TerminalSession? { guard currentIndex < tabs.count else { return nil } return tabs[currentIndex] } func saveSession() { let sessionData = tabs.map { SessionData(title: $0.title, sessionID: $0.sessionID.uuidString, workingDirectory: $0.currentWorkingDirectory) } if let data = try? JSONEncoder().encode(sessionData) { UserDefaults.standard.set(data, forKey: "terminal_session") UserDefaults.standard.set(currentIndex, forKey: "terminal_current_tab") } } func loadSession() -> [SessionData] { guard let data = UserDefaults.standard.data(forKey: "terminal_session"), let sessions = try? JSONDecoder().decode([SessionData].self, from: data) else { return [] } return sessions } func getSavedCurrentIndex() -> Int { return UserDefaults.standard.integer(forKey: "terminal_current_tab") } }