926 lines
32 KiB
Swift
926 lines
32 KiB
Swift
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..<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
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|