mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-09-05 19:08:17 +00:00
macos: GetTerminalDetails intent
This commit is contained in:
@@ -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 */,
|
||||
|
@@ -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"),
|
||||
]
|
||||
}
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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 {
|
||||
|
Reference in New Issue
Block a user