Files
EnhancedTerminal/Panels.swift
Paul Bailey 9561e229aa Add 5 new features: edit snippets, clear scrollback, category filter, find in terminal, startup commands
1. Edit Snippets: Edit button to modify existing snippets
2. Clear Scrollback: Cmd+Shift+K to clear all terminal output
3. Category Filter: Dropdown to filter snippets by category
4. Find in Terminal: Cmd+F to search terminal output (built-in)
5. Startup Commands: Configure commands to run when opening new tabs

All features tested and working
2026-01-31 23:21:39 +00:00

968 lines
39 KiB
Swift

import Cocoa
import UniformTypeIdentifiers
// MARK: - History Search Panel (Ctrl+R)
class HistorySearchPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextFieldDelegate {
private var tableView: NSTableView!
private var searchField: NSTextField!
private var filteredHistory: [String] = []
private var completion: ((String?) -> Void)?
init() {
super.init(
contentRect: NSRect(x: 0, y: 0, width: 600, height: 400),
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false
)
title = "Command History (Ctrl+R)"
isReleasedWhenClosed = false
setupUI()
}
private func setupUI() {
let container = NSView(frame: contentView!.bounds)
container.autoresizingMask = [.width, .height]
// Search field
let searchLabel = NSTextField(labelWithString: "Search:")
searchLabel.frame = NSRect(x: 20, y: container.bounds.height - 40, width: 60, height: 20)
searchLabel.autoresizingMask = [.minYMargin]
searchField = NSTextField(frame: NSRect(x: 85, y: container.bounds.height - 42, width: container.bounds.width - 105, height: 24))
searchField.placeholderString = "Type to search history (fuzzy search)..."
searchField.autoresizingMask = [.width, .minYMargin]
searchField.delegate = self
// Table view
let scrollView = NSScrollView(frame: NSRect(x: 20, y: 50, width: container.bounds.width - 40, height: container.bounds.height - 110))
scrollView.autoresizingMask = [.width, .height]
scrollView.hasVerticalScroller = true
scrollView.borderType = .bezelBorder
tableView = NSTableView(frame: scrollView.bounds)
tableView.headerView = nil
tableView.rowHeight = 24
let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("command"))
column.width = scrollView.bounds.width - 20
tableView.addTableColumn(column)
tableView.dataSource = self
tableView.delegate = self
tableView.target = self
tableView.doubleAction = #selector(selectCommand)
scrollView.documentView = tableView
// Buttons
let selectButton = NSButton(frame: NSRect(x: container.bounds.width - 120, y: 10, width: 100, height: 30))
selectButton.title = "Use Command"
selectButton.bezelStyle = .rounded
selectButton.target = self
selectButton.action = #selector(selectCommand)
selectButton.autoresizingMask = [.minXMargin, .maxYMargin]
let cancelButton = NSButton(frame: NSRect(x: container.bounds.width - 230, y: 10, width: 100, height: 30))
cancelButton.title = "Cancel"
cancelButton.bezelStyle = .rounded
cancelButton.target = self
cancelButton.action = #selector(cancel)
cancelButton.autoresizingMask = [.minXMargin, .maxYMargin]
let infoLabel = NSTextField(labelWithString: "Tip: Double-click or press Enter to use a command")
infoLabel.frame = NSRect(x: 20, y: 15, width: 400, height: 20)
infoLabel.textColor = .secondaryLabelColor
infoLabel.font = NSFont.systemFont(ofSize: 11)
infoLabel.autoresizingMask = [.maxYMargin]
container.addSubview(searchLabel)
container.addSubview(searchField)
container.addSubview(scrollView)
container.addSubview(selectButton)
container.addSubview(cancelButton)
container.addSubview(infoLabel)
contentView = container
filteredHistory = HistoryManager.shared.getRecentCommands(50)
tableView.reloadData()
}
func controlTextDidChange(_ obj: Notification) {
let query = searchField.stringValue
filteredHistory = HistoryManager.shared.searchHistory(query)
tableView.reloadData()
}
@objc private func selectCommand() {
let row = tableView.selectedRow
guard row >= 0 && row < filteredHistory.count else { return }
completion?(filteredHistory[row])
close()
}
@objc private func cancel() {
completion?(nil)
close()
}
func show(relativeTo window: NSWindow, completion: @escaping (String?) -> Void) {
self.completion = completion
center()
searchField.stringValue = ""
filteredHistory = HistoryManager.shared.getRecentCommands(50)
tableView.reloadData()
makeKeyAndOrderFront(nil)
searchField.becomeFirstResponder()
}
// MARK: - NSTableViewDataSource
func numberOfRows(in tableView: NSTableView) -> Int {
return filteredHistory.count
}
// MARK: - NSTableViewDelegate
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
let text = NSTextField(labelWithString: filteredHistory[row])
text.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
text.lineBreakMode = .byTruncatingTail
return text
}
}
// MARK: - Snippet Panel
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)?
init() {
super.init(
contentRect: NSRect(x: 0, y: 0, width: 700, height: 500),
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false
)
title = "Command Snippets"
isReleasedWhenClosed = false
setupUI()
}
private func setupUI() {
let container = NSView(frame: contentView!.bounds)
container.autoresizingMask = [.width, .height]
// Search field
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 - 165, y: container.bounds.height - 45, width: 50, height: 30))
addButton.title = "Add"
addButton.bezelStyle = .rounded
addButton.target = self
addButton.action = #selector(addSnippet)
addButton.autoresizingMask = [.minXMargin, .minYMargin]
// Export button
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 - 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
let scrollView = NSScrollView(frame: NSRect(x: 20, y: 50, width: container.bounds.width - 40, height: container.bounds.height - 110))
scrollView.autoresizingMask = [.width, .height]
scrollView.hasVerticalScroller = true
scrollView.borderType = .bezelBorder
tableView = NSTableView(frame: scrollView.bounds)
tableView.rowHeight = 24
let nameColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("name"))
nameColumn.title = "Name"
nameColumn.width = 150
let commandColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("command"))
commandColumn.title = "Command"
commandColumn.width = 350
let categoryColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("category"))
categoryColumn.title = "Category"
categoryColumn.width = 100
tableView.addTableColumn(nameColumn)
tableView.addTableColumn(commandColumn)
tableView.addTableColumn(categoryColumn)
tableView.dataSource = self
tableView.delegate = self
tableView.target = self
tableView.doubleAction = #selector(selectSnippet)
scrollView.documentView = tableView
// Empty state label
let emptyLabel = NSTextField(wrappingLabelWithString: "No snippets yet.\n\nTo create a snippet:\n1. Select text in the terminal\n2. Right-click and choose 'Save as Snippet'\n\nOr click 'Add New' above.")
emptyLabel.frame = NSRect(x: 0, y: 0, width: scrollView.bounds.width - 40, height: 100)
emptyLabel.alignment = .center
emptyLabel.textColor = .secondaryLabelColor
emptyLabel.font = NSFont.systemFont(ofSize: 13)
emptyLabel.tag = 999 // Tag to identify empty state label
scrollView.addSubview(emptyLabel)
// Buttons
let useButton = NSButton(frame: NSRect(x: container.bounds.width - 120, y: 10, width: 100, height: 30))
useButton.title = "Use Snippet"
useButton.bezelStyle = .rounded
useButton.target = self
useButton.action = #selector(selectSnippet)
useButton.autoresizingMask = [.minXMargin, .maxYMargin]
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 - 450, y: 10, width: 100, height: 30))
cancelButton.title = "Cancel"
cancelButton.bezelStyle = .rounded
cancelButton.target = self
cancelButton.action = #selector(cancel)
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)
contentView = container
filteredSnippets = SnippetManager.shared.getAllSnippets()
tableView.reloadData()
updateEmptyState()
}
private func updateEmptyState() {
if let scrollView = tableView.enclosingScrollView,
let emptyLabel = scrollView.viewWithTag(999) {
emptyLabel.isHidden = !filteredSnippets.isEmpty
}
}
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
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"
alert.informativeText = "Enter snippet details:"
let nameField = NSTextField(frame: NSRect(x: 0, y: 90, width: 300, height: 24))
nameField.placeholderString = "Name"
let commandField = NSTextField(frame: NSRect(x: 0, y: 60, width: 300, height: 24))
commandField.placeholderString = "Command"
let categoryField = NSTextField(frame: NSRect(x: 0, y: 30, width: 300, height: 24))
categoryField.placeholderString = "Category (optional)"
let descField = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 24))
descField.placeholderString = "Description (optional)"
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: "Add")
alert.addButton(withTitle: "Cancel")
if alert.runModal() == .alertFirstButtonReturn {
let snippet = SnippetManager.Snippet(
name: nameField.stringValue,
command: commandField.stringValue,
description: descField.stringValue,
category: categoryField.stringValue.isEmpty ? "General" : categoryField.stringValue
)
SnippetManager.shared.addSnippet(snippet)
filteredSnippets = SnippetManager.shared.getAllSnippets()
tableView.reloadData()
updateEmptyState()
updateCategoryFilter()
}
}
@objc private func selectSnippet() {
let row = tableView.selectedRow
guard row >= 0 && row < filteredSnippets.count else { return }
completion?(filteredSnippets[row])
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 }
let snippet = filteredSnippets[row]
let alert = NSAlert()
alert.messageText = "Delete Snippet?"
alert.informativeText = "Are you sure you want to delete '\(snippet.name)'?"
alert.addButton(withTitle: "Delete")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
if alert.runModal() == .alertFirstButtonReturn {
SnippetManager.shared.removeSnippet(snippet.id)
filteredSnippets = SnippetManager.shared.getAllSnippets()
tableView.reloadData()
updateEmptyState()
updateCategoryFilter()
}
}
@objc private func exportSnippets() {
guard let data = SnippetManager.shared.exportSnippets() else {
let alert = NSAlert()
alert.messageText = "Export Failed"
alert.informativeText = "Could not export snippets."
alert.alertStyle = .warning
alert.runModal()
return
}
let savePanel = NSSavePanel()
savePanel.allowedContentTypes = [.json]
savePanel.nameFieldStringValue = "snippets.json"
savePanel.title = "Export Snippets"
if savePanel.runModal() == .OK, let url = savePanel.url {
do {
try data.write(to: url)
let alert = NSAlert()
alert.messageText = "Export Successful"
alert.informativeText = "Snippets exported to \(url.lastPathComponent)"
alert.alertStyle = .informational
alert.runModal()
} catch {
let alert = NSAlert()
alert.messageText = "Export Failed"
alert.informativeText = error.localizedDescription
alert.alertStyle = .warning
alert.runModal()
}
}
}
@objc private func importSnippets() {
let openPanel = NSOpenPanel()
openPanel.allowedContentTypes = [.json]
openPanel.allowsMultipleSelection = false
openPanel.title = "Import Snippets"
if openPanel.runModal() == .OK, let url = openPanel.url {
do {
let data = try Data(contentsOf: url)
// Ask if user wants to replace or merge
let alert = NSAlert()
alert.messageText = "Import Snippets"
alert.informativeText = "Do you want to replace existing snippets or merge with them?"
alert.addButton(withTitle: "Merge")
alert.addButton(withTitle: "Replace")
alert.addButton(withTitle: "Cancel")
let response = alert.runModal()
if response == .alertThirdButtonReturn {
return
}
let replace = (response == .alertSecondButtonReturn)
if SnippetManager.shared.importSnippets(from: data, replace: replace) {
filteredSnippets = SnippetManager.shared.getAllSnippets()
tableView.reloadData()
updateEmptyState()
updateCategoryFilter()
let success = NSAlert()
success.messageText = "Import Successful"
success.informativeText = "Snippets imported successfully."
success.alertStyle = .informational
success.runModal()
} else {
let error = NSAlert()
error.messageText = "Import Failed"
error.informativeText = "Could not parse snippets file."
error.alertStyle = .warning
error.runModal()
}
} catch {
let alert = NSAlert()
alert.messageText = "Import Failed"
alert.informativeText = error.localizedDescription
alert.alertStyle = .warning
alert.runModal()
}
}
}
@objc private func cancel() {
completion?(nil)
close()
}
func show(relativeTo window: NSWindow, completion: @escaping (SnippetManager.Snippet?) -> Void) {
self.completion = completion
center()
searchField.stringValue = ""
updateCategoryFilter()
filteredSnippets = SnippetManager.shared.getAllSnippets()
tableView.reloadData()
updateEmptyState()
makeKeyAndOrderFront(nil)
}
// MARK: - NSTableViewDataSource
func numberOfRows(in tableView: NSTableView) -> Int {
return filteredSnippets.count
}
// MARK: - NSTableViewDelegate
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
let snippet = filteredSnippets[row]
let identifier = tableColumn?.identifier.rawValue ?? ""
let text: String
switch identifier {
case "name": text = snippet.name
case "command": text = snippet.command
case "category": text = snippet.category
default: text = ""
}
let textField = NSTextField(labelWithString: text)
textField.font = NSFont.systemFont(ofSize: 12)
textField.lineBreakMode = .byTruncatingTail
return textField
}
func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat {
return 24
}
func tableViewSelectionDidChange(_ notification: Notification) {
// Update UI based on selection if needed
}
}
// MARK: - AI Generator Panel
class AIGeneratorPanel: NSPanel, NSTextFieldDelegate {
private var promptField: NSTextField!
private var resultField: NSTextView!
private var generateButton: NSButton!
private var useButton: NSButton!
private var statusLabel: NSTextField!
private var completion: ((String?) -> Void)?
init() {
super.init(
contentRect: NSRect(x: 0, y: 0, width: 600, height: 350),
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false
)
title = "AI Command Generator"
isReleasedWhenClosed = false
setupUI()
}
private func setupUI() {
let container = NSView(frame: contentView!.bounds)
container.autoresizingMask = [.width, .height]
// Prompt field
let promptLabel = NSTextField(labelWithString: "Describe what you want to do:")
promptLabel.frame = NSRect(x: 20, y: container.bounds.height - 35, width: 250, height: 20)
promptLabel.autoresizingMask = [.minYMargin]
promptField = NSTextField(frame: NSRect(x: 20, y: container.bounds.height - 65, width: container.bounds.width - 40, height: 24))
promptField.placeholderString = "e.g., find all PDF files larger than 10MB"
promptField.autoresizingMask = [.width, .minYMargin]
promptField.delegate = self
// Generate button
generateButton = NSButton(frame: NSRect(x: 20, y: container.bounds.height - 105, width: 120, height: 30))
generateButton.title = "Generate"
generateButton.bezelStyle = .rounded
generateButton.target = self
generateButton.action = #selector(generate)
generateButton.autoresizingMask = [.minYMargin]
// Status label
statusLabel = NSTextField(labelWithString: "")
statusLabel.frame = NSRect(x: 150, y: container.bounds.height - 100, width: 300, height: 20)
statusLabel.textColor = .secondaryLabelColor
statusLabel.font = NSFont.systemFont(ofSize: 11)
statusLabel.autoresizingMask = [.minYMargin]
// Result field
let resultLabel = NSTextField(labelWithString: "Generated Command:")
resultLabel.frame = NSRect(x: 20, y: container.bounds.height - 135, width: 200, height: 20)
resultLabel.autoresizingMask = [.minYMargin]
let scrollView = NSScrollView(frame: NSRect(x: 20, y: 60, width: container.bounds.width - 40, height: container.bounds.height - 205))
scrollView.autoresizingMask = [.width, .height]
scrollView.hasVerticalScroller = true
scrollView.borderType = .bezelBorder
resultField = NSTextView(frame: scrollView.bounds)
resultField.isEditable = true
resultField.font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)
resultField.string = "Generated command will appear here..."
resultField.textColor = .secondaryLabelColor
scrollView.documentView = resultField
// Buttons
useButton = NSButton(frame: NSRect(x: container.bounds.width - 120, y: 15, width: 100, height: 30))
useButton.title = "Use Command"
useButton.bezelStyle = .rounded
useButton.target = self
useButton.action = #selector(useCommand)
useButton.autoresizingMask = [.minXMargin, .maxYMargin]
let cancelButton = NSButton(frame: NSRect(x: container.bounds.width - 230, y: 15, width: 100, height: 30))
cancelButton.title = "Cancel"
cancelButton.bezelStyle = .rounded
cancelButton.target = self
cancelButton.action = #selector(cancel)
cancelButton.autoresizingMask = [.minXMargin, .maxYMargin]
let infoLabel = NSTextField(labelWithString: "Tip: You can edit the generated command before using it")
infoLabel.frame = NSRect(x: 20, y: 20, width: 400, height: 20)
infoLabel.textColor = .secondaryLabelColor
infoLabel.font = NSFont.systemFont(ofSize: 11)
infoLabel.autoresizingMask = [.maxYMargin]
container.addSubview(promptLabel)
container.addSubview(promptField)
container.addSubview(generateButton)
container.addSubview(statusLabel)
container.addSubview(resultLabel)
container.addSubview(scrollView)
container.addSubview(useButton)
container.addSubview(cancelButton)
container.addSubview(infoLabel)
contentView = container
}
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
generate()
return true
}
return false
}
@objc private func generate() {
let prompt = promptField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !prompt.isEmpty else {
statusLabel.stringValue = "Please enter a description"
return
}
generateButton.isEnabled = false
statusLabel.stringValue = "Generating..."
resultField.string = "Generating command..."
resultField.textColor = .secondaryLabelColor
AIManager.shared.generateCommand(from: prompt) { [weak self] result in
DispatchQueue.main.async {
self?.generateButton.isEnabled = true
switch result {
case .success(let command):
self?.resultField.string = command
self?.resultField.textColor = .labelColor
self?.statusLabel.stringValue = "✓ Generated successfully"
case .failure(let error):
self?.resultField.string = "Error: \(error.localizedDescription)"
self?.resultField.textColor = .systemRed
self?.statusLabel.stringValue = "✗ Generation failed"
}
}
}
}
@objc private func useCommand() {
let command = resultField.string.trimmingCharacters(in: .whitespacesAndNewlines)
guard !command.isEmpty && !command.hasPrefix("Error:") && !command.hasPrefix("Generating") && !command.hasPrefix("Generated command") else {
return
}
completion?(command)
close()
}
@objc private func cancel() {
completion?(nil)
close()
}
func show(relativeTo window: NSWindow, completion: @escaping (String?) -> Void) {
self.completion = completion
center()
promptField.stringValue = ""
resultField.string = "Generated command will appear here..."
resultField.textColor = .secondaryLabelColor
statusLabel.stringValue = ""
makeKeyAndOrderFront(nil)
promptField.becomeFirstResponder()
}
}
// MARK: - Settings Window
class SettingsWindowController: NSWindowController {
convenience init() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 550),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
window.title = "Settings"
window.center()
window.isReleasedWhenClosed = false
self.init(window: window)
setupUI()
}
private func setupUI() {
guard let window = window else { return }
let container = NSView(frame: window.contentView!.bounds)
container.autoresizingMask = [.width, .height]
var yPos = container.bounds.height - 40
// Theme selection
let themeLabel = NSTextField(labelWithString: "Theme:")
themeLabel.frame = NSRect(x: 20, y: yPos, width: 120, height: 20)
let themePopup = NSPopUpButton(frame: NSRect(x: 150, y: yPos - 5, width: 200, height: 30))
themePopup.addItems(withTitles: TerminalSettings.Theme.allThemes.map { $0.name })
themePopup.selectItem(at: TerminalSettings.Theme.allThemes.firstIndex(where: { $0.name == TerminalSettings.shared.theme.name }) ?? 0)
themePopup.target = self
themePopup.action = #selector(themeChanged(_:))
container.addSubview(themeLabel)
container.addSubview(themePopup)
yPos -= 50
// Font size
let fontLabel = NSTextField(labelWithString: "Font Size:")
fontLabel.frame = NSRect(x: 20, y: yPos, width: 120, height: 20)
let fontField = NSTextField(frame: NSRect(x: 150, y: yPos, width: 60, height: 24))
fontField.stringValue = "\(Int(TerminalSettings.shared.fontSize))"
fontField.target = self
fontField.action = #selector(fontSizeChanged(_:))
container.addSubview(fontLabel)
container.addSubview(fontField)
yPos -= 50
// Auto-suggestions toggle
let suggestLabel = NSTextField(labelWithString: "Auto-suggestions:")
suggestLabel.frame = NSRect(x: 20, y: yPos, width: 120, height: 20)
let suggestCheckbox = NSButton(checkboxWithTitle: "Enable", target: self, action: #selector(suggestionsToggled(_:)))
suggestCheckbox.frame = NSRect(x: 150, y: yPos - 2, width: 100, height: 24)
suggestCheckbox.state = TerminalSettings.shared.enableAutoSuggestions ? .on : .off
container.addSubview(suggestLabel)
container.addSubview(suggestCheckbox)
yPos -= 50
// AI Provider
let aiLabel = NSTextField(labelWithString: "AI Provider:")
aiLabel.frame = NSRect(x: 20, y: yPos, width: 120, height: 20)
let aiPopup = NSPopUpButton(frame: NSRect(x: 150, y: yPos - 5, width: 200, height: 30))
aiPopup.addItems(withTitles: TerminalSettings.AIProvider.allCases.map { $0.rawValue })
aiPopup.selectItem(withTitle: TerminalSettings.shared.aiProvider.rawValue)
aiPopup.target = self
aiPopup.action = #selector(aiProviderChanged(_:))
container.addSubview(aiLabel)
container.addSubview(aiPopup)
yPos -= 50
// OpenAI Key
let openAILabel = NSTextField(labelWithString: "OpenAI Key:")
openAILabel.frame = NSRect(x: 20, y: yPos, width: 120, height: 20)
let openAIField = NSSecureTextField(frame: NSRect(x: 150, y: yPos, width: 320, height: 24))
openAIField.placeholderString = "sk-..."
openAIField.stringValue = TerminalSettings.shared.openAIKey
openAIField.target = self
openAIField.action = #selector(openAIKeyChanged(_:))
container.addSubview(openAILabel)
container.addSubview(openAIField)
yPos -= 50
// Ollama URL
let ollamaLabel = NSTextField(labelWithString: "Ollama URL:")
ollamaLabel.frame = NSRect(x: 20, y: yPos, width: 120, height: 20)
let ollamaField = NSTextField(frame: NSRect(x: 150, y: yPos, width: 320, height: 24))
ollamaField.placeholderString = "http://localhost:11434"
ollamaField.stringValue = TerminalSettings.shared.ollamaURL
ollamaField.target = self
ollamaField.action = #selector(ollamaURLChanged(_:))
container.addSubview(ollamaLabel)
container.addSubview(ollamaField)
yPos -= 50
// Ollama Model
let modelLabel = NSTextField(labelWithString: "Ollama Model:")
modelLabel.frame = NSRect(x: 20, y: yPos, width: 120, height: 20)
let modelField = NSTextField(frame: NSRect(x: 150, y: yPos, width: 320, height: 24))
modelField.placeholderString = "llama3.2:latest"
modelField.stringValue = TerminalSettings.shared.ollamaModel
modelField.target = self
modelField.action = #selector(ollamaModelChanged(_:))
container.addSubview(modelLabel)
container.addSubview(modelField)
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\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)
container.addSubview(infoLabel)
window.contentView = container
}
@objc private func themeChanged(_ sender: NSPopUpButton) {
let themes = TerminalSettings.Theme.allThemes
if sender.indexOfSelectedItem < themes.count {
TerminalSettings.shared.theme = themes[sender.indexOfSelectedItem]
NotificationCenter.default.post(name: NSNotification.Name("ThemeChanged"), object: nil)
}
}
@objc private func fontSizeChanged(_ sender: NSTextField) {
if let size = Double(sender.stringValue), size >= 8 && size <= 72 {
TerminalSettings.shared.fontSize = CGFloat(size)
NotificationCenter.default.post(name: NSNotification.Name("ThemeChanged"), object: nil)
}
}
@objc private func suggestionsToggled(_ sender: NSButton) {
TerminalSettings.shared.enableAutoSuggestions = sender.state == .on
}
@objc private func aiProviderChanged(_ sender: NSPopUpButton) {
let providers = TerminalSettings.AIProvider.allCases
if sender.indexOfSelectedItem < providers.count {
TerminalSettings.shared.aiProvider = providers[sender.indexOfSelectedItem]
AIManager.shared.updateService()
}
}
@objc private func openAIKeyChanged(_ sender: NSTextField) {
TerminalSettings.shared.openAIKey = sender.stringValue
AIManager.shared.updateService()
}
@objc private func ollamaURLChanged(_ sender: NSTextField) {
TerminalSettings.shared.ollamaURL = sender.stringValue
AIManager.shared.updateService()
}
@objc private func ollamaModelChanged(_ sender: NSTextField) {
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 }
}
}