import SwiftUI import AppKit // MARK: - Terminal View struct TerminalView: View { @ObservedObject var viewModel: EditorViewModel @FocusState private var inputFocused: Bool @State private var showSearch: Bool = false @State private var searchQuery: String = "" @State private var searchResultCount: Int = 0 private var terminal: TerminalSession { viewModel.terminalSession } var body: some View { VStack(spacing: 0) { terminalHeader if showSearch { terminalSearchBar } Divider() TerminalOutputView(terminal: terminal, highlightQuery: showSearch ? searchQuery : nil) terminalInput } .background(Color(red: 0.05, green: 0.05, blue: 0.07)) .onAppear { if !terminal.isRunning { if let dir = viewModel.workspaceService.currentWorkspace?.rootURL { terminal.start(in: dir) } else { terminal.start(in: FileManager.default.homeDirectoryForCurrentUser) } } inputFocused = true } .onChange(of: viewModel.workspaceService.currentWorkspace?.rootURL) { _, newURL in if let url = newURL, terminal.workingDirectory != url { terminal.sendInput("cd \(url.path.replacingOccurrences(of: " ", with: "\\ "))") } } } // MARK: - Header private var terminalHeader: some View { HStack(spacing: 6) { Image(systemName: "terminal") .font(.caption) .foregroundColor(.green) Text(terminal.title) .font(.system(size: 11, weight: .medium, design: .monospaced)) .foregroundColor(.gray) .lineLimit(1) Spacer() // Process indicator if terminal.isRunning { Circle() .fill(.green) .frame(width: 6, height: 6) } else { Circle() .fill(.red) .frame(width: 6, height: 6) } Button(action: terminal.clear) { Image(systemName: "trash") .font(.caption) .foregroundColor(.gray) } .buttonStyle(.plain) .help("Clear output") Button(action: { showSearch.toggle() }) { Image(systemName: "magnifyingglass") .font(.caption) .foregroundColor(showSearch ? .accentColor : .gray) } .buttonStyle(.plain) .help("Search output (⌘F)") Button(action: { terminal.kill() if let dir = viewModel.workspaceService.currentWorkspace?.rootURL { terminal.start(in: dir) } else { terminal.start(in: FileManager.default.homeDirectoryForCurrentUser) } }) { Image(systemName: "arrow.counterclockwise") .font(.caption) .foregroundColor(.gray) } .buttonStyle(.plain) .help("Restart terminal") } .padding(.horizontal, 10) .padding(.vertical, 4) } // MARK: - Search Bar private var terminalSearchBar: some View { HStack(spacing: 6) { Image(systemName: "magnifyingglass") .font(.caption) .foregroundColor(.gray) TextField("Search output...", text: $searchQuery) .font(.system(size: 11, design: .monospaced)) .textFieldStyle(.plain) .foregroundColor(.white) .onChange(of: searchQuery) { _, newValue in searchResultCount = terminal.searchOutput(newValue).count } if !searchQuery.isEmpty { Text("\(searchResultCount) matches") .font(.system(size: 10, design: .monospaced)) .foregroundColor(.gray) } Button(action: { searchQuery = "" showSearch = false }) { Image(systemName: "xmark.circle.fill") .font(.caption) .foregroundColor(.gray) } .buttonStyle(.plain) } .padding(.horizontal, 10) .padding(.vertical, 3) .background(Color(white: 0.12)) } // MARK: - Input private var terminalInput: some View { HStack(spacing: 6) { Text(terminal.prompt) .font(.system(size: 12, design: .monospaced)) .foregroundColor(.green.opacity(0.7)) TerminalInputField( text: $viewModel.terminalSession.inputBuffer, onSubmit: { let cmd = terminal.inputBuffer terminal.sendInput(cmd) terminal.inputBuffer = "" }, onHistoryUp: { terminal.historyUp() }, onHistoryDown: { terminal.historyDown() }, onTab: { terminal.handleTab() }, onInterrupt: { terminal.sendInterrupt() } ) .focused($inputFocused) } .padding(.horizontal, 8) .padding(.vertical, 6) .background(Color(white: 0.08)) } } // MARK: - Terminal Input Field (NSViewRepresentable for key handling) struct TerminalInputField: NSViewRepresentable { @Binding var text: String let onSubmit: () -> Void let onHistoryUp: () -> Void let onHistoryDown: () -> Void let onTab: () -> Void let onInterrupt: () -> Void func makeNSView(context: Context) -> NSTextField { let field = NSTextField() field.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) field.textColor = .white field.backgroundColor = .clear field.isBordered = false field.focusRingType = .none field.delegate = context.coordinator field.placeholderString = "" field.cell?.sendsActionOnEndEditing = false return field } func updateNSView(_ nsView: NSTextField, context: Context) { if nsView.stringValue != text { nsView.stringValue = text } } func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator: NSObject, NSTextFieldDelegate { var parent: TerminalInputField init(_ parent: TerminalInputField) { self.parent = parent } func controlTextDidChange(_ obj: Notification) { if let field = obj.object as? NSTextField { parent.text = field.stringValue } } func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { switch commandSelector { case #selector(NSResponder.insertNewline(_:)): parent.onSubmit() return true case #selector(NSResponder.moveUp(_:)): parent.onHistoryUp() return true case #selector(NSResponder.moveDown(_:)): parent.onHistoryDown() return true case #selector(NSResponder.insertTab(_:)): parent.onTab() return true case #selector(NSResponder.cancelOperation(_:)): parent.onInterrupt() return true default: return false } } } } // MARK: - Terminal Output View (NSViewRepresentable for performance) struct TerminalOutputView: NSViewRepresentable { @ObservedObject var terminal: TerminalSession var highlightQuery: String? = nil func makeNSView(context: Context) -> NSScrollView { let scrollView = NSScrollView() scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = false scrollView.autohidesScrollers = true scrollView.borderType = .noBorder scrollView.backgroundColor = NSColor(red: 0.05, green: 0.05, blue: 0.07, alpha: 1.0) let textView = NSTextView() textView.isEditable = false textView.isSelectable = true textView.isRichText = true textView.backgroundColor = NSColor(red: 0.05, green: 0.05, blue: 0.07, alpha: 1.0) textView.textContainerInset = NSSize(width: 8, height: 4) textView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) textView.textColor = NSColor(calibratedRed: 0.85, green: 0.85, blue: 0.85, alpha: 1.0) textView.isAutomaticQuoteSubstitutionEnabled = false textView.isAutomaticDashSubstitutionEnabled = false textView.isAutomaticTextReplacementEnabled = false textView.allowsUndo = false let maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) textView.maxSize = maxSize textView.isVerticallyResizable = true textView.isHorizontallyResizable = false textView.textContainer?.widthTracksTextView = true textView.textContainer?.containerSize = NSSize(width: scrollView.contentSize.width, height: CGFloat.greatestFiniteMagnitude) scrollView.documentView = textView context.coordinator.textView = textView context.coordinator.scrollView = scrollView return scrollView } func updateNSView(_ nsView: NSScrollView, context: Context) { context.coordinator.updateContent(terminal.lines) } func makeCoordinator() -> Coordinator { Coordinator() } class Coordinator { weak var textView: NSTextView? weak var scrollView: NSScrollView? private var lastLineCount = 0 func updateContent(_ lines: [TerminalSession.TerminalLine]) { guard let textView = textView else { return } if lines.isEmpty { textView.string = "" lastLineCount = 0 return } // Incremental append for performance if lines.count > lastLineCount { let newLines = lines[lastLineCount...] let storage = textView.textStorage! storage.beginEditing() for line in newLines { let attrs = lineAttributes(for: line) let str = NSAttributedString(string: (storage.length > 0 ? "\n" : "") + line.text, attributes: attrs) storage.append(str) } storage.endEditing() lastLineCount = lines.count } else if lines.count < lastLineCount { // Cleared — rebuild let storage = textView.textStorage! storage.beginEditing() let fullText = NSMutableAttributedString() for (i, line) in lines.enumerated() { let attrs = lineAttributes(for: line) let prefix = i > 0 ? "\n" : "" fullText.append(NSAttributedString(string: prefix + line.text, attributes: attrs)) } storage.setAttributedString(fullText) storage.endEditing() lastLineCount = lines.count } // Auto-scroll to bottom DispatchQueue.main.async { textView.scrollToEndOfDocument(nil) } } private func lineAttributes(for line: TerminalSession.TerminalLine) -> [NSAttributedString.Key: Any] { let font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) let color: NSColor switch line.kind { case .stdout: color = NSColor(calibratedRed: 0.85, green: 0.85, blue: 0.85, alpha: 1.0) case .stderr: color = NSColor(calibratedRed: 1.0, green: 0.45, blue: 0.4, alpha: 1.0) case .stdin: color = NSColor(calibratedRed: 0.4, green: 0.9, blue: 0.4, alpha: 1.0) case .system: color = NSColor(calibratedRed: 0.5, green: 0.6, blue: 0.8, alpha: 1.0) } return [.font: font, .foregroundColor: color] } } } // MARK: - Terminal Session (PTY-backed persistent bash process) @MainActor final class TerminalSession: ObservableObject { @Published var lines: [TerminalLine] = [] @Published var inputBuffer: String = "" @Published var title: String = "bash" @Published var isRunning: Bool = false @Published var currentDirectory: String = "" let id = UUID() var prompt: String { isRunning ? "$ " : "[exited] $ " } private var process: Process? private var inputPipe: Pipe? private var outputPipe: Pipe? private var errorPipe: Pipe? var workingDirectory: URL? // Command history private(set) var commandHistory: [String] = [] private(set) var historyIndex: Int = -1 private var savedInput: String = "" let maxHistory = 500 // Ring buffer for agent reads private(set) var recentOutput: [String] = [] let maxRecentLines = 200 // Shell aliases private(set) var aliases: [String: String] = [ "ll": "ls -la", "la": "ls -A", "l": "ls -CF", "gs": "git status", "gd": "git diff", "gl": "git log --oneline -20", "gb": "git branch", "..": "cd ..", "...": "cd ../..", ] // Blocked commands (security) static let blockedPatterns: [String] = [ "rm -rf /", "rm -rf /*", "mkfs", ":(){:|:&};:", // fork bomb "dd if=/dev/", "chmod -R 777 /", "curl.*|.*bash", "wget.*|.*bash", ] enum LineKind: String, CaseIterable { case stdout, stderr, stdin, system } struct TerminalLine: Identifiable { let id = UUID() let text: String let kind: LineKind let timestamp: Date = Date() // Legacy compat var isError: Bool { kind == .stderr } } // MARK: - Lifecycle func start(in directory: URL) { kill() workingDirectory = directory currentDirectory = directory.path title = "bash — \(directory.lastPathComponent)" let proc = Process() proc.executableURL = URL(fileURLWithPath: "/bin/bash") proc.arguments = ["--login"] proc.currentDirectoryURL = directory var env = ProcessInfo.processInfo.environment env["TERM"] = "dumb" env["CLICOLOR"] = "0" env["NO_COLOR"] = "1" // Custom prompt that's recognizable and clean env["PS1"] = "\\W $ " env["PS2"] = "> " // Strip secrets for key in env.keys where key.lowercased().contains("api_key") || key.lowercased().contains("secret") || key.lowercased().contains("token") || key.lowercased().contains("password") { env.removeValue(forKey: key) } proc.environment = env let inPipe = Pipe() let outPipe = Pipe() let errPipe = Pipe() proc.standardInput = inPipe proc.standardOutput = outPipe proc.standardError = errPipe // Read stdout outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in let data = handle.availableData guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return } let cleaned = Self.stripANSI(text) Task { @MainActor [weak self] in self?.handleOutput(cleaned, kind: .stdout) } } // Read stderr errPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in let data = handle.availableData guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return } let cleaned = Self.stripANSI(text) Task { @MainActor [weak self] in self?.handleOutput(cleaned, kind: .stderr) } } // Termination proc.terminationHandler = { [weak self] proc in Task { @MainActor [weak self] in self?.isRunning = false self?.title = "bash [exited: \(proc.terminationStatus)]" self?.appendLine("[Process exited with code \(proc.terminationStatus)]", kind: .system) } } do { try proc.run() process = proc inputPipe = inPipe outputPipe = outPipe errorPipe = errPipe isRunning = true appendLine("Terminal ready — \(directory.path)", kind: .system) } catch { appendLine("Failed to start: \(error.localizedDescription)", kind: .system) } } // MARK: - Input func sendInput(_ input: String) { guard !input.isEmpty else { return } // Check for blocked commands if Self.isBlocked(input) { appendLine("$ \(input)", kind: .stdin) appendLine("⛔ Blocked: this command is not allowed for safety reasons.", kind: .system) return } // Handle built-in commands if handleBuiltIn(input) { return } // Expand aliases let expanded = expandAlias(input) // Add to history (skip duplicates of last entry) if commandHistory.last != input { commandHistory.append(input) if commandHistory.count > maxHistory { commandHistory.removeFirst(commandHistory.count - maxHistory) } } historyIndex = -1 savedInput = "" if isRunning, let pipe = inputPipe { appendLine("$ \(input)", kind: .stdin) let data = (expanded + "\n").data(using: .utf8)! pipe.fileHandleForWriting.write(data) } else { // No running process — execute one-shot appendLine("$ \(input)", kind: .stdin) Task { await executeOneShot(expanded) } } } /// Check if a command is blocked for security nonisolated static func isBlocked(_ command: String) -> Bool { let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() for pattern in blockedPatterns { if trimmed.contains(pattern.lowercased()) { return true } } // Block sudo rm -rf with broad path if trimmed.hasPrefix("sudo") && trimmed.contains("rm") && trimmed.contains("-rf") { return true } return false } /// Expand aliases in the command func expandAlias(_ input: String) -> String { let parts = input.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: false) guard let firstWord = parts.first else { return input } let key = String(firstWord) if let expanded = aliases[key] { if parts.count > 1 { return expanded + " " + String(parts[1]) } return expanded } return input } /// Handle built-in terminal commands (return true if handled) func handleBuiltIn(_ input: String) -> Bool { let trimmed = input.trimmingCharacters(in: .whitespaces) let parts = trimmed.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) guard let cmd = parts.first else { return false } switch String(cmd) { case "clear": clear() return true case "history": appendLine("$ history", kind: .stdin) for (i, entry) in commandHistory.enumerated() { appendLine(" \(i + 1) \(entry)", kind: .stdout) } return true case "alias": if parts.count == 1 { // List all aliases appendLine("$ alias", kind: .stdin) for (key, val) in aliases.sorted(by: { $0.key < $1.key }) { appendLine("alias \(key)='\(val)'", kind: .stdout) } return true } else { // Set alias: alias name='command' let rest = String(parts[1]) if let eqIdx = rest.firstIndex(of: "=") { let name = String(rest[rest.startIndex.. 0 { historyIndex -= 1 } inputBuffer = commandHistory[historyIndex] } func historyDown() { guard historyIndex >= 0 else { return } if historyIndex < commandHistory.count - 1 { historyIndex += 1 inputBuffer = commandHistory[historyIndex] } else { historyIndex = -1 inputBuffer = savedInput } } // MARK: - Tab Completion func handleTab() { guard !inputBuffer.isEmpty else { return } let partial = inputBuffer // Find the last path component to complete let parts = partial.split(separator: " ", omittingEmptySubsequences: false) guard let lastPart = parts.last, !lastPart.isEmpty else { return } let pathStr = String(lastPart) let baseDir: String let prefix: String if pathStr.contains("/") { let url = URL(fileURLWithPath: pathStr, relativeTo: workingDirectory) baseDir = url.deletingLastPathComponent().path prefix = url.lastPathComponent } else { baseDir = workingDirectory?.path ?? FileManager.default.currentDirectoryPath prefix = pathStr } let fm = FileManager.default guard let contents = try? fm.contentsOfDirectory(atPath: baseDir) else { return } let matches = contents.filter { $0.hasPrefix(prefix) }.sorted() if matches.count == 1 { var completion = matches[0] let fullPath = (baseDir as NSString).appendingPathComponent(completion) var isDir: ObjCBool = false if fm.fileExists(atPath: fullPath, isDirectory: &isDir), isDir.boolValue { completion += "/" } // Replace the last part var newParts = Array(parts.dropLast()) if pathStr.contains("/") { let dirPart = (pathStr as NSString).deletingLastPathComponent newParts.append(Substring(dirPart + "/" + completion)) } else { newParts.append(Substring(completion)) } inputBuffer = newParts.joined(separator: " ") } else if matches.count > 1 { // Show options appendLine(matches.joined(separator: " "), kind: .system) // Complete common prefix let common = Self.commonPrefix(matches) if common.count > prefix.count { var newParts = Array(parts.dropLast()) if pathStr.contains("/") { let dirPart = (pathStr as NSString).deletingLastPathComponent newParts.append(Substring(dirPart + "/" + common)) } else { newParts.append(Substring(common)) } inputBuffer = newParts.joined(separator: " ") } } } // MARK: - Agent API /// Execute a command and return the output (for agent tool calls) func executeAndCapture(_ command: String, timeout: Int = 30) async -> (output: String, exitCode: Int32, isError: Bool) { let cwd = workingDirectory ?? FileManager.default.homeDirectoryForCurrentUser return await withCheckedContinuation { continuation in DispatchQueue.global(qos: .userInitiated).async { let proc = Process() proc.executableURL = URL(fileURLWithPath: "/bin/bash") proc.arguments = ["-c", command] proc.currentDirectoryURL = cwd var env = ProcessInfo.processInfo.environment env["TERM"] = "dumb" for key in env.keys where key.lowercased().contains("api_key") || key.lowercased().contains("secret") || key.lowercased().contains("token") || key.lowercased().contains("password") { env.removeValue(forKey: key) } proc.environment = env let outPipe = Pipe() let errPipe = Pipe() proc.standardOutput = outPipe proc.standardError = errPipe do { try proc.run() let deadline = DispatchTime.now() + .seconds(timeout) let group = DispatchGroup() group.enter() DispatchQueue.global().async { proc.waitUntilExit() group.leave() } let result = group.wait(timeout: deadline) if result == .timedOut { proc.terminate() continuation.resume(returning: ("Command timed out after \(timeout)s", -1, true)) return } let outData = outPipe.fileHandleForReading.readDataToEndOfFile() let errData = errPipe.fileHandleForReading.readDataToEndOfFile() let output = String(data: outData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let errOutput = String(data: errData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let exitCode = proc.terminationStatus var combined = output if !errOutput.isEmpty { combined += combined.isEmpty ? errOutput : "\n\(errOutput)" } continuation.resume(returning: (combined, exitCode, exitCode != 0)) } catch { continuation.resume(returning: ("Error: \(error.localizedDescription)", -1, true)) } } } } /// Get recent output lines (for agent to read terminal state) func getRecentOutput(lineCount: Int = 50) -> String { let count = min(lineCount, recentOutput.count) return recentOutput.suffix(count).joined(separator: "\n") } /// Append agent-executed command output to the terminal UI func appendAgentCommand(_ command: String, output: String) { appendLine("[agent] $ \(command)", kind: .system) if !output.isEmpty { for line in output.components(separatedBy: "\n") where !line.isEmpty { appendLine(line, kind: .stdout) } } } // MARK: - Clear/Kill func clear() { lines.removeAll() recentOutput.removeAll() } func kill() { outputPipe?.fileHandleForReading.readabilityHandler = nil errorPipe?.fileHandleForReading.readabilityHandler = nil if let proc = process, proc.isRunning { proc.terminate() } process = nil inputPipe = nil outputPipe = nil errorPipe = nil isRunning = false } func setCwd(_ path: String) { workingDirectory = URL(fileURLWithPath: path) currentDirectory = path } // MARK: - Search & Filtering /// Search terminal output for a pattern (case-insensitive) func searchOutput(_ query: String) -> [TerminalLine] { guard !query.isEmpty else { return [] } let lower = query.lowercased() return lines.filter { $0.text.lowercased().contains(lower) } } /// Get line counts by kind func lineCounts() -> [LineKind: Int] { var counts: [LineKind: Int] = [:] for kind in LineKind.allCases { counts[kind] = lines.filter { $0.kind == kind }.count } return counts } /// Total output size in characters var totalOutputSize: Int { lines.reduce(0) { $0 + $1.text.count } } // MARK: - Internal (exposed for testing) func handleOutput(_ text: String, kind: LineKind) { let outputLines = text.components(separatedBy: "\n") for line in outputLines where !line.isEmpty { // Skip prompt echo lines if line.hasSuffix(" $ ") && line.count < 60 { continue } appendLine(line, kind: kind) recentOutput.append(line) if recentOutput.count > maxRecentLines { recentOutput.removeFirst(recentOutput.count - maxRecentLines) } } } func appendLine(_ text: String, kind: LineKind) { lines.append(TerminalLine(text: text, kind: kind)) if lines.count > 5000 { lines.removeFirst(lines.count - 5000) } } private func executeOneShot(_ command: String) async { let result = await executeAndCapture(command) if !result.output.isEmpty { for line in result.output.components(separatedBy: "\n") { appendLine(line, kind: result.isError ? .stderr : .stdout) } } if result.exitCode != 0 { appendLine("[exit code: \(result.exitCode)]", kind: .system) } } // MARK: - Static Helpers /// Strip ANSI escape sequences from terminal output nonisolated static func stripANSI(_ text: String) -> String { // Match ESC[ ... m (SGR), ESC[ ... H (cursor), ESC[ ... J (erase), etc. guard let regex = try? NSRegularExpression(pattern: "\\x1B(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~])", options: []) else { return text } return regex.stringByReplacingMatches(in: text, options: [], range: NSRange(text.startIndex..., in: text), withTemplate: "") } /// Find the common prefix of an array of strings nonisolated static func commonPrefix(_ strings: [String]) -> String { guard let first = strings.first else { return "" } var prefix = first for s in strings.dropFirst() { while !s.hasPrefix(prefix) { prefix = String(prefix.dropLast()) if prefix.isEmpty { return "" } } } return prefix } } // MARK: - Split Editor View struct SplitEditorView: View { @ObservedObject var viewModel: EditorViewModel let orientation: SplitOrientation var body: some View { Group { if orientation == .horizontal { HSplitView { CodeEditorView(viewModel: viewModel) if viewModel.splitEditorEnabled, let splitVM = viewModel.splitViewModel { Divider() CodeEditorView(viewModel: splitVM) } } } else { VSplitView { CodeEditorView(viewModel: viewModel) if viewModel.splitEditorEnabled, let splitVM = viewModel.splitViewModel { Divider() CodeEditorView(viewModel: splitVM) } } } } } }