feat: add shared terminal session and enhance terminal view with command history and input handling
This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user