Add quick-win features: copy-on-select, middle-click paste, visual bell, zoom pane, clickable URLs, draggable dividers
- Copy on select: Auto-copy selected text to clipboard - Middle-click paste: Quick paste with middle mouse button - Visual bell: Subtle flash when commands complete - Zoom pane: Cmd+Shift+Enter to maximize/restore split pane - Clickable URLs: Cmd+click to open URLs in browser - Draggable dividers: Resize split panes by dragging divider - Updated help and keyboard shortcuts documentation
This commit is contained in:
Binary file not shown.
@@ -50,6 +50,8 @@ class MainWindowController: NSWindowController {
|
|||||||
var snippetPanel: SnippetPanel!
|
var snippetPanel: SnippetPanel!
|
||||||
var aiPanel: AIGeneratorPanel!
|
var aiPanel: AIGeneratorPanel!
|
||||||
var settingsWindow: SettingsWindowController!
|
var settingsWindow: SettingsWindowController!
|
||||||
|
var zoomedPane: TerminalSession?
|
||||||
|
var preZoomSplitView: NSSplitView?
|
||||||
|
|
||||||
convenience init() {
|
convenience init() {
|
||||||
let window = NSWindow(
|
let window = NSWindow(
|
||||||
@@ -202,6 +204,10 @@ class MainWindowController: NSWindowController {
|
|||||||
closePaneItem.keyEquivalentModifierMask = [.command, .shift]
|
closePaneItem.keyEquivalentModifierMask = [.command, .shift]
|
||||||
fileMenu.addItem(closePaneItem)
|
fileMenu.addItem(closePaneItem)
|
||||||
|
|
||||||
|
let zoomPaneItem = NSMenuItem(title: "Zoom Pane", action: #selector(toggleZoomPane), keyEquivalent: "\r")
|
||||||
|
zoomPaneItem.keyEquivalentModifierMask = [.command, .shift]
|
||||||
|
fileMenu.addItem(zoomPaneItem)
|
||||||
|
|
||||||
fileMenu.addItem(NSMenuItem.separator())
|
fileMenu.addItem(NSMenuItem.separator())
|
||||||
fileMenu.addItem(NSMenuItem(title: "Clear Screen", action: #selector(clearScreen), keyEquivalent: "k"))
|
fileMenu.addItem(NSMenuItem(title: "Clear Screen", action: #selector(clearScreen), keyEquivalent: "k"))
|
||||||
fileMenu.addItem(NSMenuItem(title: "Clear Scrollback", action: #selector(clearScrollback), keyEquivalent: "K"))
|
fileMenu.addItem(NSMenuItem(title: "Clear Scrollback", action: #selector(clearScrollback), keyEquivalent: "K"))
|
||||||
@@ -552,6 +558,7 @@ class MainWindowController: NSWindowController {
|
|||||||
Cmd+W - Close Tab
|
Cmd+W - Close Tab
|
||||||
Cmd+D - Split Pane Vertically (side-by-side)
|
Cmd+D - Split Pane Vertically (side-by-side)
|
||||||
Cmd+Shift+D - Split Pane Horizontally (top-bottom)
|
Cmd+Shift+D - Split Pane Horizontally (top-bottom)
|
||||||
|
Cmd+Shift+Enter - Zoom/Unzoom Pane
|
||||||
Cmd+K - Clear Screen
|
Cmd+K - Clear Screen
|
||||||
Cmd+Shift+K - Clear Scrollback
|
Cmd+Shift+K - Clear Scrollback
|
||||||
|
|
||||||
@@ -561,6 +568,7 @@ class MainWindowController: NSWindowController {
|
|||||||
Cmd+A - Select All
|
Cmd+A - Select All
|
||||||
Cmd+F - Find in Terminal
|
Cmd+F - Find in Terminal
|
||||||
Cmd+R - Search History
|
Cmd+R - Search History
|
||||||
|
Middle-click - Paste
|
||||||
|
|
||||||
Tools:
|
Tools:
|
||||||
Cmd+K - AI Command Generator
|
Cmd+K - AI Command Generator
|
||||||
@@ -579,6 +587,8 @@ class MainWindowController: NSWindowController {
|
|||||||
Up/Down - Navigate Command History
|
Up/Down - Navigate Command History
|
||||||
Double-click Tab - Rename Tab
|
Double-click Tab - Rename Tab
|
||||||
Click × button - Close split pane
|
Click × button - Close split pane
|
||||||
|
Cmd+Click URL - Open in browser
|
||||||
|
Select text - Auto-copy to clipboard
|
||||||
"""
|
"""
|
||||||
alert.alertStyle = .informational
|
alert.alertStyle = .informational
|
||||||
alert.addButton(withTitle: "OK")
|
alert.addButton(withTitle: "OK")
|
||||||
@@ -602,6 +612,7 @@ class MainWindowController: NSWindowController {
|
|||||||
splitView?.isVertical = true // Side-by-side (vertical divider)
|
splitView?.isVertical = true // Side-by-side (vertical divider)
|
||||||
splitView?.dividerStyle = .thin
|
splitView?.dividerStyle = .thin
|
||||||
splitView?.autoresizingMask = [.width, .height]
|
splitView?.autoresizingMask = [.width, .height]
|
||||||
|
splitView?.delegate = self
|
||||||
|
|
||||||
// Move current session to split view with close button
|
// Move current session to split view with close button
|
||||||
currentSession.scrollView.removeFromSuperview()
|
currentSession.scrollView.removeFromSuperview()
|
||||||
@@ -646,6 +657,7 @@ class MainWindowController: NSWindowController {
|
|||||||
splitView?.isVertical = false // Top-bottom (horizontal divider)
|
splitView?.isVertical = false // Top-bottom (horizontal divider)
|
||||||
splitView?.dividerStyle = .thin
|
splitView?.dividerStyle = .thin
|
||||||
splitView?.autoresizingMask = [.width, .height]
|
splitView?.autoresizingMask = [.width, .height]
|
||||||
|
splitView?.delegate = self
|
||||||
|
|
||||||
// Move current session to split view with close button
|
// Move current session to split view with close button
|
||||||
currentSession.scrollView.removeFromSuperview()
|
currentSession.scrollView.removeFromSuperview()
|
||||||
@@ -759,6 +771,53 @@ class MainWindowController: NSWindowController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func toggleZoomPane() {
|
||||||
|
if zoomedPane != nil {
|
||||||
|
// Unzoom - restore split view
|
||||||
|
unzoomPane()
|
||||||
|
} else {
|
||||||
|
// Zoom current pane
|
||||||
|
zoomPane()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func zoomPane() {
|
||||||
|
guard panes.count > 1 else { return }
|
||||||
|
|
||||||
|
// Save current split view state
|
||||||
|
preZoomSplitView = splitView
|
||||||
|
zoomedPane = panes[activePaneIndex]
|
||||||
|
|
||||||
|
// Remove split view
|
||||||
|
splitView?.removeFromSuperview()
|
||||||
|
|
||||||
|
// Show only the active pane
|
||||||
|
if let zoomed = zoomedPane {
|
||||||
|
zoomed.scrollView.removeFromSuperview()
|
||||||
|
zoomed.scrollView.frame = contentView.bounds
|
||||||
|
contentView.addSubview(zoomed.scrollView)
|
||||||
|
zoomed.textView.window?.makeFirstResponder(zoomed.textView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func unzoomPane() {
|
||||||
|
guard let zoomed = zoomedPane, let savedSplit = preZoomSplitView else { return }
|
||||||
|
|
||||||
|
// Remove zoomed pane
|
||||||
|
zoomed.scrollView.removeFromSuperview()
|
||||||
|
|
||||||
|
// Restore split view
|
||||||
|
contentView.addSubview(savedSplit)
|
||||||
|
savedSplit.frame = contentView.bounds
|
||||||
|
|
||||||
|
// Clear zoom state
|
||||||
|
zoomedPane = nil
|
||||||
|
preZoomSplitView = nil
|
||||||
|
|
||||||
|
// Focus the previously zoomed pane
|
||||||
|
zoomed.textView.window?.makeFirstResponder(zoomed.textView)
|
||||||
|
}
|
||||||
|
|
||||||
@objc func clearHistory() {
|
@objc func clearHistory() {
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
alert.messageText = "Clear Command History?"
|
alert.messageText = "Clear Command History?"
|
||||||
@@ -813,3 +872,19 @@ class MainWindowController: NSWindowController {
|
|||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - NSSplitView Delegate
|
||||||
|
extension MainWindowController: NSSplitViewDelegate {
|
||||||
|
func splitView(_ splitView: NSSplitView, canCollapseSubview subview: NSView) -> Bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitView(_ splitView: NSSplitView, constrainMinCoordinate proposedMinimumPosition: CGFloat, ofSubviewAt dividerIndex: Int) -> CGFloat {
|
||||||
|
return 100 // Minimum pane size
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitView(_ splitView: NSSplitView, constrainMaxCoordinate proposedMaximumPosition: CGFloat, ofSubviewAt dividerIndex: Int) -> CGFloat {
|
||||||
|
let totalSize = splitView.isVertical ? splitView.bounds.width : splitView.bounds.height
|
||||||
|
return totalSize - 100 // Minimum pane size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class TerminalSettings {
|
|||||||
var maxHistorySize: Int = 1000
|
var maxHistorySize: Int = 1000
|
||||||
var startupCommands: [String] = []
|
var startupCommands: [String] = []
|
||||||
var commandAliases: [String: String] = [:] // alias -> command
|
var commandAliases: [String: String] = [:] // alias -> command
|
||||||
|
var copyOnSelect: Bool = true
|
||||||
|
var visualBell: Bool = true
|
||||||
|
|
||||||
enum AIProvider: String, CaseIterable {
|
enum AIProvider: String, CaseIterable {
|
||||||
case none = "None"
|
case none = "None"
|
||||||
|
|||||||
@@ -45,6 +45,32 @@ class TerminalSession: NSObject, NSTextViewDelegate, NSMenuDelegate {
|
|||||||
|
|
||||||
// Setup suggestion label
|
// Setup suggestion label
|
||||||
setupSuggestionLabel()
|
setupSuggestionLabel()
|
||||||
|
|
||||||
|
// Setup middle-click paste
|
||||||
|
setupMiddleClickPaste()
|
||||||
|
|
||||||
|
// Setup clickable URLs
|
||||||
|
setupClickableURLs()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupMiddleClickPaste() {
|
||||||
|
let clickGesture = NSClickGestureRecognizer(target: self, action: #selector(handleMiddleClick(_:)))
|
||||||
|
clickGesture.buttonMask = 0x4 // Middle button
|
||||||
|
textView.addGestureRecognizer(clickGesture)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleMiddleClick(_ gesture: NSClickGestureRecognizer) {
|
||||||
|
if let pasteString = NSPasteboard.general.string(forType: .string) {
|
||||||
|
textView.insertText(pasteString, replacementRange: textView.selectedRange())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupClickableURLs() {
|
||||||
|
textView.isAutomaticLinkDetectionEnabled = true
|
||||||
|
textView.linkTextAttributes = [
|
||||||
|
.foregroundColor: NSColor.systemBlue,
|
||||||
|
.underlineStyle: NSUnderlineStyle.single.rawValue
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupContextMenu() {
|
private func setupContextMenu() {
|
||||||
@@ -362,6 +388,13 @@ class TerminalSession: NSObject, NSTextViewDelegate, NSMenuDelegate {
|
|||||||
if selectedRange.location < promptPosition && selectedRange.length == 0 {
|
if selectedRange.location < promptPosition && selectedRange.length == 0 {
|
||||||
textView.setSelectedRange(NSRange(location: promptPosition, length: 0))
|
textView.setSelectedRange(NSRange(location: promptPosition, length: 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Copy on select
|
||||||
|
if TerminalSettings.shared.copyOnSelect && selectedRange.length > 0 {
|
||||||
|
let selectedText = (textView.string as NSString).substring(with: selectedRange)
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(selectedText, forType: .string)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getCurrentCommand() -> String {
|
private func getCurrentCommand() -> String {
|
||||||
@@ -555,12 +588,34 @@ class TerminalSession: NSObject, NSTextViewDelegate, NSMenuDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Visual bell on command completion
|
||||||
|
if TerminalSettings.shared.visualBell {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||||
|
self?.flashVisualBell()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Show prompt after a delay
|
// Show prompt after a delay
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||||
self?.showPrompt()
|
self?.showPrompt()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func flashVisualBell() {
|
||||||
|
guard let layer = textView.layer else { return }
|
||||||
|
let originalBg = layer.backgroundColor
|
||||||
|
|
||||||
|
NSAnimationContext.runAnimationGroup({ context in
|
||||||
|
context.duration = 0.1
|
||||||
|
layer.backgroundColor = NSColor.white.withAlphaComponent(0.1).cgColor
|
||||||
|
}, completionHandler: {
|
||||||
|
NSAnimationContext.runAnimationGroup({ context in
|
||||||
|
context.duration = 0.2
|
||||||
|
layer.backgroundColor = originalBg
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private func showHelp() {
|
private func showHelp() {
|
||||||
appendText("""
|
appendText("""
|
||||||
Enhanced Terminal Commands:
|
Enhanced Terminal Commands:
|
||||||
|
|||||||
Reference in New Issue
Block a user