macos: GetTerminalDetails intent

This commit is contained in:
Mitchell Hashimoto
2025-06-18 10:39:15 -07:00
parent 2aa731a64e
commit 93f0ee2089
5 changed files with 136 additions and 4 deletions

View File

@@ -123,6 +123,7 @@
A5E4082E2E0237460035FEAC /* NewTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */; };
A5E408302E0271320035FEAC /* GhosttyIntentError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */; };
A5E408322E02FEDF0035FEAC /* TerminalEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */; };
A5E408342E0320140035FEAC /* GetTerminalDetailsIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */; };
A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; };
AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */; };
C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; };
@@ -246,6 +247,7 @@
A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTerminalIntent.swift; sourceTree = "<group>"; };
A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyIntentError.swift; sourceTree = "<group>"; };
A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalEntity.swift; sourceTree = "<group>"; };
A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetTerminalDetailsIntent.swift; sourceTree = "<group>"; };
A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = "<group>"; };
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = "<group>"; };
@@ -610,6 +612,7 @@
children = (
A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */,
A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */,
A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */,
A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */,
);
path = "App Intents";
@@ -754,6 +757,7 @@
C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */,
A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */,
A5CF66D42D289CEE00139794 /* NSEvent+Extension.swift in Sources */,
A5E408342E0320140035FEAC /* GetTerminalDetailsIntent.swift in Sources */,
A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */,
A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */,
A5E408322E02FEDF0035FEAC /* TerminalEntity.swift in Sources */,

View File

@@ -0,0 +1,65 @@
import AppKit
import AppIntents
/// App intent that retrieves details about a specific terminal.
struct GetTerminalDetailsIntent: AppIntent {
static var title: LocalizedStringResource = "Get Details of Terminal"
@Parameter(
title: "Detail",
description: "The detail to extract about a terminal."
)
var detail: TerminalDetail
@Parameter(
title: "Terminal",
description: "The terminal to extract information about."
)
var terminal: TerminalEntity
@available(macOS 26.0, *)
static var supportedModes: IntentModes = .background
static var parameterSummary: some ParameterSummary {
Summary("Get \(\.$detail) from \(\.$terminal)")
}
@MainActor
func perform() async throws -> some IntentResult & ReturnsValue<String?> {
switch detail {
case .title: return .result(value: terminal.title)
case .workingDirectory: return .result(value: terminal.workingDirectory)
case .allContents:
guard let view = terminal.surfaceView else { return .result(value: nil) }
return .result(value: view.cachedScreenContents.get())
case .selectedText:
guard let view = terminal.surfaceView else { return .result(value: nil) }
return .result(value: view.accessibilitySelectedText())
case .visibleText:
guard let view = terminal.surfaceView else { return .result(value: nil) }
return .result(value: view.cachedVisibleContents.get())
}
}
}
// MARK: TerminalDetail
enum TerminalDetail: String {
case title
case workingDirectory
case allContents
case selectedText
case visibleText
}
extension TerminalDetail: AppEnum {
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Terminal Detail")
static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [
.title: .init(title: "Title"),
.workingDirectory: .init(title: "Working Directory"),
.allContents: .init(title: "Full Contents"),
.selectedText: .init(title: "Selected Text"),
.visibleText: .init(title: "Visible Text"),
]
}

View File

