Files
Termind/Frameworks/TermindRemoteInfrastructure/Agent/AgentPaneTools.swift
T

161 lines
7.2 KiB
Swift

import AgentKit
import Foundation
import TermindApplication
nonisolated public struct ListPanesTool: AgentToolDefinition, AgentToolSchemaBuilding {
public static let presenter = AgentToolDetailPresenter(id: "builtin.list_panes", present: present)
public let panes: AgentPaneAccess
public init(panes: AgentPaneAccess) { self.panes = panes }
public static func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
let rows =
input.result.objectValue?["panes"]?.arrayValue?.compactMap { value -> AgentToolDetail.ListRow? in
guard let pane = value.objectValue,
let title = pane["title"]?.stringValue,
let id = pane["pane_id"]?.stringValue
else { return nil }
var badges: [String] = []
if pane["is_active"]?.boolValue == true {
badges.append(AgentPaneToolCopy.localized("Active in window", locale: input.locale))
}
if pane["is_current"]?.boolValue == true {
badges.append(AgentPaneToolCopy.localized("Current in Assistant window", locale: input.locale))
}
return .init(title: title, subtitle: id, badges: badges, symbol: "terminal")
} ?? []
return [
.list(
.init(
rows: rows, emptyMessage: AgentPaneToolCopy.localized("No open terminals", locale: input.locale)))
]
}
public var descriptor: AgentToolDescriptor {
Self.descriptor(
"list_panes",
"List open terminal panes on this device, including hidden panes, without reading their text or opening a connection. is_active is per window; is_current identifies this run's window default.",
properties: [:], required: [], target: .local, approvalPolicy: .approve,
concurrency: .parallel,
presentation: .init(symbol: "rectangle.split.2x1", activity: .label("terminals"), output: .json)
)
}
public func execute(_ invocation: AgentToolInvocation, context: AgentToolExecutionContext) async throws
-> AgentToolResult
{
let rows = await panes.list().map { pane in
AgentJSONValue.object([
"pane_id": .string(pane.id.uuidString),
"title": .string(pane.title),
"host_id": pane.hostID.map { .string($0.uuidString) } ?? .null,
"workspace": .string(pane.workspace),
"window": .string(pane.window),
"is_active": .bool(pane.isActive),
"is_current": .bool(pane.isCurrent),
])
}
return Self.result(invocation, .object(["panes": .array(rows)]))
}
}
nonisolated public struct ReadPaneTool: AgentToolDefinition, AgentToolSchemaBuilding {
public static let presenter = AgentToolDetailPresenter(id: "builtin.read_pane", present: present)
public let panes: AgentPaneAccess
public init(panes: AgentPaneAccess) { self.panes = panes }
public static func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
let object = input.result.objectValue ?? [:]
var items: [AgentToolDetail.Item] = []
if let title = object["title"]?.stringValue {
items.append(
.field(
.init(
label: AgentPaneToolCopy.localized("Terminal name", locale: input.locale), value: title)))
}
if let id = object["pane_id"]?.stringValue ?? input.arguments["pane_id"]?.stringValue {
items.append(
.field(
.init(
label: AgentPaneToolCopy.localized("Pane ID", locale: input.locale),
value: id, isMonospaced: true)))
}
return items
}
public var descriptor: AgentToolDescriptor {
Self.descriptor(
"read_pane",
"Read the visible viewport of an open terminal pane on this device without changing focus, scroll position, selection, or clipboard. With no pane_id, use this run's window current terminal at call time. Use list_panes to find an ID when no current terminal exists.",
properties: ["pane_id": Self.string(max: 64)], required: [],
target: .local, approvalPolicy: .approve, concurrency: .parallel,
presentation: .init(symbol: "terminal", activity: .label("terminal"), output: .json)
)
}
public func execute(_ invocation: AgentToolInvocation, context: AgentToolExecutionContext) async throws
-> AgentToolResult
{
let arguments = try Arguments(invocation)
let id: UUID
if let supplied = arguments.optionalString("pane_id") {
guard let parsed = UUID(uuidString: supplied) else {
throw AgentToolError.invalidArguments(
"pane_id is not a valid UUID. Use list_panes for an open pane ID.")
}
id = parsed
} else {
guard let current = await panes.list().first(where: \.isCurrent) else {
throw AgentToolError.invalidArguments(
"No current terminal in this Assistant window. Call list_panes and pass pane_id."
)
}
id = current.id
}
let read = await panes.read(id)
switch read {
case .closed:
throw AgentToolError.invalidArguments("Pane is not open. Call list_panes for a current pane ID.")
case .notConnected:
throw AgentToolError.invalidArguments("The pane is not connected. Call list_panes for a readable pane ID.")
case .noSurface:
throw AgentToolError.invalidArguments("The pane's terminal surface is not established.")
case .text(let pane, let text):
let (limited, truncated) = Self.limitUTF8(text)
return Self.result(
invocation,
.object([
"pane_id": .string(pane.id.uuidString),
"title": .string(pane.title),
"host_id": pane.hostID.map { .string($0.uuidString) } ?? .null,
"text": .string(limited),
"truncated": .bool(truncated),
]), truncated: truncated)
}
}
public static func limitUTF8(_ text: String, maxBytes: Int = 65_536) -> (String, Bool) {
guard text.utf8.count > maxBytes else { return (text, false) }
var bytes = 0
var end = text.startIndex
while end < text.endIndex {
let next = text.index(after: end)
let size = text[end..<next].utf8.count
if bytes + size > maxBytes { break }
bytes += size
end = next
}
return (String(text[..<end]), true)
}
}
nonisolated private enum AgentPaneToolCopy {
// These keys are owned by shared AppStrings and the app's manually managed catalog.
// This UI-neutral presenter cannot import Presentation; the app projection
// uses AppStrings directly and this fallback resolves the same catalog keys.
static func localized(_ key: String.LocalizationValue, locale: Locale) -> String {
AppLocalization.string(key, locale: locale)
}
}