207 lines
11 KiB
Swift
207 lines
11 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import AgentKit
|
|
import Foundation
|
|
import TermindApplication
|
|
|
|
/// Localized SSH projections composed into each app's trusted presenter registry.
|
|
nonisolated public enum SSHExecToolPresentation {
|
|
private static let names: Set<String> = [
|
|
TermindToolName.sshExecRun, TermindToolName.sshExecOutput,
|
|
TermindToolName.sshExecStdin, TermindToolName.sshExecCancel, TermindToolName.sshExecList,
|
|
]
|
|
|
|
/// Replace only the SSH result projections. Tool-owned layout, approval arguments,
|
|
/// and operation policies remain attached to the same persisted presenter IDs.
|
|
public static func registering(in registry: AgentToolPresenterRegistry) -> AgentToolPresenterRegistry {
|
|
AgentToolPresenterRegistry(
|
|
registry.ids.compactMap { id in
|
|
guard let original = registry.presenter(id: id) else { return nil }
|
|
guard let name = names.first(where: { id == "builtin.\($0)" }) else { return original }
|
|
return AgentToolDetailPresenter(
|
|
id: id, layout: original.layout,
|
|
present: { present($0, name: name) },
|
|
presentArguments: {
|
|
original.presentArguments($0)
|
|
?? AgentToolDetailFormatting.genericItems(.object($0.arguments), locale: $0.locale)
|
|
},
|
|
operation: { original.resolveOperation(arguments: $0) })
|
|
})
|
|
}
|
|
|
|
static let profiles: [String: AgentToolPresentationProfile] = Dictionary(
|
|
uniqueKeysWithValues: names.map { name in
|
|
var profile = AgentToolPresentationProfile()
|
|
profile.phaseSummary = { phase, activity, target, locale in
|
|
phaseSummary(phase, activity: activity, target: target, locale: locale)
|
|
}
|
|
profile.detail = { card, _, locale in
|
|
guard card.result == nil else { return .useDefault }
|
|
return .content(
|
|
.init(
|
|
items: present(
|
|
.init(
|
|
result: .object([:]), arguments: card.invocation.call.arguments.objectValue ?? [:],
|
|
locale: locale),
|
|
name: name, pending: true)))
|
|
}
|
|
return ("builtin.\(name)", profile)
|
|
})
|
|
|
|
private static func present(
|
|
_ input: AgentToolDetailInput, name: String, pending: Bool = false
|
|
) -> [AgentToolDetail.Item] {
|
|
guard let object = input.result.objectValue else {
|
|
return AgentToolDetailFormatting.genericItems(input.result, locale: input.locale)
|
|
}
|
|
let args = input.arguments
|
|
typealias S = AppStrings.SSHExec
|
|
func text(_ resource: LocalizedStringResource) -> String {
|
|
AppLocalization.resolve(resource, locale: input.locale)
|
|
}
|
|
func field(_ title: LocalizedStringResource, _ value: String = "", mono: Bool = false) -> AgentToolDetail.Item {
|
|
.field(.init(label: text(title), value: value, isMonospaced: mono))
|
|
}
|
|
func block(
|
|
_ title: LocalizedStringResource, _ value: String, isCollapsible: Bool = true
|
|
) -> AgentToolDetail.Item {
|
|
.text(.init(title: text(title), text: value, style: .monospaced, isCollapsible: isCollapsible))
|
|
}
|
|
func status(_ value: String) -> String {
|
|
switch value {
|
|
case "written": text(S.written)
|
|
case "closed": text(S.stdinClosed)
|
|
case "released": text(S.released)
|
|
default: AgentToolDetailFormatting.statusLabel(value, locale: input.locale)
|
|
}
|
|
}
|
|
if let commands = object["commands"]?.arrayValue {
|
|
let rows = commands.compactMap { value -> AgentToolDetail.ListRow? in
|
|
guard let entry = value.objectValue else { return nil }
|
|
let subtitle = [entry["host_id"]?.stringValue, entry["handle"]?.stringValue].compactMap { $0 }
|
|
return .init(
|
|
title: entry["command"]?.stringValue ?? "", subtitle: subtitle.joined(separator: " · "),
|
|
badges: [status(entry["status"]?.stringValue ?? "")], symbol: "terminal")
|
|
}
|
|
return rows.isEmpty ? [.message(text(S.noCommands), .secondary)] : [.list(.init(rows: rows))]
|
|
}
|
|
var items: [AgentToolDetail.Item] = []
|
|
if let command = args["command"]?.stringValue {
|
|
items.append(block(S.command, command, isCollapsible: false))
|
|
}
|
|
if name == TermindToolName.sshExecRun {
|
|
items.append(field(args["run_in_background"]?.boolValue == true ? S.background : S.foreground))
|
|
}
|
|
if let handle = object["handle"]?.stringValue ?? args["handle"]?.stringValue {
|
|
items.append(field(S.handle, handle, mono: true))
|
|
}
|
|
if let value = object["status"]?.stringValue { items.append(field(AppStrings.Common.status, status(value))) }
|
|
if let code = object["exit_code"]?.integerValue { items.append(field(S.exitCode, String(code), mono: true)) }
|
|
if let signal = object["signal"]?.stringValue { items.append(field(S.signal, signal, mono: true)) }
|
|
if let type = args["type"]?.stringValue {
|
|
items.append(field(S.input, text(type == "secret" ? S.secret : type == "eof" ? S.eof : S.text)))
|
|
if type == "text", let input = args["stdin"]?.stringValue { items.append(block(S.input, input)) }
|
|
}
|
|
if let count = object["bytes_written"]?.integerValue {
|
|
items.append(field(S.written, DataSizeFormat.bytes(count), mono: true))
|
|
}
|
|
if let closed = object["stdin_closed"]?.boolValue {
|
|
items.append(field(closed ? S.stdinClosed : S.stdinOpen))
|
|
}
|
|
if let error = object["error"]?.stringValue { items.append(.message(error, .failure)) }
|
|
if object["remote_termination_confirmed"]?.boolValue == false {
|
|
items.append(.message(text(S.cancellationNotice), .warning))
|
|
}
|
|
let output = object["output"]?.stringValue ?? ""
|
|
if !output.isEmpty { items.append(block(S.output, output)) }
|
|
if [TermindToolName.sshExecRun, TermindToolName.sshExecOutput].contains(name), output.isEmpty {
|
|
items.append(pending ? .message(text(S.pending), .secondary) : field(S.noOutput))
|
|
}
|
|
if let note = object["note"]?.stringValue { items.append(.message(note, .warning)) }
|
|
return items
|
|
}
|
|
|
|
private static func phaseSummary(
|
|
_ kind: AgentToolDescriptor.Presentation.PhaseKind,
|
|
activity: String, target: String?, locale: Locale
|
|
) -> String? {
|
|
// Commands already carry their own shell punctuation. English reads
|
|
// more naturally without another pair of prose quotes around the
|
|
// entire command, while Chinese and Japanese use quotation marks to separate the
|
|
// command from the surrounding sentence.
|
|
if !AgentToolPresentation().usesLocalizedActionTemplates(locale) {
|
|
return switch kind {
|
|
case .sshPreparingCommand:
|
|
target.map { "Preparing to run \(activity) on \($0)" }
|
|
?? "Preparing to run \(activity)"
|
|
case .sshWaitingForCommand:
|
|
target.map { "Waiting to run \(activity) on \($0)" }
|
|
?? "Waiting to run \(activity)"
|
|
case .sshWaitingForAuthentication:
|
|
target.map { "Waiting to authenticate with \($0)" }
|
|
?? "Waiting for authentication"
|
|
case .sshConnecting:
|
|
target.map { "Connecting to \($0)" } ?? "Connecting"
|
|
case .sshRunningCommand:
|
|
target.map { "Running \(activity) on \($0)" } ?? "Running \(activity)"
|
|
case .sshCompletedCommand:
|
|
target.map { "Ran \(activity) on \($0)" } ?? "Ran \(activity)"
|
|
case .sshDeniedCommand:
|
|
target.map { "Did not run \(activity) on \($0)" }
|
|
?? "Did not run \(activity)"
|
|
case .sshFailedCommand:
|
|
target.map { "Failed to run \(activity) on \($0)" }
|
|
?? "Failed to run \(activity)"
|
|
case .sshInterruptedCommand:
|
|
target.map { "Stopped waiting for \(activity) on \($0)" }
|
|
?? "Stopped waiting for \(activity)"
|
|
default: nil
|
|
}
|
|
}
|
|
switch kind {
|
|
case .sshPreparingCommand:
|
|
return target.map {
|
|
AppLocalization.resolve("Preparing to run “\(activity)” on \($0)", locale: locale)
|
|
} ?? AppLocalization.resolve("Preparing to run “\(activity)”", locale: locale)
|
|
case .sshWaitingForCommand:
|
|
return target.map {
|
|
AppLocalization.resolve("Waiting to run “\(activity)” on \($0)", locale: locale)
|
|
} ?? AppLocalization.resolve("Waiting to run “\(activity)”", locale: locale)
|
|
case .sshWaitingForAuthentication:
|
|
return target.map {
|
|
AppLocalization.resolve("Waiting to authenticate with \($0)", locale: locale)
|
|
} ?? AppLocalization.resolve("Waiting for authentication", locale: locale)
|
|
case .sshConnecting:
|
|
return target.map {
|
|
AppLocalization.resolve("Connecting to \($0)", locale: locale)
|
|
} ?? AppLocalization.resolve(AppStrings.Connections.connecting, locale: locale)
|
|
case .sshRunningCommand:
|
|
return target.map {
|
|
AppLocalization.resolve("Running “\(activity)” on \($0)", locale: locale)
|
|
} ?? AppLocalization.resolve("Running “\(activity)”", locale: locale)
|
|
case .sshCompletedCommand:
|
|
return target.map {
|
|
AppLocalization.resolve("Ran “\(activity)” on \($0)", locale: locale)
|
|
} ?? AppLocalization.resolve("Ran “\(activity)”", locale: locale)
|
|
case .sshDeniedCommand:
|
|
return target.map {
|
|
AppLocalization.resolve("Did not run “\(activity)” on \($0)", locale: locale)
|
|
} ?? AppLocalization.resolve("Did not run “\(activity)”", locale: locale)
|
|
case .sshFailedCommand:
|
|
return target.map {
|
|
AppLocalization.resolve("Failed to run “\(activity)” on \($0)", locale: locale)
|
|
} ?? AppLocalization.resolve("Failed to run “\(activity)”", locale: locale)
|
|
case .sshInterruptedCommand:
|
|
return target.map {
|
|
AppLocalization.resolve("Stopped waiting for “\(activity)” on \($0)", locale: locale)
|
|
} ?? AppLocalization.resolve("Stopped waiting for “\(activity)”", locale: locale)
|
|
// Open vocabulary. A phase this app has no wording for falls back to the
|
|
// ordinary action lifecycle, which is what the caller does with `nil`.
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
}
|