@@ -1,5 +1,6 @@
import AppKit
import AppIntents
import SwiftUI
struct TerminalEntity: AppEntity {
let id: UUID
@@ -7,12 +8,31 @@ struct TerminalEntity: AppEntity {
@Property(title: "Title")
var title: String
@Property(title: "Working Directory")
var workingDirectory: String?
var screenshot: Image?
static var typeDisplayRepresentation: TypeDisplayRepresentation {
TypeDisplayRepresentation(name: "Terminal")
}
@MainActor
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(title)")
var rep = DisplayRepresentation(title: "\(title)")
if let screenshot,
let nsImage = ImageRenderer(content: screenshot).nsImage,
let data = nsImage.tiffRepresentation {
rep.image = .init(data: data)
}
return rep
}
/// Returns the view associated with this entity. This may no longer exist.
@MainActor
var surfaceView: Ghostty.SurfaceView? {
Self.defaultQuery.all.first { $0.uuid == self.id }
}
static var defaultQuery = TerminalQuery()
@@ -20,6 +40,8 @@ struct TerminalEntity: AppEntity {
init(_ view: Ghostty.SurfaceView) {
self.id = view.uuid
self.title = view.title
self.workingDirectory = view.pwd
self.screenshot = view.screenshot()
}
}
@@ -53,7 +75,7 @@ struct TerminalQuery: EntityStringQuery, EnumerableEntityQuery {
}
@MainActor
private var all: [Ghostty.SurfaceView] {
var all: [Ghostty.SurfaceView] {
// Find all of our terminal windows (includes quick terminal)
let controllers = NSApp.windows.compactMap {
$0.windowController as? BaseTerminalController

View File

@@ -139,7 +139,8 @@ extension Ghostty {
private var titleFromTerminal: String?
// The cached contents of the screen.
private var cachedScreenContents: CachedValue<String>
private(set) var cachedScreenContents: CachedValue<String>
private(set) var cachedVisibleContents: CachedValue<String>
/// Event monitor (see individual events for why)
private var eventMonitor: Any? = nil
@@ -166,6 +167,7 @@ extension Ghostty {
// it back up later so we can reference `self`. This is a hack we should
// fix at some point.
self.cachedScreenContents = .init(duration: .milliseconds(500)) { "" }
self.cachedVisibleContents = self.cachedScreenContents
// Initialize with some default frame size. The important thing is that this
// is non-zero so that our layer bounds are non-zero so that our renderer
@@ -193,6 +195,26 @@ extension Ghostty {
defer { ghostty_surface_free_text(surface, &text) }
return String(cString: text.text)
}
cachedVisibleContents = .init(duration: .milliseconds(500)) { [weak self] in
guard let self else { return "" }
guard let surface = self.surface else { return "" }
var text = ghostty_text_s()
let sel = ghostty_selection_s(
top_left: ghostty_point_s(
tag: GHOSTTY_POINT_VIEWPORT,
coord: GHOSTTY_POINT_COORD_TOP_LEFT,
x: 0,
y: 0),
bottom_right: ghostty_point_s(
tag: GHOSTTY_POINT_VIEWPORT,
coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT,
x: 0,
y: 0),
rectangle: false)
guard ghostty_surface_read_text(surface, sel, &text) else { return "" }
defer { ghostty_surface_free_text(surface, &text) }
return String(cString: text.text)
}
// Set a timer to show the ghost emoji after 500ms if no title is set
titleFallbackTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
@@ -1979,7 +2001,7 @@ extension Ghostty.SurfaceView {
/// Caches a value for some period of time, evicting it automatically when that time expires.
/// We use this to cache our surface content. This probably should be extracted some day
/// to a more generic helper.
fileprivate class CachedValue<T> {
class CachedValue<T> {
private var value: T?
private let fetch: () -> T
private let duration: Duration

View File

@@ -1,4 +1,5 @@
import AppKit
import SwiftUI
extension NSView {
/// Returns true if this view is currently in the responder chain
@@ -15,6 +16,24 @@ extension NSView {
}
}
// MARK: Screenshot
extension NSView {
/// Take a screenshot of just this view.
func screenshot() -> NSImage? {
guard let bitmapRep = bitmapImageRepForCachingDisplay(in: bounds) else { return nil }
cacheDisplay(in: bounds, to: bitmapRep)
let image = NSImage(size: bounds.size)
image.addRepresentation(bitmapRep)
return image
}
func screenshot() -> Image? {
guard let nsImage: NSImage = self.screenshot() else { return nil }
return Image(nsImage: nsImage)
}
}
// MARK: View Traversal and Search
extension NSView {