feat: add shared terminal session and enhance terminal view with command history and input handling

This commit is contained in:
cx-git-agent
2026-04-21 18:40:45 -05:00
parent f71da0219b
commit 4dbc328363
2 changed files with 647 additions and 71 deletions
+10 -1
View File
@@ -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
+637 -70
View File
@@ -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..<eqIdx]).trimmingCharacters(in: .whitespaces)
var value = String(rest[rest.index(after: eqIdx)...]).trimmingCharacters(in: .whitespaces)
// Strip surrounding quotes
if (value.hasPrefix("'") && value.hasSuffix("'")) ||
(value.hasPrefix("\"") && value.hasSuffix("\"")) {
value = String(value.dropFirst().dropLast())
}
if !name.isEmpty && !value.isEmpty {
aliases[name] = value
appendLine("$ alias \(name)='\(value)'", kind: .stdin)
return true
}
}
return false
}
case "unalias":
if let name = parts.dropFirst().first {
let key = String(name)
aliases.removeValue(forKey: key)
appendLine("$ unalias \(key)", kind: .stdin)
return true
}
return false
case "export":
// Show environment info
appendLine("$ export", kind: .stdin)
appendLine("CWD: \(currentDirectory)", kind: .stdout)
appendLine("SHELL: /bin/bash", kind: .stdout)
appendLine("TERM: dumb", kind: .stdout)
return true
default:
return false
}
}
func sendInterrupt() {
guard let proc = process, proc.isRunning else { return }
proc.interrupt() // sends SIGINT
appendLine("^C", kind: .system)
}
// MARK: - History Navigation
func historyUp() {
guard !commandHistory.isEmpty else { return }
if historyIndex == -1 {
savedInput = inputBuffer
historyIndex = commandHistory.count - 1
} else if historyIndex > 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
}
}