macOS: Focus Terminal App Intent (#8961)

Closes #8791 
Discussion: #8657 

### Summary
This adds the FocusTerminalIntent App Intent and related function
(focusSurface), allows external tools (such as Shortcuts/Siri) to
programmatically move focus to a specific terminal window or tab.

### Verification
This functionality has been tested across following scenarios,
confirming correct focus behavior for:

- Split Window
- Tab Group
- Quick Terminal

### Note
It is not supported to move focus to a split that is hidden by a zoomed
split. The same applies to the CloseTerminalIntent.

### AI Disclosure
This pull request was made with assistance from Claude Code.
I reviewed all AI-generated code and wrote the final output manually.
pull/8938/head
Mitchell Hashimoto 2025-09-30 06:55:49 -07:00 committed by GitHub
commit 0cc3728803
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 67 additions and 0 deletions

View File

@ -75,6 +75,7 @@
"Features/App Intents/CommandPaletteIntent.swift",
"Features/App Intents/Entities/CommandEntity.swift",
"Features/App Intents/Entities/TerminalEntity.swift",
"Features/App Intents/FocusTerminalIntent.swift",
"Features/App Intents/GetTerminalDetailsIntent.swift",
"Features/App Intents/GhosttyIntentError.swift",
"Features/App Intents/InputIntent.swift",

View File

@ -0,0 +1,35 @@
import AppKit
import AppIntents
import GhosttyKit
struct FocusTerminalIntent: AppIntent {
static var title: LocalizedStringResource = "Focus Terminal"
static var description = IntentDescription("Move focus to an existing terminal.")
@Parameter(
title: "Terminal",
description: "The terminal to focus.",
)
var terminal: TerminalEntity
@available(macOS 26.0, *)
static var supportedModes: IntentModes = .background
@MainActor
func perform() async throws -> some IntentResult {
guard await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
guard let surfaceView = terminal.surfaceView else {
throw GhosttyIntentError.surfaceNotFound
}
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else {
return .result()
}
controller.focusSurface(surfaceView)
return .result()
}
}

View File

@ -247,6 +247,22 @@ class QuickTerminalController: BaseTerminalController {
// MARK: Base Controller Overrides
override func focusSurface(_ view: Ghostty.SurfaceView) {
if visible {
// If we're visible, we just focus the surface as normal.
super.focusSurface(view)
return
}
// Check if target surface belongs to this quick terminal
guard surfaceTree.contains(view) else { return }
// Set the target surface as focused
DispatchQueue.main.async {
Ghostty.moveFocus(to: view)
}
// Animation completion handler will handle window/app activation
animateIn()
}
override func surfaceTreeDidChange(from: SplitTree<Ghostty.SurfaceView>, to: SplitTree<Ghostty.SurfaceView>) {
super.surfaceTreeDidChange(from: from, to: to)

View File

@ -233,6 +233,21 @@ class BaseTerminalController: NSWindowController,
return newView
}
/// Move focus to a surface view.
func focusSurface(_ view: Ghostty.SurfaceView) {
// Check if target surface is in our tree
guard surfaceTree.contains(view) else { return }
// Move focus to the target surface and activate the window/app
DispatchQueue.main.async {
Ghostty.moveFocus(to: view)
view.window?.makeKeyAndOrderFront(nil)
if !NSApp.isActive {
NSApp.activate(ignoringOtherApps: true)
}
}
}
/// Called when the surfaceTree variable changed.
///
/// Subclasses should call super first.