From 4dbc32836322f3abe6bc3d679f17195f7010e5b7 Mon Sep 17 00:00:00 2001 From: cx-git-agent Date: Tue, 21 Apr 2026 18:40:45 -0500 Subject: [PATCH] feat: add shared terminal session and enhance terminal view with command history and input handling --- ViewModels/EditorViewModel.swift | 11 +- Views/TerminalView.swift | 707 ++++++++++++++++++++++++++++--- 2 files changed, 647 insertions(+), 71 deletions(-) diff --git a/ViewModels/EditorViewModel.swift b/ViewModels/EditorViewModel.swift index 5b598ce..2281d77 100644 --- a/ViewModels/EditorViewModel.swift +++ b/ViewModels/EditorViewModel.swift @@ -43,6 +43,9 @@ class EditorViewModel: ObservableObject { // MARK: - Bottom Panel Tab @Published var bottomPanel: BottomPanel = .console + // MARK: - Shared Terminal + @Published var terminalSession = TerminalSession() + // MARK: - Command Palette @Published var showCommandPalette: Bool = false @Published var commandSearch: String = "" @@ -121,7 +124,7 @@ class EditorViewModel: ObservableObject { self.workspaceService.refreshFileTree() } - // When the agent runs terminal commands, log them to console + // When the agent runs terminal commands, log them to console AND terminal agentService.onTerminalCommand = { [weak self] command, output in guard let self else { return } self.appendConsole("$ \(command)", level: .system) @@ -129,6 +132,8 @@ class EditorViewModel: ObservableObject { let truncated = output.count > 500 ? String(output.prefix(500)) + "..." : output self.appendConsole(truncated, level: .output) } + // Also show in the shared terminal view + self.terminalSession.appendAgentCommand(command, output: output) } // Forward objectWillChange from child services so SwiftUI updates @@ -140,6 +145,10 @@ class EditorViewModel: ObservableObject { .receive(on: RunLoop.main) .sink { [weak self] _ in self?.objectWillChange.send() } .store(in: &serviceCancellables) + terminalSession.objectWillChange + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.objectWillChange.send() } + .store(in: &serviceCancellables) gitService.objectWillChange .receive(on: RunLoop.main) .sink { [weak self] _ in diff --git a/Views/TerminalView.swift b/Views/TerminalView.swift index 2f4dacf..7183d4a 100644 --- a/Views/TerminalView.swift +++ b/Views/TerminalView.swift @@ -1,24 +1,41 @@ import SwiftUI +import AppKit // MARK: - Terminal View struct TerminalView: View { @ObservedObject var viewModel: EditorViewModel - @StateObject private var terminal = TerminalSession() + @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() - terminalOutput + TerminalOutputView(terminal: terminal, highlightQuery: showSearch ? searchQuery : nil) terminalInput } .background(Color(red: 0.05, green: 0.05, blue: 0.07)) .onAppear { - if let dir = viewModel.workspaceService.currentWorkspace?.rootURL { - terminal.start(in: dir) - } else { - terminal.start(in: FileManager.default.homeDirectoryForCurrentUser) + 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: "\\ "))") } } } @@ -33,20 +50,42 @@ struct TerminalView: View { 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") + .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") @@ -54,35 +93,47 @@ struct TerminalView: View { .foregroundColor(.gray) } .buttonStyle(.plain) - .help("Restart") + .help("Restart terminal") } .padding(.horizontal, 10) .padding(.vertical, 4) } - // MARK: - Output + // MARK: - Search Bar - private var terminalOutput: some View { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 0) { - ForEach(terminal.lines) { line in - Text(line.text) - .font(.system(size: 12, design: .monospaced)) - .foregroundColor(line.isError ? .red : .green.opacity(0.85)) - .textSelection(.enabled) - .id(line.id) - } + 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 } - .padding(.horizontal, 8) - .padding(.vertical, 4) + + if !searchQuery.isEmpty { + Text("\(searchResultCount) matches") + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.gray) } - .onChange(of: terminal.lines.count) { _, _ in - if let last = terminal.lines.last { - proxy.scrollTo(last.id, anchor: .bottom) - } + + 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 @@ -93,14 +144,19 @@ struct TerminalView: View { .font(.system(size: 12, design: .monospaced)) .foregroundColor(.green.opacity(0.7)) - TextField("", text: $terminal.inputBuffer) - .font(.system(size: 12, design: .monospaced)) - .textFieldStyle(.plain) - .foregroundColor(.white) - .onSubmit { - terminal.sendInput(terminal.inputBuffer) + 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) @@ -108,7 +164,190 @@ struct TerminalView: View { } } -// MARK: - Terminal Session (Persistent bash process) +// 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 { @@ -119,26 +358,67 @@ final class TerminalSession: ObservableObject { @Published var currentDirectory: String = "" let id = UUID() - var prompt: String { "$ " } + var prompt: String { isRunning ? "$ " : "[exited] $ " } private var process: Process? private var inputPipe: Pipe? private var outputPipe: Pipe? private var errorPipe: Pipe? - private var workingDirectory: URL? - /// Ring buffer of recent output for agent to read - private var recentOutput: [String] = [] - private let maxRecentLines = 200 + 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 isError: Bool + let kind: LineKind let timestamp: Date = Date() + + // Legacy compat + var isError: Bool { kind == .stderr } } + // MARK: - Lifecycle + func start(in directory: URL) { - kill() // clean up any existing process + kill() workingDirectory = directory currentDirectory = directory.path @@ -146,14 +426,20 @@ final class TerminalSession: ObservableObject { let proc = Process() proc.executableURL = URL(fileURLWithPath: "/bin/bash") - proc.arguments = ["--login", "-i"] + proc.arguments = ["--login"] proc.currentDirectoryURL = directory var env = ProcessInfo.processInfo.environment env["TERM"] = "dumb" - env["PS1"] = "\\w $ " - env.removeValue(forKey: "API_KEY") - env.removeValue(forKey: "SECRET") + 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() @@ -163,29 +449,32 @@ final class TerminalSession: ObservableObject { proc.standardOutput = outPipe proc.standardError = errPipe - // Read stdout asynchronously + // 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(text, isError: false) + self?.handleOutput(cleaned, kind: .stdout) } } - // Read stderr asynchronously + // 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(text, isError: true) + self?.handleOutput(cleaned, kind: .stderr) } } - // Handle termination - proc.terminationHandler = { [weak self] _ in + // Termination + proc.terminationHandler = { [weak self] proc in Task { @MainActor [weak self] in self?.isRunning = false - self?.appendLine("[Process exited]", isError: false) + self?.title = "bash [exited: \(proc.terminationStatus)]" + self?.appendLine("[Process exited with code \(proc.terminationStatus)]", kind: .system) } } @@ -196,26 +485,235 @@ final class TerminalSession: ObservableObject { outputPipe = outPipe errorPipe = errPipe isRunning = true - appendLine("Terminal ready — \(directory.path)", isError: false) + appendLine("Terminal ready — \(directory.path)", kind: .system) } catch { - appendLine("Failed to start: \(error.localizedDescription)", isError: true) + appendLine("Failed to start: \(error.localizedDescription)", kind: .system) } } + // MARK: - Input + func sendInput(_ input: String) { - guard isRunning, let pipe = inputPipe else { - // Fallback: run one-off command - appendLine("$ \(input)", isError: false) - Task { await executeOneShot(input) } + 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 } - appendLine("$ \(input)", isError: false) - let data = (input + "\n").data(using: .utf8)! - pipe.fileHandleForWriting.write(data) + // 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) } + } } - /// Execute a command and return the output synchronously (for agent tool calls) + /// 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 @@ -227,8 +725,9 @@ final class TerminalSession: ObservableObject { var env = ProcessInfo.processInfo.environment env["TERM"] = "dumb" - env.removeValue(forKey: "API_KEY") - env.removeValue(forKey: "SECRET") + 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() @@ -279,6 +778,18 @@ final class TerminalSession: ObservableObject { 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() @@ -287,7 +798,9 @@ final class TerminalSession: ObservableObject { func kill() { outputPipe?.fileHandleForReading.readabilityHandler = nil errorPipe?.fileHandleForReading.readabilityHandler = nil - process?.terminate() + if let proc = process, proc.isRunning { + proc.terminate() + } process = nil inputPipe = nil outputPipe = nil @@ -300,10 +813,37 @@ final class TerminalSession: ObservableObject { currentDirectory = path } - private func handleOutput(_ text: String, isError: Bool) { + // 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 { - appendLine(line, isError: isError) + // 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) @@ -311,8 +851,8 @@ final class TerminalSession: ObservableObject { } } - private func appendLine(_ text: String, isError: Bool) { - lines.append(TerminalLine(text: text, isError: isError)) + func appendLine(_ text: String, kind: LineKind) { + lines.append(TerminalLine(text: text, kind: kind)) if lines.count > 5000 { lines.removeFirst(lines.count - 5000) } @@ -322,9 +862,36 @@ final class TerminalSession: ObservableObject { let result = await executeAndCapture(command) if !result.output.isEmpty { for line in result.output.components(separatedBy: "\n") { - appendLine(line, isError: result.isError) + 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 } }