diff --git a/.DS_Store b/.DS_Store index ad6a20f..e65d791 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/EnhancedTerminal.app/Contents/MacOS/EnhancedTerminal b/EnhancedTerminal.app/Contents/MacOS/EnhancedTerminal index 225f0f5..6f35b1c 100755 Binary files a/EnhancedTerminal.app/Contents/MacOS/EnhancedTerminal and b/EnhancedTerminal.app/Contents/MacOS/EnhancedTerminal differ diff --git a/MainWindowController.swift b/MainWindowController.swift index 2e53616..03ec769 100644 --- a/MainWindowController.swift +++ b/MainWindowController.swift @@ -141,6 +141,7 @@ class MainWindowController: NSWindowController { fileMenu.addItem(NSMenuItem(title: "Close Tab", action: #selector(closeCurrentTab), keyEquivalent: "w")) fileMenu.addItem(NSMenuItem.separator()) fileMenu.addItem(NSMenuItem(title: "Clear Screen", action: #selector(clearScreen), keyEquivalent: "k")) + fileMenu.addItem(NSMenuItem(title: "Clear Scrollback", action: #selector(clearScrollback), keyEquivalent: "K")) // Edit menu let editMenuItem = NSMenuItem(title: "Edit", action: nil, keyEquivalent: "") @@ -152,6 +153,8 @@ class MainWindowController: NSWindowController { editMenu.addItem(NSMenuItem(title: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")) editMenu.addItem(NSMenuItem(title: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")) editMenu.addItem(NSMenuItem.separator()) + editMenu.addItem(NSMenuItem(title: "Find...", action: #selector(showFindPanel), keyEquivalent: "f")) + editMenu.addItem(NSMenuItem.separator()) let searchHistoryItem = NSMenuItem(title: "Search History", action: #selector(searchHistory), keyEquivalent: "r") searchHistoryItem.keyEquivalentModifierMask = [.command] @@ -453,6 +456,28 @@ class MainWindowController: NSWindowController { } } + @objc func clearScrollback() { + if let session = tabManager.getCurrentSession() { + let alert = NSAlert() + alert.messageText = "Clear Scrollback?" + alert.informativeText = "This will clear all terminal output history." + alert.addButton(withTitle: "Clear") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + + if alert.runModal() == .alertFirstButtonReturn { + session.textView.string = "" + session.showPrompt() + } + } + } + + @objc func showFindPanel() { + if let session = tabManager.getCurrentSession() { + session.textView.performTextFinderAction(nil) + } + } + @objc func clearHistory() { let alert = NSAlert() alert.messageText = "Clear Command History?" diff --git a/Panels.swift b/Panels.swift index 1541611..6e5ce4e 100644 --- a/Panels.swift +++ b/Panels.swift @@ -136,6 +136,7 @@ class HistorySearchPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, N class SnippetPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextFieldDelegate { private var tableView: NSTableView! private var searchField: NSTextField! + private var categoryPopup: NSPopUpButton! private var filteredSnippets: [SnippetManager.Snippet] = [] private var completion: ((SnippetManager.Snippet?) -> Void)? @@ -157,13 +158,24 @@ class SnippetPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextF container.autoresizingMask = [.width, .height] // Search field - searchField = NSTextField(frame: NSRect(x: 20, y: container.bounds.height - 42, width: container.bounds.width - 260, height: 24)) + searchField = NSTextField(frame: NSRect(x: 20, y: container.bounds.height - 42, width: container.bounds.width - 400, height: 24)) searchField.placeholderString = "Search snippets..." searchField.autoresizingMask = [.width, .minYMargin] searchField.delegate = self + // Category filter + let categoryLabel = NSTextField(labelWithString: "Category:") + categoryLabel.frame = NSRect(x: container.bounds.width - 370, y: container.bounds.height - 40, width: 70, height: 20) + categoryLabel.autoresizingMask = [.minXMargin, .minYMargin] + + categoryPopup = NSPopUpButton(frame: NSRect(x: container.bounds.width - 295, y: container.bounds.height - 45, width: 120, height: 30)) + categoryPopup.autoresizingMask = [.minXMargin, .minYMargin] + categoryPopup.target = self + categoryPopup.action = #selector(categoryChanged) + updateCategoryFilter() + // Add button - let addButton = NSButton(frame: NSRect(x: container.bounds.width - 230, y: container.bounds.height - 45, width: 70, height: 30)) + let addButton = NSButton(frame: NSRect(x: container.bounds.width - 165, y: container.bounds.height - 45, width: 50, height: 30)) addButton.title = "Add" addButton.bezelStyle = .rounded addButton.target = self @@ -171,19 +183,21 @@ class SnippetPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextF addButton.autoresizingMask = [.minXMargin, .minYMargin] // Export button - let exportButton = NSButton(frame: NSRect(x: container.bounds.width - 155, y: container.bounds.height - 45, width: 70, height: 30)) - exportButton.title = "Export" + let exportButton = NSButton(frame: NSRect(x: container.bounds.width - 110, y: container.bounds.height - 45, width: 50, height: 30)) + exportButton.title = "↓" exportButton.bezelStyle = .rounded exportButton.target = self exportButton.action = #selector(exportSnippets) + exportButton.toolTip = "Export snippets" exportButton.autoresizingMask = [.minXMargin, .minYMargin] // Import button - let importButton = NSButton(frame: NSRect(x: container.bounds.width - 80, y: container.bounds.height - 45, width: 70, height: 30)) - importButton.title = "Import" + let importButton = NSButton(frame: NSRect(x: container.bounds.width - 55, y: container.bounds.height - 45, width: 50, height: 30)) + importButton.title = "↑" importButton.bezelStyle = .rounded importButton.target = self importButton.action = #selector(importSnippets) + importButton.toolTip = "Import snippets" importButton.autoresizingMask = [.minXMargin, .minYMargin] // Table view @@ -235,14 +249,21 @@ class SnippetPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextF useButton.action = #selector(selectSnippet) useButton.autoresizingMask = [.minXMargin, .maxYMargin] - let deleteButton = NSButton(frame: NSRect(x: container.bounds.width - 230, y: 10, width: 100, height: 30)) + let editButton = NSButton(frame: NSRect(x: container.bounds.width - 230, y: 10, width: 100, height: 30)) + editButton.title = "Edit" + editButton.bezelStyle = .rounded + editButton.target = self + editButton.action = #selector(editSnippet) + editButton.autoresizingMask = [.minXMargin, .maxYMargin] + + let deleteButton = NSButton(frame: NSRect(x: container.bounds.width - 340, y: 10, width: 100, height: 30)) deleteButton.title = "Delete" deleteButton.bezelStyle = .rounded deleteButton.target = self deleteButton.action = #selector(deleteSnippet) deleteButton.autoresizingMask = [.minXMargin, .maxYMargin] - let cancelButton = NSButton(frame: NSRect(x: container.bounds.width - 340, y: 10, width: 100, height: 30)) + let cancelButton = NSButton(frame: NSRect(x: container.bounds.width - 450, y: 10, width: 100, height: 30)) cancelButton.title = "Cancel" cancelButton.bezelStyle = .rounded cancelButton.target = self @@ -250,11 +271,14 @@ class SnippetPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextF cancelButton.autoresizingMask = [.minXMargin, .maxYMargin] container.addSubview(searchField) + container.addSubview(categoryLabel) + container.addSubview(categoryPopup) container.addSubview(addButton) container.addSubview(exportButton) container.addSubview(importButton) container.addSubview(scrollView) container.addSubview(useButton) + container.addSubview(editButton) container.addSubview(deleteButton) container.addSubview(cancelButton) @@ -272,13 +296,48 @@ class SnippetPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextF } } - func controlTextDidChange(_ obj: Notification) { + private func updateCategoryFilter() { + categoryPopup.removeAllItems() + categoryPopup.addItem(withTitle: "All Categories") + + let categories = Set(SnippetManager.shared.getAllSnippets().map { $0.category }).sorted() + categoryPopup.addItems(withTitles: categories) + } + + @objc private func categoryChanged() { + filterSnippets() + } + + private func filterSnippets() { let query = searchField.stringValue - filteredSnippets = SnippetManager.shared.searchSnippets(query) + let selectedCategory = categoryPopup.titleOfSelectedItem ?? "All Categories" + + var snippets = SnippetManager.shared.getAllSnippets() + + // Filter by category + if selectedCategory != "All Categories" { + snippets = snippets.filter { $0.category == selectedCategory } + } + + // Filter by search query + if !query.isEmpty { + snippets = snippets.filter { + $0.name.localizedCaseInsensitiveContains(query) || + $0.command.localizedCaseInsensitiveContains(query) || + $0.category.localizedCaseInsensitiveContains(query) || + $0.tags.contains { $0.localizedCaseInsensitiveContains(query) } + } + } + + filteredSnippets = snippets tableView.reloadData() updateEmptyState() } + func controlTextDidChange(_ obj: Notification) { + filterSnippets() + } + @objc private func addSnippet() { let alert = NSAlert() alert.messageText = "Add New Snippet" @@ -317,6 +376,7 @@ class SnippetPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextF filteredSnippets = SnippetManager.shared.getAllSnippets() tableView.reloadData() updateEmptyState() + updateCategoryFilter() } } @@ -327,6 +387,62 @@ class SnippetPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextF close() } + @objc private func editSnippet() { + let row = tableView.selectedRow + guard row >= 0 && row < filteredSnippets.count else { return } + + let snippet = filteredSnippets[row] + + let alert = NSAlert() + alert.messageText = "Edit Snippet" + alert.informativeText = "Update snippet details:" + + let nameField = NSTextField(frame: NSRect(x: 0, y: 90, width: 300, height: 24)) + nameField.stringValue = snippet.name + nameField.placeholderString = "Name" + + let commandField = NSTextField(frame: NSRect(x: 0, y: 60, width: 300, height: 24)) + commandField.stringValue = snippet.command + commandField.placeholderString = "Command" + + let categoryField = NSTextField(frame: NSRect(x: 0, y: 30, width: 300, height: 24)) + categoryField.stringValue = snippet.category + categoryField.placeholderString = "Category" + + let descField = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 24)) + descField.stringValue = snippet.description + descField.placeholderString = "Description" + + let view = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 120)) + view.addSubview(nameField) + view.addSubview(commandField) + view.addSubview(categoryField) + view.addSubview(descField) + + alert.accessoryView = view + alert.addButton(withTitle: "Save") + alert.addButton(withTitle: "Cancel") + + if alert.runModal() == .alertFirstButtonReturn { + // Remove old snippet + SnippetManager.shared.removeSnippet(snippet.id) + + // Add updated snippet + let updatedSnippet = SnippetManager.Snippet( + name: nameField.stringValue.isEmpty ? snippet.name : nameField.stringValue, + command: commandField.stringValue.isEmpty ? snippet.command : commandField.stringValue, + description: descField.stringValue, + category: categoryField.stringValue.isEmpty ? snippet.category : categoryField.stringValue + ) + SnippetManager.shared.addSnippet(updatedSnippet) + + filteredSnippets = SnippetManager.shared.getAllSnippets() + tableView.reloadData() + updateEmptyState() + updateCategoryFilter() + } + } + @objc private func deleteSnippet() { let row = tableView.selectedRow guard row >= 0 && row < filteredSnippets.count else { return } @@ -344,6 +460,7 @@ class SnippetPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextF filteredSnippets = SnippetManager.shared.getAllSnippets() tableView.reloadData() updateEmptyState() + updateCategoryFilter() } } @@ -409,6 +526,7 @@ class SnippetPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextF filteredSnippets = SnippetManager.shared.getAllSnippets() tableView.reloadData() updateEmptyState() + updateCategoryFilter() let success = NSAlert() success.messageText = "Import Successful" @@ -441,6 +559,7 @@ class SnippetPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextF self.completion = completion center() searchField.stringValue = "" + updateCategoryFilter() filteredSnippets = SnippetManager.shared.getAllSnippets() tableView.reloadData() updateEmptyState() @@ -650,7 +769,7 @@ class AIGeneratorPanel: NSPanel, NSTextFieldDelegate { class SettingsWindowController: NSWindowController { convenience init() { let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 500, height: 450), + contentRect: NSRect(x: 0, y: 0, width: 500, height: 550), styleMask: [.titled, .closable], backing: .buffered, defer: false @@ -773,8 +892,23 @@ class SettingsWindowController: NSWindowController { yPos -= 50 + // Startup commands + let startupLabel = NSTextField(labelWithString: "Startup Commands:") + startupLabel.frame = NSRect(x: 20, y: yPos, width: 120, height: 20) + + let startupField = NSTextField(frame: NSRect(x: 150, y: yPos, width: 320, height: 24)) + startupField.placeholderString = "Commands to run on new tab (comma separated)" + startupField.stringValue = TerminalSettings.shared.startupCommands.joined(separator: ", ") + startupField.target = self + startupField.action = #selector(startupCommandsChanged(_:)) + + container.addSubview(startupLabel) + container.addSubview(startupField) + + yPos -= 50 + // Info label - let infoLabel = NSTextField(wrappingLabelWithString: "Note: For Ollama, make sure it's running locally. For OpenAI, you need an API key from platform.openai.com") + let infoLabel = NSTextField(wrappingLabelWithString: "Note: For Ollama, make sure it's running locally. For OpenAI, you need an API key from platform.openai.com\n\nStartup commands will run automatically when opening a new tab.") infoLabel.frame = NSRect(x: 20, y: 20, width: 460, height: 40) infoLabel.textColor = .secondaryLabelColor infoLabel.font = NSFont.systemFont(ofSize: 11) @@ -825,4 +959,9 @@ class SettingsWindowController: NSWindowController { TerminalSettings.shared.ollamaModel = sender.stringValue AIManager.shared.updateService() } + + @objc private func startupCommandsChanged(_ sender: NSTextField) { + let commands = sender.stringValue.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + TerminalSettings.shared.startupCommands = commands.filter { !$0.isEmpty } + } } diff --git a/Settings.swift b/Settings.swift index c84d8ad..fedbfbb 100644 --- a/Settings.swift +++ b/Settings.swift @@ -15,6 +15,7 @@ class TerminalSettings { var ollamaModel: String = "llama3.2:latest" var enableAutoSuggestions: Bool = true var maxHistorySize: Int = 1000 + var startupCommands: [String] = [] enum AIProvider: String, CaseIterable { case none = "None" diff --git a/TerminalSession.swift b/TerminalSession.swift index 4fe6194..294c76a 100644 --- a/TerminalSession.swift +++ b/TerminalSession.swift @@ -179,6 +179,13 @@ class TerminalSession: NSObject, NSTextViewDelegate, NSMenuDelegate { // Wait a bit for shell to initialize, then show prompt DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.showPrompt() + + // Run startup commands + for command in TerminalSettings.shared.startupCommands { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self?.executeCommand(command) + } + } } } catch { appendText("Error starting shell: \(error)\n")