refactor agent tool detail presentation
This commit is contained in:
1 parent
39d1285cf8
commit
a2e64cd6fb
14 files changed
+1350
-770
No files matched your search
@@ -225,7 +225,8 @@ nonisolated extension AgentBuiltInTools {
|
||||
}
|
||||
return .init(
|
||||
symbol: symbol, activity: .semanticArgument(key: "path", fallback: .remoteFile),
|
||||
output: .automatic, actionKind: actionKind, includesTarget: true
|
||||
output: .automatic, detailPresenterID: "builtin.\(name)",
|
||||
actionKind: actionKind, includesTarget: true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -304,3 +305,202 @@ nonisolated extension AgentBuiltInTools {
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tool-owned transcript details
|
||||
|
||||
nonisolated extension AgentBuiltInTools {
|
||||
static let sftpListDetailPresenter = AgentToolDetailPresenter(
|
||||
id: "builtin.sftp_list", present: presentRemoteFileList
|
||||
)
|
||||
static let sftpStatDetailPresenter = AgentToolDetailPresenter(
|
||||
id: "builtin.sftp_stat", present: presentRemoteFileStat
|
||||
)
|
||||
static let sftpReadDetailPresenter = AgentToolDetailPresenter(
|
||||
id: "builtin.sftp_read", present: presentRemoteFileContent
|
||||
)
|
||||
static let sftpWriteDetailPresenter = AgentToolDetailPresenter(
|
||||
id: "builtin.sftp_write", present: presentSFTPWrite
|
||||
)
|
||||
static let sftpMkdirDetailPresenter = AgentToolDetailPresenter(
|
||||
id: "builtin.sftp_mkdir", present: presentSFTPMkdir
|
||||
)
|
||||
static let sftpMoveDetailPresenter = AgentToolDetailPresenter(
|
||||
id: "builtin.sftp_move", present: presentSFTPMove
|
||||
)
|
||||
static let sftpChmodDetailPresenter = AgentToolDetailPresenter(
|
||||
id: "builtin.sftp_chmod", present: presentSFTPChmod
|
||||
)
|
||||
static let sftpDeleteDetailPresenter = AgentToolDetailPresenter(
|
||||
id: "builtin.sftp_delete", present: presentSFTPDelete
|
||||
)
|
||||
|
||||
private static func presentRemoteFileList(
|
||||
_ input: AgentToolDetailInput
|
||||
) -> [AgentToolDetail.Item] {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
guard let values = input.result.arrayValue else {
|
||||
return F.genericItems(input.result, locale: input.locale)
|
||||
}
|
||||
let rows = values.compactMap { remoteFileDetailRow($0, locale: input.locale) }
|
||||
return [
|
||||
.message(String(localized: "\(rows.count) items", locale: input.locale), .secondary),
|
||||
.list(.init(
|
||||
title: nil, rows: rows,
|
||||
emptyMessage: F.localized("Directory is empty", locale: input.locale)
|
||||
)),
|
||||
]
|
||||
}
|
||||
|
||||
private static func remoteFileDetailRow(
|
||||
_ value: AgentJSONValue, locale: Locale
|
||||
) -> AgentToolDetail.ListRow? {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
guard let object = value.objectValue else { return nil }
|
||||
let path = object["path"]?.stringValue ?? ""
|
||||
let name = F.nonempty(object["name"]?.stringValue)
|
||||
?? F.nonempty(path.split(separator: "/").last.map(String.init))
|
||||
?? F.localized("Unnamed item", locale: locale)
|
||||
let kind = object["kind"]?.stringValue ?? ""
|
||||
var metadata = [F.kindLabel(kind, locale: locale)]
|
||||
if let bytes = object["size"]?.integerValue { metadata.append(F.byteCount(bytes)) }
|
||||
if let permissions = F.nonempty(object["permissions"]?.stringValue) {
|
||||
metadata.append(permissions)
|
||||
}
|
||||
return .init(
|
||||
title: name, subtitle: metadata.filter { !$0.isEmpty }.joined(separator: " · "),
|
||||
detail: object["modified_at"]?.stringValue.map(F.displayDate),
|
||||
symbol: kind == "directory" ? "folder" : "doc"
|
||||
)
|
||||
}
|
||||
|
||||
private static func presentRemoteFileStat(
|
||||
_ input: AgentToolDetailInput
|
||||
) -> [AgentToolDetail.Item] {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
guard let object = input.result.objectValue else {
|
||||
return F.genericItems(input.result, locale: input.locale)
|
||||
}
|
||||
var items: [AgentToolDetail.Item] = []
|
||||
F.appendField(&items, "Path", object["path"]?.stringValue, locale: input.locale, monospaced: true)
|
||||
F.appendField(
|
||||
&items, "Kind", object["kind"]?.stringValue.map {
|
||||
F.kindLabel($0, locale: input.locale)
|
||||
}, locale: input.locale
|
||||
)
|
||||
if let bytes = object["size"]?.integerValue {
|
||||
items.append(F.field("Size", F.byteCount(bytes), locale: input.locale))
|
||||
}
|
||||
F.appendField(
|
||||
&items, "Permissions", object["permissions"]?.stringValue,
|
||||
locale: input.locale, monospaced: true
|
||||
)
|
||||
F.appendField(
|
||||
&items, "Modified", object["modified_at"]?.stringValue.map(F.displayDate),
|
||||
locale: input.locale
|
||||
)
|
||||
return items
|
||||
}
|
||||
|
||||
private static func presentRemoteFileContent(
|
||||
_ input: AgentToolDetailInput
|
||||
) -> [AgentToolDetail.Item] {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
guard let object = input.result.objectValue else {
|
||||
return F.genericItems(input.result, locale: input.locale)
|
||||
}
|
||||
var items: [AgentToolDetail.Item] = []
|
||||
F.appendField(&items, "Path", object["path"]?.stringValue, locale: input.locale, monospaced: true)
|
||||
let content = object["content"]?.stringValue ?? ""
|
||||
items.append(content.isEmpty
|
||||
? .message(F.localized("Empty file", locale: input.locale), .secondary)
|
||||
: .text(.init(
|
||||
title: F.localized("Content", locale: input.locale), text: content,
|
||||
style: .monospaced
|
||||
)))
|
||||
if let bytes = object["bytes"]?.integerValue {
|
||||
items.append(F.field("Size", F.byteCount(bytes), locale: input.locale))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
private static func presentSFTPWrite(
|
||||
_ input: AgentToolDetailInput
|
||||
) -> [AgentToolDetail.Item] {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
guard let object = input.result.objectValue else {
|
||||
return F.genericItems(input.result, locale: input.locale)
|
||||
}
|
||||
var items: [AgentToolDetail.Item] = []
|
||||
F.appendField(&items, "Path", object["path"]?.stringValue, locale: input.locale, monospaced: true)
|
||||
if let content = input.arguments["content"]?.stringValue {
|
||||
items.append(F.field("Size", F.byteCount(content.utf8.count), locale: input.locale))
|
||||
}
|
||||
items.append(F.field("Result", F.localized("Written", locale: input.locale), locale: input.locale))
|
||||
return items
|
||||
}
|
||||
|
||||
private static func presentSFTPMkdir(
|
||||
_ input: AgentToolDetailInput
|
||||
) -> [AgentToolDetail.Item] {
|
||||
pathDetail(input, result: AgentToolDetailFormatting.localized(
|
||||
"Directory created", locale: input.locale
|
||||
))
|
||||
}
|
||||
|
||||
private static func pathDetail(
|
||||
_ input: AgentToolDetailInput, result: String
|
||||
) -> [AgentToolDetail.Item] {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
guard let object = input.result.objectValue else {
|
||||
return F.genericItems(input.result, locale: input.locale)
|
||||
}
|
||||
var items: [AgentToolDetail.Item] = []
|
||||
F.appendField(&items, "Path", object["path"]?.stringValue, locale: input.locale, monospaced: true)
|
||||
items.append(F.field("Result", result, locale: input.locale))
|
||||
return items
|
||||
}
|
||||
|
||||
private static func presentSFTPMove(
|
||||
_ input: AgentToolDetailInput
|
||||
) -> [AgentToolDetail.Item] {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
guard let object = input.result.objectValue else {
|
||||
return F.genericItems(input.result, locale: input.locale)
|
||||
}
|
||||
var items: [AgentToolDetail.Item] = []
|
||||
F.appendField(&items, "From", object["source"]?.stringValue, locale: input.locale, monospaced: true)
|
||||
F.appendField(&items, "To", object["destination"]?.stringValue, locale: input.locale, monospaced: true)
|
||||
return items
|
||||
}
|
||||
|
||||
private static func presentSFTPChmod(
|
||||
_ input: AgentToolDetailInput
|
||||
) -> [AgentToolDetail.Item] {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
guard let object = input.result.objectValue else {
|
||||
return F.genericItems(input.result, locale: input.locale)
|
||||
}
|
||||
var items: [AgentToolDetail.Item] = []
|
||||
F.appendField(&items, "Path", object["path"]?.stringValue, locale: input.locale, monospaced: true)
|
||||
F.appendField(&items, "Permissions", object["mode"]?.stringValue, locale: input.locale, monospaced: true)
|
||||
return items
|
||||
}
|
||||
|
||||
private static func presentSFTPDelete(
|
||||
_ input: AgentToolDetailInput
|
||||
) -> [AgentToolDetail.Item] {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
guard let object = input.result.objectValue else {
|
||||
return F.genericItems(input.result, locale: input.locale)
|
||||
}
|
||||
var items: [AgentToolDetail.Item] = []
|
||||
F.appendField(&items, "Path", object["path"]?.stringValue, locale: input.locale, monospaced: true)
|
||||
F.appendField(
|
||||
&items, "Kind", input.arguments["kind"]?.stringValue.map {
|
||||
F.kindLabel($0, locale: input.locale)
|
||||
}, locale: input.locale
|
||||
)
|
||||
items.append(F.field("Result", F.localized("Deleted", locale: input.locale), locale: input.locale))
|
||||
return items
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ nonisolated extension AgentBuiltInTools {
|
||||
concurrency: .parallel,
|
||||
presentation: .init(
|
||||
symbol: "server.rack", activity: .semanticLabel(.hosts), output: .json,
|
||||
detailPresenterID: "builtin.list_hosts",
|
||||
actionKind: .list
|
||||
)
|
||||
)) { invocation, _ in
|
||||
@@ -99,6 +100,7 @@ nonisolated extension AgentBuiltInTools {
|
||||
presentation: .init(
|
||||
symbol: "clock.arrow.circlepath",
|
||||
activity: .semanticLabel(.remoteCommand), output: .streams,
|
||||
detailPresenterID: "builtin.ssh_wait",
|
||||
actionKind: .wait
|
||||
)
|
||||
),
|
||||
@@ -151,6 +153,7 @@ nonisolated extension AgentBuiltInTools {
|
||||
symbol: "terminal",
|
||||
activity: .semanticArgument(key: "command", fallback: .sshCommand),
|
||||
output: .streams,
|
||||
detailPresenterID: "builtin.ssh_exec",
|
||||
actionKind: .run,
|
||||
phaseKinds: [
|
||||
"proposed": .sshPreparingCommand,
|
||||
@@ -348,6 +351,7 @@ nonisolated extension AgentBuiltInTools {
|
||||
concurrency: .parallel,
|
||||
presentation: .init(
|
||||
symbol: "terminal", activity: .semanticArgument(key: "command", fallback: .command),
|
||||
detailPresenterID: "builtin.terminal_suggest",
|
||||
groupsWithAdjacentTools: false,
|
||||
actionKind: .suggest
|
||||
)
|
||||
@@ -366,3 +370,194 @@ nonisolated extension AgentBuiltInTools {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tool-owned transcript details
|
||||
|
||||
nonisolated extension AgentBuiltInTools {
|
||||
static let listHostsDetailPresenter = AgentToolDetailPresenter(
|
||||
id: "builtin.list_hosts", present: presentHostListing
|
||||
)
|
||||
static let sshExecDetailPresenter = AgentToolDetailPresenter(
|
||||
id: "builtin.ssh_exec", present: presentCommandResult
|
||||
)
|
||||
static let sshWaitDetailPresenter = AgentToolDetailPresenter(
|
||||
id: "builtin.ssh_wait", present: presentCommandResult
|
||||
)
|
||||
static let terminalSuggestDetailPresenter = AgentToolDetailPresenter(
|
||||
id: "builtin.terminal_suggest", present: presentTerminalSuggestion
|
||||
)
|
||||
|
||||
private struct HostGroupDetailProjection {
|
||||
let id: String
|
||||
let title: String
|
||||
}
|
||||
|
||||
private static func presentHostListing(
|
||||
_ input: AgentToolDetailInput
|
||||
) -> [AgentToolDetail.Item] {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
guard let object = input.result.objectValue else {
|
||||
return F.genericItems(input.result, locale: input.locale)
|
||||
}
|
||||
let hostValues = object["hosts"]?.arrayValue ?? []
|
||||
let groupValues = object["groups"]?.arrayValue ?? []
|
||||
var groups: [HostGroupDetailProjection] = []
|
||||
|
||||
func walk(_ rows: [AgentJSONValue], prefix: String = "") {
|
||||
for row in rows {
|
||||
guard let fields = row.objectValue else { continue }
|
||||
let name = F.nonempty(fields["name"]?.stringValue)
|
||||
?? F.localized("Unnamed group", locale: input.locale)
|
||||
let computed = prefix.isEmpty ? name : "\(prefix) / \(name)"
|
||||
let title = F.nonempty(fields["path"]?.stringValue) ?? computed
|
||||
if let id = fields["id"]?.stringValue {
|
||||
groups.append(.init(id: id, title: title))
|
||||
}
|
||||
walk(fields["children"]?.arrayValue ?? [], prefix: computed)
|
||||
}
|
||||
}
|
||||
walk(groupValues)
|
||||
|
||||
var grouped: [String: [AgentToolDetail.ListRow]] = [:]
|
||||
var ungrouped: [AgentToolDetail.ListRow] = []
|
||||
var temporary: [AgentToolDetail.ListRow] = []
|
||||
var connected = 0
|
||||
for hostValue in hostValues {
|
||||
guard let host = hostValue.objectValue else { continue }
|
||||
if host["connection_state"]?.stringValue == "connected" { connected += 1 }
|
||||
let row = hostDetailRow(host, locale: input.locale)
|
||||
if host["temporary"]?.boolValue == true {
|
||||
temporary.append(row)
|
||||
} else if let groupID = host["group_id"]?.stringValue,
|
||||
groups.contains(where: { $0.id == groupID })
|
||||
{
|
||||
grouped[groupID, default: []].append(row)
|
||||
} else {
|
||||
ungrouped.append(row)
|
||||
}
|
||||
}
|
||||
|
||||
guard !hostValues.isEmpty || !groups.isEmpty else {
|
||||
return [.message(F.localized("No hosts available", locale: input.locale), .secondary)]
|
||||
}
|
||||
func hostCount(_ count: Int) -> String {
|
||||
String(localized: "\(count) hosts", locale: input.locale)
|
||||
}
|
||||
var summary = [hostCount(hostValues.count)]
|
||||
if connected > 0 {
|
||||
summary.append(String(localized: "\(connected) connected", locale: input.locale))
|
||||
}
|
||||
if !temporary.isEmpty {
|
||||
summary.append(String(localized: "\(temporary.count) temporary", locale: input.locale))
|
||||
}
|
||||
var sections = groups.map { group in
|
||||
let rows = grouped[group.id] ?? []
|
||||
return AgentToolDetail.ListSection(
|
||||
title: "\(group.title) · \(hostCount(rows.count))", rows: rows
|
||||
)
|
||||
}
|
||||
if !groups.isEmpty || !ungrouped.isEmpty {
|
||||
sections.append(.init(
|
||||
title: "\(F.localized("Ungrouped", locale: input.locale)) · \(hostCount(ungrouped.count))",
|
||||
rows: ungrouped
|
||||
))
|
||||
}
|
||||
if !temporary.isEmpty {
|
||||
sections.append(.init(
|
||||
title: "\(F.localized("Temporary", locale: input.locale)) · \(hostCount(temporary.count))",
|
||||
rows: temporary
|
||||
))
|
||||
}
|
||||
return [.groupedList(.init(
|
||||
summary: summary.joined(separator: " · "), sections: sections,
|
||||
isScrollable: hostValues.count > 12
|
||||
))]
|
||||
}
|
||||
|
||||
private static func hostDetailRow(
|
||||
_ host: [String: AgentJSONValue], locale: Locale
|
||||
) -> AgentToolDetail.ListRow {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
let address = F.nonempty(host["address"]?.stringValue)
|
||||
let name = F.nonempty(host["display_name"]?.stringValue) ?? address
|
||||
?? F.localized("Unnamed host", locale: locale)
|
||||
let state = connectionDetailState(host["connection_state"]?.stringValue ?? "", locale: locale)
|
||||
let subtitle = [address ?? F.localized("Address unavailable", locale: locale), state.label]
|
||||
.joined(separator: " · ")
|
||||
let tags = host["tags"]?.arrayValue?.compactMap { F.nonempty($0.stringValue) } ?? []
|
||||
return .init(
|
||||
title: name, subtitle: subtitle, badges: tags, symbol: "circle.fill", tint: state.tint
|
||||
)
|
||||
}
|
||||
|
||||
private static func connectionDetailState(
|
||||
_ raw: String, locale: Locale
|
||||
) -> (label: String, tint: AgentToolDetail.Tint) {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
return switch raw {
|
||||
case "connected": (F.localized("Connected", locale: locale), AgentToolDetail.Tint.success)
|
||||
case "connecting": (F.localized("Connecting", locale: locale), AgentToolDetail.Tint.warning)
|
||||
case "reconnecting": (F.localized("Reconnecting", locale: locale), AgentToolDetail.Tint.warning)
|
||||
case "disconnected": (F.localized("Not connected", locale: locale), AgentToolDetail.Tint.secondary)
|
||||
case "failed": (F.localized("Failed", locale: locale), AgentToolDetail.Tint.failure)
|
||||
default: (
|
||||
F.nonempty(F.humanized(raw))?.capitalized
|
||||
?? F.localized("Unknown", locale: locale),
|
||||
AgentToolDetail.Tint.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func presentCommandResult(
|
||||
_ input: AgentToolDetailInput
|
||||
) -> [AgentToolDetail.Item] {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
guard let object = input.result.objectValue else {
|
||||
return F.genericItems(input.result, locale: input.locale)
|
||||
}
|
||||
var items: [AgentToolDetail.Item] = []
|
||||
if let status = object["status"]?.stringValue {
|
||||
items.append(F.field(
|
||||
"Status", F.statusLabel(status, locale: input.locale), locale: input.locale
|
||||
))
|
||||
}
|
||||
if let code = object["exit_code"]?.integerValue {
|
||||
items.append(F.field("Exit code", String(code), locale: input.locale, monospaced: true))
|
||||
}
|
||||
F.appendField(
|
||||
&items, "Signal", object["signal"]?.stringValue,
|
||||
locale: input.locale, monospaced: true
|
||||
)
|
||||
let stdout = object["stdout"]?.stringValue ?? ""
|
||||
let stderr = object["stderr"]?.stringValue ?? ""
|
||||
if !stdout.isEmpty { items.append(F.text("Standard output", stdout, locale: input.locale)) }
|
||||
if !stderr.isEmpty { items.append(F.text("Standard error", stderr, locale: input.locale)) }
|
||||
if stdout.isEmpty, stderr.isEmpty, object["note"] == nil {
|
||||
items.append(.message(F.localized("No output", locale: input.locale), .secondary))
|
||||
}
|
||||
if let note = F.nonempty(object["note"]?.stringValue) {
|
||||
items.append(.message(note, .warning))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
private static func presentTerminalSuggestion(
|
||||
_ input: AgentToolDetailInput
|
||||
) -> [AgentToolDetail.Item] {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
let object = input.result.objectValue ?? [:]
|
||||
let command = input.arguments["command"]?.stringValue
|
||||
?? object["command"]?.stringValue ?? ""
|
||||
var items: [AgentToolDetail.Item] = [F.text("Command", command, locale: input.locale)]
|
||||
if let explanation = F.nonempty(
|
||||
input.arguments["explanation"]?.stringValue ?? object["explanation"]?.stringValue
|
||||
) {
|
||||
items.append(.message(explanation, .secondary))
|
||||
}
|
||||
let assessment = CommandRiskClassifier.assess(command)
|
||||
items.append(F.field("Risk", assessment.risk.label, locale: input.locale))
|
||||
items.append(contentsOf: assessment.reasons.map { .message($0, .warning) })
|
||||
items.append(.message(F.localized("Requires user action", locale: input.locale), .warning))
|
||||
return items
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ nonisolated extension AgentBuiltInTools {
|
||||
activity: .semanticArrayCount(
|
||||
key: "questions", unit: .question, fallback: .question
|
||||
),
|
||||
detailPresenterID: "builtin.request_user_input",
|
||||
actionKind: .ask
|
||||
)
|
||||
)) { invocation, context in
|
||||
@@ -152,6 +153,7 @@ nonisolated extension AgentBuiltInTools {
|
||||
target: .user, safety: .locallyReadOnly,
|
||||
presentation: .init(
|
||||
symbol: "key", activity: .semanticArgument(key: "prompt", fallback: .secret),
|
||||
detailPresenterID: "builtin.request_user_secret",
|
||||
actionKind: .request
|
||||
)
|
||||
)) { invocation, context in
|
||||
@@ -198,3 +200,93 @@ nonisolated extension AgentBuiltInTools {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tool-owned transcript details
|
||||
|
||||
nonisolated extension AgentBuiltInTools {
|
||||
static let requestUserInputDetailPresenter = AgentToolDetailPresenter(
|
||||
id: "builtin.request_user_input", present: presentUserInputDetail
|
||||
)
|
||||
static let requestUserSecretDetailPresenter = AgentToolDetailPresenter(
|
||||
id: "builtin.request_user_secret", present: presentUserSecretDetail
|
||||
)
|
||||
|
||||
private static func presentUserInputDetail(
|
||||
_ input: AgentToolDetailInput
|
||||
) -> [AgentToolDetail.Item] {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
guard let answers = input.result.objectValue?["answers"]?.arrayValue else {
|
||||
return F.genericItems(input.result, locale: input.locale)
|
||||
}
|
||||
let answerByID = Dictionary(
|
||||
answers.compactMap { row -> (String, [String: AgentJSONValue])? in
|
||||
guard let fields = row.objectValue,
|
||||
let id = fields["question_id"]?.stringValue
|
||||
else { return nil }
|
||||
return (id, fields)
|
||||
},
|
||||
uniquingKeysWith: { first, _ in first }
|
||||
)
|
||||
let questions = input.arguments["questions"]?.arrayValue ?? []
|
||||
var items: [AgentToolDetail.Item] = []
|
||||
for questionValue in questions {
|
||||
guard let question = questionValue.objectValue else { continue }
|
||||
let id = question["id"]?.stringValue ?? ""
|
||||
let prompt = question["prompt"]?.stringValue
|
||||
?? F.localized("Question unavailable", locale: input.locale)
|
||||
let displayedQuestion = F.nonempty(question["header"]?.stringValue)
|
||||
.map { "\($0) — \(prompt)" } ?? prompt
|
||||
let answerFields = answerByID[id]
|
||||
let answer = answerFields?["answer"]
|
||||
let displayedAnswer: String
|
||||
if let answer, answer != .null {
|
||||
let selected = answerFields?["selected_option_ids"]?.arrayValue?
|
||||
.compactMap(\.stringValue) ?? []
|
||||
let selectedSet = Set(selected)
|
||||
let labels = question["options"]?.arrayValue?.compactMap { option -> String? in
|
||||
guard let fields = option.objectValue,
|
||||
let optionID = fields["id"]?.stringValue,
|
||||
selectedSet.contains(optionID)
|
||||
else { return nil }
|
||||
return F.nonempty(fields["label"]?.stringValue)
|
||||
} ?? []
|
||||
let custom = F.nonempty(answerFields?["custom_text"]?.stringValue)
|
||||
let structured = labels + [custom].compactMap { $0 }
|
||||
displayedAnswer = structured.isEmpty
|
||||
? F.nonempty(answer.stringValue)
|
||||
?? F.localized("Skipped", locale: input.locale)
|
||||
: structured.joined(separator: ", ")
|
||||
} else {
|
||||
displayedAnswer = F.localized("Skipped", locale: input.locale)
|
||||
}
|
||||
items.append(F.field("Question", displayedQuestion, locale: input.locale))
|
||||
items.append(F.field("Answer", displayedAnswer, locale: input.locale))
|
||||
}
|
||||
return items.isEmpty ? F.genericItems(input.result, locale: input.locale) : items
|
||||
}
|
||||
|
||||
private static func presentUserSecretDetail(
|
||||
_ input: AgentToolDetailInput
|
||||
) -> [AgentToolDetail.Item] {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
var items: [AgentToolDetail.Item] = []
|
||||
F.appendField(&items, "Request", input.arguments["prompt"]?.stringValue, locale: input.locale)
|
||||
F.appendField(&items, "Purpose", input.arguments["purpose"]?.stringValue, locale: input.locale)
|
||||
if input.arguments["consumer_tool"]?.stringValue == "ssh_exec" {
|
||||
items.append(F.field(
|
||||
"Consumer", F.localized("SSH command", locale: input.locale), locale: input.locale
|
||||
))
|
||||
} else {
|
||||
F.appendField(
|
||||
&items, "Consumer",
|
||||
input.arguments["consumer_tool"]?.stringValue.map(F.humanized),
|
||||
locale: input.locale
|
||||
)
|
||||
}
|
||||
items.append(F.field(
|
||||
"Status", F.localized("Secret received securely", locale: input.locale),
|
||||
locale: input.locale
|
||||
))
|
||||
return items
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ nonisolated extension AgentBuiltInTools {
|
||||
presentation: .init(
|
||||
symbol: "globe", activity: .semanticArgument(key: "url", fallback: .url),
|
||||
output: .field("body"),
|
||||
detailPresenterID: "builtin.fetch",
|
||||
actionKind: .fetch
|
||||
)
|
||||
)
|
||||
@@ -117,6 +118,7 @@ nonisolated extension AgentBuiltInTools {
|
||||
presentation: .init(
|
||||
symbol: "magnifyingglass",
|
||||
activity: .semanticArgument(key: "query", fallback: .web), output: .json,
|
||||
detailPresenterID: "builtin.web_search",
|
||||
actionKind: .search
|
||||
)
|
||||
)
|
||||
@@ -152,3 +154,82 @@ nonisolated extension AgentBuiltInTools {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tool-owned transcript details
|
||||
|
||||
nonisolated extension AgentBuiltInTools {
|
||||
static let fetchDetailPresenter = AgentToolDetailPresenter(
|
||||
id: "builtin.fetch", present: presentFetchDetail
|
||||
)
|
||||
static let webSearchDetailPresenter = AgentToolDetailPresenter(
|
||||
id: "builtin.web_search", present: presentWebSearchDetail
|
||||
)
|
||||
|
||||
private static func presentFetchDetail(
|
||||
_ input: AgentToolDetailInput
|
||||
) -> [AgentToolDetail.Item] {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
guard let object = input.result.objectValue else {
|
||||
return F.genericItems(input.result, locale: input.locale)
|
||||
}
|
||||
var items: [AgentToolDetail.Item] = []
|
||||
if let url = F.nonempty(object["url"]?.stringValue) {
|
||||
items.append(.field(.init(
|
||||
label: F.localized("URL", locale: input.locale), value: url,
|
||||
isMonospaced: true, url: url
|
||||
)))
|
||||
}
|
||||
if let status = object["status"]?.integerValue {
|
||||
items.append(F.field("Status", String(status), locale: input.locale))
|
||||
}
|
||||
if let headers = object["headers"]?.objectValue, !headers.isEmpty {
|
||||
let rows = headers.keys.sorted {
|
||||
$0.localizedCaseInsensitiveCompare($1) == .orderedAscending
|
||||
}.map { name in
|
||||
AgentToolDetail.ListRow(title: name, subtitle: headers[name]?.stringValue ?? "")
|
||||
}
|
||||
items.append(.list(.init(
|
||||
title: F.localized("Response headers", locale: input.locale), rows: rows
|
||||
)))
|
||||
}
|
||||
if input.arguments["method"]?.stringValue ?? "GET" != "HEAD" {
|
||||
let body = object["body"]?.stringValue ?? ""
|
||||
items.append(body.isEmpty
|
||||
? .message(F.localized("Empty response body", locale: input.locale), .secondary)
|
||||
: .text(.init(
|
||||
title: F.localized("Response body", locale: input.locale),
|
||||
text: body, style: .plain
|
||||
)))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
private static func presentWebSearchDetail(
|
||||
_ input: AgentToolDetailInput
|
||||
) -> [AgentToolDetail.Item] {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
guard let values = input.result.arrayValue else {
|
||||
return F.genericItems(input.result, locale: input.locale)
|
||||
}
|
||||
let rows = values.compactMap { entry -> AgentToolDetail.ListRow? in
|
||||
guard let object = entry.objectValue else { return nil }
|
||||
let url = F.nonempty(object["url"]?.stringValue)
|
||||
let title = F.nonempty(object["title"]?.stringValue) ?? url
|
||||
?? F.localized("Untitled result", locale: input.locale)
|
||||
let snippet = F.nonempty(object["snippet"]?.stringValue)
|
||||
let published = object["published_at"]?.stringValue.map(F.displayDate)
|
||||
return .init(
|
||||
title: title, subtitle: url,
|
||||
detail: [snippet, published].compactMap { $0 }.joined(separator: "\n"),
|
||||
symbol: "link", url: url
|
||||
)
|
||||
}
|
||||
return [
|
||||
.message(String(localized: "\(rows.count) results", locale: input.locale), .secondary),
|
||||
.list(.init(
|
||||
title: nil, rows: rows,
|
||||
emptyMessage: F.localized("No results", locale: input.locale)
|
||||
)),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,7 @@ nonisolated enum AgentPlanTools: AgentToolSchemaBuilding {
|
||||
presentation: .init(
|
||||
symbol: "lightbulb.max",
|
||||
activity: .semanticArgument(key: "title", fallback: .plan), output: .field("plan"),
|
||||
detailPresenterID: "builtin.present_plan",
|
||||
actionKind: .present
|
||||
)
|
||||
)) { invocation, _ in
|
||||
@@ -137,6 +138,7 @@ nonisolated enum AgentPlanTools: AgentToolSchemaBuilding {
|
||||
presentation: .init(
|
||||
symbol: "checklist", activity: .semanticLabel(.taskList),
|
||||
output: .json,
|
||||
detailPresenterID: "builtin.manage_tasks",
|
||||
actionKind: .update
|
||||
)
|
||||
)) { invocation, _ in
|
||||
@@ -150,6 +152,36 @@ nonisolated enum AgentPlanTools: AgentToolSchemaBuilding {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tool-owned transcript details
|
||||
|
||||
nonisolated extension AgentPlanTools {
|
||||
static let presentPlanDetailPresenter = AgentToolDetailPresenter(
|
||||
id: "builtin.present_plan"
|
||||
) { input in
|
||||
guard let path = input.result.objectValue?["scratch_path"]?.stringValue else {
|
||||
return AgentToolDetailFormatting.genericItems(input.result, locale: input.locale)
|
||||
}
|
||||
return [.message(
|
||||
String(localized: "Plan presented from \(path)", locale: input.locale), .success
|
||||
)]
|
||||
}
|
||||
|
||||
static let manageTasksDetailPresenter = AgentToolDetailPresenter(
|
||||
id: "builtin.manage_tasks"
|
||||
) { input in
|
||||
guard let object = input.result.objectValue,
|
||||
let total = object["tasks"]?.integerValue,
|
||||
let completed = object["completed"]?.integerValue
|
||||
else {
|
||||
return AgentToolDetailFormatting.genericItems(input.result, locale: input.locale)
|
||||
}
|
||||
return [.message(
|
||||
String(localized: "\(total) tasks · \(completed) completed", locale: input.locale),
|
||||
.secondary
|
||||
)]
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated extension Arguments {
|
||||
/// The `tasks` array of `manage_tasks`.
|
||||
///
|
||||
|
||||
@@ -61,6 +61,7 @@ nonisolated enum AgentScratchTools: AgentToolSchemaBuilding {
|
||||
symbol: "folder",
|
||||
activity: .semanticArgument(key: "path", fallback: .scratchWorkspace),
|
||||
output: .json,
|
||||
detailPresenterID: "builtin.scratch_list",
|
||||
actionKind: .list
|
||||
)
|
||||
)) { invocation, _ in
|
||||
@@ -104,6 +105,7 @@ nonisolated enum AgentScratchTools: AgentToolSchemaBuilding {
|
||||
presentation: .init(
|
||||
symbol: "doc.text", activity: .semanticArgument(key: "path", fallback: .scratchFile),
|
||||
output: .field("content"),
|
||||
detailPresenterID: "builtin.scratch_read",
|
||||
actionKind: .read
|
||||
)
|
||||
)) { invocation, _ in
|
||||
@@ -139,6 +141,7 @@ nonisolated enum AgentScratchTools: AgentToolSchemaBuilding {
|
||||
presentation: .init(
|
||||
symbol: "square.and.pencil",
|
||||
activity: .semanticArgument(key: "path", fallback: .scratchFile), output: .json,
|
||||
detailPresenterID: "builtin.scratch_write",
|
||||
actionKind: .write
|
||||
)
|
||||
)) { invocation, _ in
|
||||
@@ -188,6 +191,7 @@ nonisolated enum AgentScratchTools: AgentToolSchemaBuilding {
|
||||
presentation: .init(
|
||||
symbol: "pencil", activity: .semanticArgument(key: "path", fallback: .scratchFile),
|
||||
output: .field("diff"),
|
||||
detailPresenterID: "builtin.scratch_edit",
|
||||
actionKind: .edit
|
||||
)
|
||||
)) { invocation, _ in
|
||||
@@ -228,7 +232,8 @@ nonisolated enum AgentScratchTools: AgentToolSchemaBuilding {
|
||||
presentation: .init(
|
||||
symbol: "arrow.left.arrow.right",
|
||||
activity: .semanticArgument(key: "path_a", fallback: .scratchFiles),
|
||||
output: .field("diff"), actionKind: .compare
|
||||
output: .field("diff"), detailPresenterID: "builtin.scratch_diff",
|
||||
actionKind: .compare
|
||||
)
|
||||
)) { invocation, _ in
|
||||
let arguments = try Arguments(invocation)
|
||||
@@ -267,6 +272,7 @@ nonisolated enum AgentScratchTools: AgentToolSchemaBuilding {
|
||||
presentation: .init(
|
||||
symbol: "trash", activity: .semanticArgument(key: "path", fallback: .scratchEntry),
|
||||
output: .json,
|
||||
detailPresenterID: "builtin.scratch_delete",
|
||||
actionKind: .delete
|
||||
)
|
||||
)) { invocation, _ in
|
||||
@@ -313,7 +319,7 @@ nonisolated enum AgentScratchTools: AgentToolSchemaBuilding {
|
||||
presentation: .init(
|
||||
symbol: "arrow.down.doc",
|
||||
activity: .semanticArgument(key: "remote_path", fallback: .remoteFile),
|
||||
output: .json, actionKind: .pull,
|
||||
output: .json, detailPresenterID: "builtin.scratch_pull", actionKind: .pull,
|
||||
includesTarget: true
|
||||
)
|
||||
)
|
||||
@@ -382,7 +388,7 @@ nonisolated enum AgentScratchTools: AgentToolSchemaBuilding {
|
||||
presentation: .init(
|
||||
symbol: "arrow.up.doc",
|
||||
activity: .semanticArgument(key: "remote_path", fallback: .remoteFile),
|
||||
output: .json, actionKind: .push,
|
||||
output: .json, detailPresenterID: "builtin.scratch_push", actionKind: .push,
|
||||
includesTarget: true
|
||||
)
|
||||
)
|
||||
@@ -480,3 +486,248 @@ nonisolated enum AgentScratchTools: AgentToolSchemaBuilding {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tool-owned transcript details
|
||||
|
||||
nonisolated extension AgentScratchTools {
|
||||
static let listDetailPresenter = AgentToolDetailPresenter(
|
||||
id: "builtin.scratch_list", present: presentListDetail
|
||||
)
|
||||
static let readDetailPresenter = AgentToolDetailPresenter(
|
||||
id: "builtin.scratch_read", present: presentReadDetail
|
||||
)
|
||||
static let writeDetailPresenter = AgentToolDetailPresenter(
|
||||
id: "builtin.scratch_write", present: presentWriteDetail
|
||||
)
|
||||
static let editDetailPresenter = AgentToolDetailPresenter(
|
||||
id: "builtin.scratch_edit", present: presentEditDetail
|
||||
)
|
||||
static let diffDetailPresenter = AgentToolDetailPresenter(
|
||||
id: "builtin.scratch_diff", present: presentDiffDetail
|
||||
)
|
||||
static let deleteDetailPresenter = AgentToolDetailPresenter(
|
||||
id: "builtin.scratch_delete", present: presentDeleteDetail
|
||||
)
|
||||
static let pullDetailPresenter = AgentToolDetailPresenter(
|
||||
id: "builtin.scratch_pull", present: presentPullDetail
|
||||
)
|
||||
static let pushDetailPresenter = AgentToolDetailPresenter(
|
||||
id: "builtin.scratch_push", present: presentPushDetail
|
||||
)
|
||||
|
||||
private static func presentListDetail(
|
||||
_ input: AgentToolDetailInput
|
||||
) -> [AgentToolDetail.Item] {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
guard let object = input.result.objectValue else {
|
||||
return F.genericItems(input.result, locale: input.locale)
|
||||
}
|
||||
let entries = object["entries"]?.arrayValue ?? []
|
||||
let entryCount = object["entry_count"]?.integerValue ?? entries.count
|
||||
let entryQuota = object["entry_quota"]?.integerValue
|
||||
let totalBytes = object["total_bytes"]?.integerValue ?? 0
|
||||
let byteQuota = object["byte_quota"]?.integerValue
|
||||
var summary = entryQuota.map { "\(entryCount) / \($0) entries" }
|
||||
?? String(localized: "\(entryCount) entries", locale: input.locale)
|
||||
if let byteQuota {
|
||||
summary += " · \(F.byteCount(totalBytes)) / \(F.byteCount(byteQuota))"
|
||||
} else {
|
||||
summary += " · \(F.byteCount(totalBytes))"
|
||||
}
|
||||
let rows = entries.compactMap { entry -> AgentToolDetail.ListRow? in
|
||||
guard let fields = entry.objectValue else { return nil }
|
||||
let path = fields["path"]?.stringValue
|
||||
?? F.localized("Unnamed entry", locale: input.locale)
|
||||
let kind = fields["kind"]?.stringValue ?? ""
|
||||
var subtitle = F.kindLabel(kind, locale: input.locale)
|
||||
if let bytes = fields["bytes"]?.integerValue { subtitle += " · \(F.byteCount(bytes))" }
|
||||
return .init(
|
||||
title: path, subtitle: subtitle,
|
||||
detail: fields["modified_at"]?.stringValue.map(F.displayDate),
|
||||
symbol: kind == "directory" ? "folder" : "doc"
|
||||
)
|
||||
}
|
||||
return [
|
||||
.message(summary, .secondary),
|
||||
.list(.init(
|
||||
title: nil, rows: rows,
|
||||
emptyMessage: F.localized("Workspace is empty", locale: input.locale)
|
||||
)),
|
||||
]
|
||||
}
|
||||
|
||||
private static func presentReadDetail(
|
||||
_ input: AgentToolDetailInput
|
||||
) -> [AgentToolDetail.Item] {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
guard let object = input.result.objectValue else {
|
||||
return F.genericItems(input.result, locale: input.locale)
|
||||
}
|
||||
var items: [AgentToolDetail.Item] = []
|
||||
F.appendField(&items, "Path", object["path"]?.stringValue, locale: input.locale, monospaced: true)
|
||||
let content = object["content"]?.stringValue ?? ""
|
||||
items.append(content.isEmpty
|
||||
? .message(F.localized("Empty file", locale: input.locale), .secondary)
|
||||
: F.text("Content", content, locale: input.locale))
|
||||
if let offset = object["offset"]?.integerValue,
|
||||
let returned = object["returned_lines"]?.integerValue,
|
||||
let total = object["total_lines"]?.integerValue
|
||||
{
|
||||
let end = returned == 0 ? offset : offset + returned - 1
|
||||
items.append(F.field("Lines", "\(offset)–\(end) of \(total)", locale: input.locale))
|
||||
}
|
||||
F.appendBytes(&items, "Size", object["bytes"], locale: input.locale)
|
||||
F.appendField(
|
||||
&items, "Line endings", object["line_ending"]?.stringValue?.uppercased(),
|
||||
locale: input.locale
|
||||
)
|
||||
return items
|
||||
}
|
||||
|
||||
private static func presentWriteDetail(
|
||||
_ input: AgentToolDetailInput
|
||||
) -> [AgentToolDetail.Item] {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
guard let object = input.result.objectValue else {
|
||||
return F.genericItems(input.result, locale: input.locale)
|
||||
}
|
||||
var items: [AgentToolDetail.Item] = []
|
||||
F.appendField(&items, "Path", object["path"]?.stringValue, locale: input.locale, monospaced: true)
|
||||
let result = object["created"]?.boolValue == true
|
||||
? F.localized("Created", locale: input.locale)
|
||||
: F.localized("Overwritten", locale: input.locale)
|
||||
items.append(F.field("Result", result, locale: input.locale))
|
||||
F.appendBytes(&items, "Size", object["bytes"], locale: input.locale)
|
||||
F.appendField(&items, "SHA-256", object["sha256"]?.stringValue, locale: input.locale, monospaced: true)
|
||||
F.appendBytes(&items, "Workspace usage", object["total_bytes"], locale: input.locale)
|
||||
return items
|
||||
}
|
||||
|
||||
private static func presentEditDetail(
|
||||
_ input: AgentToolDetailInput
|
||||
) -> [AgentToolDetail.Item] {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
guard let object = input.result.objectValue else {
|
||||
return F.genericItems(input.result, locale: input.locale)
|
||||
}
|
||||
var items: [AgentToolDetail.Item] = []
|
||||
F.appendField(&items, "Path", object["path"]?.stringValue, locale: input.locale, monospaced: true)
|
||||
if let count = object["edits_applied"]?.integerValue {
|
||||
items.append(F.field(
|
||||
"Changes", String(localized: "\(count) edits applied", locale: input.locale),
|
||||
locale: input.locale
|
||||
))
|
||||
}
|
||||
if let diff = object["diff"]?.stringValue, !diff.isEmpty {
|
||||
items.append(F.text("Diff", diff, locale: input.locale))
|
||||
}
|
||||
F.appendBytes(&items, "Size", object["bytes"], locale: input.locale)
|
||||
F.appendField(
|
||||
&items, "Line endings", object["line_ending"]?.stringValue?.uppercased(),
|
||||
locale: input.locale
|
||||
)
|
||||
F.appendField(&items, "SHA-256", object["sha256"]?.stringValue, locale: input.locale, monospaced: true)
|
||||
F.appendBytes(&items, "Workspace usage", object["total_bytes"], locale: input.locale)
|
||||
return items
|
||||
}
|
||||
|
||||
private static func presentDiffDetail(
|
||||
_ input: AgentToolDetailInput
|
||||
) -> [AgentToolDetail.Item] {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
guard let object = input.result.objectValue else {
|
||||
return F.genericItems(input.result, locale: input.locale)
|
||||
}
|
||||
var items: [AgentToolDetail.Item] = []
|
||||
F.appendField(&items, "From", object["path_a"]?.stringValue, locale: input.locale, monospaced: true)
|
||||
F.appendField(&items, "To", object["path_b"]?.stringValue, locale: input.locale, monospaced: true)
|
||||
let diff = object["diff"]?.stringValue ?? ""
|
||||
let a = object["line_ending_a"]?.stringValue?.uppercased()
|
||||
let b = object["line_ending_b"]?.stringValue?.uppercased()
|
||||
if !diff.isEmpty {
|
||||
items.append(F.text("Diff", diff, locale: input.locale))
|
||||
} else if object["identical"]?.boolValue == true {
|
||||
items.append(.message(F.localized("Files are identical", locale: input.locale), .success))
|
||||
} else if let a, let b, a != b {
|
||||
items.append(.message(
|
||||
String(
|
||||
localized: "Text is identical; line endings differ: \(a) → \(b)",
|
||||
locale: input.locale
|
||||
),
|
||||
.warning
|
||||
))
|
||||
} else {
|
||||
items.append(.message(F.localized("No differences", locale: input.locale), .secondary))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
private static func presentDeleteDetail(
|
||||
_ input: AgentToolDetailInput
|
||||
) -> [AgentToolDetail.Item] {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
guard let object = input.result.objectValue else {
|
||||
return F.genericItems(input.result, locale: input.locale)
|
||||
}
|
||||
var items: [AgentToolDetail.Item] = []
|
||||
F.appendField(&items, "Path", object["path"]?.stringValue, locale: input.locale, monospaced: true)
|
||||
items.append(F.field("Result", F.localized("Deleted", locale: input.locale), locale: input.locale))
|
||||
F.appendField(
|
||||
&items, "Kind", object["kind"]?.stringValue.map {
|
||||
F.kindLabel($0, locale: input.locale)
|
||||
}, locale: input.locale
|
||||
)
|
||||
let count = object["entry_count"]?.integerValue
|
||||
let bytes = object["total_bytes"]?.integerValue
|
||||
if count != nil || bytes != nil {
|
||||
let parts = [
|
||||
count.map { String(localized: "\($0) entries", locale: input.locale) },
|
||||
bytes.map(F.byteCount),
|
||||
].compactMap { $0 }
|
||||
items.append(F.field("Workspace", parts.joined(separator: " · "), locale: input.locale))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
private static func presentPullDetail(
|
||||
_ input: AgentToolDetailInput
|
||||
) -> [AgentToolDetail.Item] {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
guard let object = input.result.objectValue else {
|
||||
return F.genericItems(input.result, locale: input.locale)
|
||||
}
|
||||
var items: [AgentToolDetail.Item] = []
|
||||
F.appendField(&items, "Remote path", object["remote_path"]?.stringValue, locale: input.locale, monospaced: true)
|
||||
F.appendField(&items, "Scratch path", object["scratch_path"]?.stringValue, locale: input.locale, monospaced: true)
|
||||
F.appendBytes(&items, "Size", object["bytes"], locale: input.locale)
|
||||
items.append(F.field(
|
||||
"Content type",
|
||||
object["is_utf8"]?.boolValue == true
|
||||
? F.localized("UTF-8 text", locale: input.locale)
|
||||
: F.localized("Binary", locale: input.locale),
|
||||
locale: input.locale
|
||||
))
|
||||
if let lines = object["lines"]?.integerValue {
|
||||
items.append(F.field("Lines", String(lines), locale: input.locale))
|
||||
}
|
||||
F.appendField(&items, "SHA-256", object["sha256"]?.stringValue, locale: input.locale, monospaced: true)
|
||||
F.appendBytes(&items, "Workspace usage", object["total_bytes"], locale: input.locale)
|
||||
return items
|
||||
}
|
||||
|
||||
private static func presentPushDetail(
|
||||
_ input: AgentToolDetailInput
|
||||
) -> [AgentToolDetail.Item] {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
guard let object = input.result.objectValue else {
|
||||
return F.genericItems(input.result, locale: input.locale)
|
||||
}
|
||||
var items: [AgentToolDetail.Item] = []
|
||||
F.appendField(&items, "Scratch path", object["scratch_path"]?.stringValue, locale: input.locale, monospaced: true)
|
||||
F.appendField(&items, "Remote path", object["remote_path"]?.stringValue, locale: input.locale, monospaced: true)
|
||||
F.appendBytes(&items, "Size", object["bytes"], locale: input.locale)
|
||||
F.appendField(&items, "SHA-256", object["sha256"]?.stringValue, locale: input.locale, monospaced: true)
|
||||
items.append(F.field("Result", F.localized("Written", locale: input.locale), locale: input.locale))
|
||||
return items
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import Foundation
|
||||
|
||||
/// UI-neutral content chosen by a tool and rendered by the transcript.
|
||||
/// It deliberately contains no SwiftUI types or executable persisted state.
|
||||
nonisolated struct AgentToolDetail: Equatable, Sendable {
|
||||
var items: [Item]
|
||||
|
||||
nonisolated enum Item: Equatable, Sendable {
|
||||
case field(Field)
|
||||
case message(String, Tint)
|
||||
case text(TextBlock)
|
||||
case list(ListBlock)
|
||||
case groupedList(GroupedList)
|
||||
}
|
||||
|
||||
nonisolated struct Field: Equatable, Sendable {
|
||||
var label: String
|
||||
var value: String
|
||||
var isMonospaced = false
|
||||
var url: String? = nil
|
||||
}
|
||||
|
||||
nonisolated enum Tint: Equatable, Sendable {
|
||||
case secondary, warning, success, failure
|
||||
}
|
||||
|
||||
nonisolated enum TextStyle: Equatable, Sendable {
|
||||
case plain, monospaced
|
||||
}
|
||||
|
||||
nonisolated struct TextBlock: Equatable, Sendable {
|
||||
var title: String?
|
||||
var text: String
|
||||
var style: TextStyle
|
||||
}
|
||||
|
||||
nonisolated struct ListRow: Equatable, Sendable {
|
||||
var title: String
|
||||
var subtitle: String? = nil
|
||||
var detail: String? = nil
|
||||
var badges: [String] = []
|
||||
var symbol: String? = nil
|
||||
var tint: Tint = .secondary
|
||||
var url: String? = nil
|
||||
}
|
||||
|
||||
nonisolated struct ListBlock: Equatable, Sendable {
|
||||
var title: String?
|
||||
var rows: [ListRow]
|
||||
var emptyMessage: String? = nil
|
||||
}
|
||||
|
||||
nonisolated struct ListSection: Equatable, Sendable {
|
||||
var title: String
|
||||
var rows: [ListRow]
|
||||
}
|
||||
|
||||
nonisolated struct GroupedList: Equatable, Sendable {
|
||||
var summary: String
|
||||
var sections: [ListSection]
|
||||
var isScrollable: Bool
|
||||
}
|
||||
}
|
||||
|
||||
/// The complete, immutable input to a detail projection.
|
||||
nonisolated struct AgentToolDetailInput: Sendable {
|
||||
var result: AgentJSONValue
|
||||
var arguments: [String: AgentJSONValue]
|
||||
var locale: Locale
|
||||
}
|
||||
|
||||
/// A local function selected by a stable ID stored in descriptor metadata.
|
||||
/// Only the built-in catalog constructs these; remote tools cannot register code.
|
||||
nonisolated struct AgentToolDetailPresenter: Sendable {
|
||||
let id: String
|
||||
private let projection: @Sendable (AgentToolDetailInput) -> [AgentToolDetail.Item]
|
||||
|
||||
init(
|
||||
id: String,
|
||||
present: @escaping @Sendable (AgentToolDetailInput) -> [AgentToolDetail.Item]
|
||||
) {
|
||||
precondition(id.hasPrefix("builtin."), "Built-in detail presenter IDs must be namespaced")
|
||||
self.id = id
|
||||
projection = present
|
||||
}
|
||||
|
||||
func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
|
||||
projection(input)
|
||||
}
|
||||
}
|
||||
|
||||
/// The only executable detail presenters the app trusts.
|
||||
nonisolated enum AgentBuiltInToolDetailPresenters {
|
||||
static let all: [AgentToolDetailPresenter] = [
|
||||
AgentBuiltInTools.listHostsDetailPresenter,
|
||||
AgentBuiltInTools.sshExecDetailPresenter,
|
||||
AgentBuiltInTools.sshWaitDetailPresenter,
|
||||
AgentBuiltInTools.terminalSuggestDetailPresenter,
|
||||
AgentBuiltInTools.sftpListDetailPresenter,
|
||||
AgentBuiltInTools.sftpStatDetailPresenter,
|
||||
AgentBuiltInTools.sftpReadDetailPresenter,
|
||||
AgentBuiltInTools.sftpWriteDetailPresenter,
|
||||
AgentBuiltInTools.sftpMkdirDetailPresenter,
|
||||
AgentBuiltInTools.sftpMoveDetailPresenter,
|
||||
AgentBuiltInTools.sftpChmodDetailPresenter,
|
||||
AgentBuiltInTools.sftpDeleteDetailPresenter,
|
||||
AgentBuiltInTools.fetchDetailPresenter,
|
||||
AgentBuiltInTools.webSearchDetailPresenter,
|
||||
AgentBuiltInTools.requestUserInputDetailPresenter,
|
||||
AgentBuiltInTools.requestUserSecretDetailPresenter,
|
||||
AgentScratchTools.listDetailPresenter,
|
||||
AgentScratchTools.readDetailPresenter,
|
||||
AgentScratchTools.writeDetailPresenter,
|
||||
AgentScratchTools.editDetailPresenter,
|
||||
AgentScratchTools.diffDetailPresenter,
|
||||
AgentScratchTools.deleteDetailPresenter,
|
||||
AgentScratchTools.pullDetailPresenter,
|
||||
AgentScratchTools.pushDetailPresenter,
|
||||
AgentPlanTools.presentPlanDetailPresenter,
|
||||
AgentPlanTools.manageTasksDetailPresenter,
|
||||
]
|
||||
|
||||
private static let byID: [String: AgentToolDetailPresenter] = {
|
||||
var result: [String: AgentToolDetailPresenter] = [:]
|
||||
for presenter in all {
|
||||
precondition(result[presenter.id] == nil, "Duplicate detail presenter ID: \(presenter.id)")
|
||||
result[presenter.id] = presenter
|
||||
}
|
||||
return result
|
||||
}()
|
||||
|
||||
/// Names understood by releases that predate persisted presenter IDs.
|
||||
private static let legacyToolNames: Set<String> = [
|
||||
"list_hosts", "ssh_exec", "ssh_wait", "terminal_suggest",
|
||||
"sftp_list", "sftp_stat", "sftp_read", "sftp_write", "sftp_mkdir",
|
||||
"sftp_move", "sftp_chmod", "sftp_delete", "fetch", "web_search",
|
||||
"request_user_input", "request_user_secret", "scratch_list", "scratch_read",
|
||||
"scratch_write", "scratch_edit", "scratch_diff", "scratch_delete",
|
||||
"scratch_pull", "scratch_push", "present_plan", "manage_tasks",
|
||||
]
|
||||
|
||||
static var ids: [String] { all.map(\.id) }
|
||||
|
||||
static func presenter(id: String) -> AgentToolDetailPresenter? { byID[id] }
|
||||
|
||||
static func legacyPresenter(forToolNamed name: String) -> AgentToolDetailPresenter? {
|
||||
guard legacyToolNames.contains(name) else { return nil }
|
||||
return byID["builtin.\(name)"]
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared vocabulary for tool-owned projections. It owns generic formatting,
|
||||
/// not knowledge of any particular result schema.
|
||||
nonisolated enum AgentToolDetailFormatting {
|
||||
typealias Item = AgentToolDetail.Item
|
||||
typealias Field = AgentToolDetail.Field
|
||||
|
||||
static func genericItems(
|
||||
_ value: AgentJSONValue, locale: Locale
|
||||
) -> [AgentToolDetail.Item] {
|
||||
var fields: [Field] = []
|
||||
flatten(value, path: [], locale: locale, into: &fields)
|
||||
if fields.isEmpty {
|
||||
return [.message(localized("Completed without output", locale: locale), .secondary)]
|
||||
}
|
||||
return fields.map(Item.field)
|
||||
}
|
||||
|
||||
private static func flatten(
|
||||
_ value: AgentJSONValue, path: [String], locale: Locale, into fields: inout [Field]
|
||||
) {
|
||||
switch value {
|
||||
case .object(let object):
|
||||
for key in object.keys.sorted() {
|
||||
flatten(
|
||||
object[key] ?? .null, path: path + [humanized(key)], locale: locale,
|
||||
into: &fields
|
||||
)
|
||||
}
|
||||
case .array(let array):
|
||||
if array.isEmpty {
|
||||
fields.append(Field(
|
||||
label: label(path, locale: locale), value: localized("None", locale: locale)
|
||||
))
|
||||
} else {
|
||||
for (index, entry) in array.enumerated() {
|
||||
flatten(
|
||||
entry, path: path + [String(index + 1)], locale: locale, into: &fields
|
||||
)
|
||||
}
|
||||
}
|
||||
case .string(let string):
|
||||
fields.append(Field(label: label(path, locale: locale), value: string))
|
||||
case .number(let number):
|
||||
let rendered = number.rounded() == number ? String(Int(number)) : String(number)
|
||||
fields.append(Field(label: label(path, locale: locale), value: rendered))
|
||||
case .bool(let bool):
|
||||
fields.append(Field(
|
||||
label: label(path, locale: locale),
|
||||
value: localized(bool ? "Yes" : "No", locale: locale)
|
||||
))
|
||||
case .null:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
static func field(
|
||||
_ label: String.LocalizationValue, _ value: String, locale: Locale,
|
||||
monospaced: Bool = false, url: String? = nil
|
||||
) -> Item {
|
||||
.field(Field(
|
||||
label: AppLocalization.string(label, locale: locale), value: value,
|
||||
isMonospaced: monospaced, url: url
|
||||
))
|
||||
}
|
||||
|
||||
static func appendField(
|
||||
_ items: inout [Item], _ label: String.LocalizationValue, _ value: String?,
|
||||
locale: Locale, monospaced: Bool = false
|
||||
) {
|
||||
guard let value = nonempty(value) else { return }
|
||||
items.append(field(label, value, locale: locale, monospaced: monospaced))
|
||||
}
|
||||
|
||||
static func appendBytes(
|
||||
_ items: inout [Item], _ label: String.LocalizationValue, _ value: AgentJSONValue?,
|
||||
locale: Locale
|
||||
) {
|
||||
guard let bytes = value?.integerValue else { return }
|
||||
items.append(field(label, byteCount(bytes), locale: locale))
|
||||
}
|
||||
|
||||
static func text(
|
||||
_ title: String.LocalizationValue, _ value: String, locale: Locale
|
||||
) -> Item {
|
||||
.text(.init(
|
||||
title: AppLocalization.string(title, locale: locale), text: value, style: .monospaced
|
||||
))
|
||||
}
|
||||
|
||||
static func localized(_ key: String.LocalizationValue, locale: Locale) -> String {
|
||||
AppLocalization.string(key, locale: locale)
|
||||
}
|
||||
|
||||
static func nonempty(_ value: String?) -> String? {
|
||||
guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
static func byteCount(_ count: Int) -> String {
|
||||
ByteCountFormatter.string(fromByteCount: Int64(count), countStyle: .file)
|
||||
}
|
||||
|
||||
static func displayDate(_ raw: String) -> String {
|
||||
guard let date = try? Date.ISO8601FormatStyle().parse(raw) else { return raw }
|
||||
return date.formatted(date: .abbreviated, time: .shortened)
|
||||
}
|
||||
|
||||
static func statusLabel(_ status: String, locale: Locale) -> String {
|
||||
switch status {
|
||||
case "connecting": localized("Connecting", locale: locale)
|
||||
case "running": localized("Running", locale: locale)
|
||||
case "exited": localized("Exited", locale: locale)
|
||||
default: humanized(status).capitalized
|
||||
}
|
||||
}
|
||||
|
||||
static func kindLabel(_ kind: String, locale: Locale) -> String {
|
||||
switch kind {
|
||||
case "file": localized("File", locale: locale)
|
||||
case "directory": localized("Directory", locale: locale)
|
||||
case "symlink", "symbolic_link": localized("Symlink", locale: locale)
|
||||
default: humanized(kind).capitalized
|
||||
}
|
||||
}
|
||||
|
||||
static func humanized(_ key: String) -> String {
|
||||
key.replacingOccurrences(of: "_", with: " ")
|
||||
}
|
||||
|
||||
private static func label(_ path: [String], locale: Locale) -> String {
|
||||
let rendered = path.joined(separator: " · ")
|
||||
return rendered.isEmpty ? localized("Result", locale: locale) : rendered.capitalized
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,7 @@ nonisolated struct AgentTurnDriver: Sendable {
|
||||
presentation: .init(
|
||||
symbol: "magnifyingglass",
|
||||
activity: .semanticArgument(key: "query", fallback: .web), output: .json,
|
||||
detailPresenterID: "builtin.web_search",
|
||||
actionKind: .search
|
||||
)
|
||||
)
|
||||
|
||||
@@ -132,6 +132,10 @@ nonisolated struct AgentToolDescriptor: Identifiable, Hashable, Sendable, Codabl
|
||||
var symbol: String
|
||||
var activity: Activity
|
||||
var output: Output = .automatic
|
||||
/// Selects an application-owned detail projection. The stable ID is
|
||||
/// persisted with the rest of the presentation metadata; executable
|
||||
/// projection code remains local and is never accepted from MCP.
|
||||
var detailPresenterID: String? = nil
|
||||
/// Defaults to participating. A tool may opt out when its row is
|
||||
/// proposed content rather than work that belongs in an activity batch.
|
||||
var groupsWithAdjacentTools: Bool = true
|
||||
@@ -143,6 +147,7 @@ nonisolated struct AgentToolDescriptor: Identifiable, Hashable, Sendable, Codabl
|
||||
|
||||
init(
|
||||
symbol: String, activity: Activity, output: Output = .automatic,
|
||||
detailPresenterID: String? = nil,
|
||||
groupsWithAdjacentTools: Bool = true,
|
||||
action: Action? = nil, actionKind: ActionKind? = nil,
|
||||
includesTarget: Bool = false,
|
||||
@@ -152,6 +157,7 @@ nonisolated struct AgentToolDescriptor: Identifiable, Hashable, Sendable, Codabl
|
||||
self.symbol = symbol
|
||||
self.activity = activity
|
||||
self.output = output
|
||||
self.detailPresenterID = detailPresenterID
|
||||
self.groupsWithAdjacentTools = groupsWithAdjacentTools
|
||||
self.actionKind = actionKind
|
||||
self.phaseKinds = phaseKinds
|
||||
@@ -172,7 +178,7 @@ nonisolated struct AgentToolDescriptor: Identifiable, Hashable, Sendable, Codabl
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case symbol, activity, output, groupsWithAdjacentTools, messages
|
||||
case symbol, activity, output, detailPresenterID, groupsWithAdjacentTools, messages
|
||||
case actionKind, phaseKinds
|
||||
}
|
||||
|
||||
@@ -181,6 +187,9 @@ nonisolated struct AgentToolDescriptor: Identifiable, Hashable, Sendable, Codabl
|
||||
symbol = try container.decode(String.self, forKey: .symbol)
|
||||
activity = try container.decode(Activity.self, forKey: .activity)
|
||||
output = try container.decodeIfPresent(Output.self, forKey: .output) ?? .automatic
|
||||
detailPresenterID = try container.decodeIfPresent(
|
||||
String.self, forKey: .detailPresenterID
|
||||
)
|
||||
groupsWithAdjacentTools = try container.decodeIfPresent(
|
||||
Bool.self, forKey: .groupsWithAdjacentTools
|
||||
) ?? true
|
||||
|
||||
@@ -17,66 +17,6 @@ nonisolated enum AgentToolPresentation {
|
||||
let omittedCharacters: Int
|
||||
}
|
||||
|
||||
nonisolated struct Detail: Equatable, Sendable {
|
||||
var items: [DetailItem]
|
||||
}
|
||||
|
||||
nonisolated enum DetailItem: Equatable, Sendable {
|
||||
case field(DetailField)
|
||||
case message(String, DetailTint)
|
||||
case text(DetailTextBlock)
|
||||
case list(DetailListBlock)
|
||||
case groupedList(DetailGroupedList)
|
||||
}
|
||||
|
||||
nonisolated struct DetailField: Equatable, Sendable {
|
||||
var label: String
|
||||
var value: String
|
||||
var isMonospaced = false
|
||||
var url: String? = nil
|
||||
}
|
||||
|
||||
nonisolated enum DetailTint: Equatable, Sendable {
|
||||
case secondary, warning, success, failure
|
||||
}
|
||||
|
||||
nonisolated enum DetailTextStyle: Equatable, Sendable {
|
||||
case plain, monospaced
|
||||
}
|
||||
|
||||
nonisolated struct DetailTextBlock: Equatable, Sendable {
|
||||
var title: String?
|
||||
var text: String
|
||||
var style: DetailTextStyle
|
||||
}
|
||||
|
||||
nonisolated struct DetailListRow: Equatable, Sendable {
|
||||
var title: String
|
||||
var subtitle: String? = nil
|
||||
var detail: String? = nil
|
||||
var badges: [String] = []
|
||||
var symbol: String? = nil
|
||||
var tint: DetailTint = .secondary
|
||||
var url: String? = nil
|
||||
}
|
||||
|
||||
nonisolated struct DetailListBlock: Equatable, Sendable {
|
||||
var title: String?
|
||||
var rows: [DetailListRow]
|
||||
var emptyMessage: String? = nil
|
||||
}
|
||||
|
||||
nonisolated struct DetailListSection: Equatable, Sendable {
|
||||
var title: String
|
||||
var rows: [DetailListRow]
|
||||
}
|
||||
|
||||
nonisolated struct DetailGroupedList: Equatable, Sendable {
|
||||
var summary: String
|
||||
var sections: [DetailListSection]
|
||||
var isScrollable: Bool
|
||||
}
|
||||
|
||||
static func symbol(for card: AgentToolCard) -> String {
|
||||
return card.descriptor.presentation?.symbol ?? "wrench.and.screwdriver"
|
||||
}
|
||||
@@ -271,7 +211,9 @@ nonisolated enum AgentToolPresentation {
|
||||
/// Projects protocol-shaped tool results into transcript-shaped content.
|
||||
/// The model and persistence keep the original JSON; only this projection
|
||||
/// is allowed to decide what a person sees.
|
||||
static func detail(for card: AgentToolCard) -> Detail? {
|
||||
static func detail(
|
||||
for card: AgentToolCard, locale: Locale = .current
|
||||
) -> AgentToolDetail? {
|
||||
guard let result = card.result else { return nil }
|
||||
|
||||
// MCP output is remote-defined and intentionally keeps the established
|
||||
@@ -281,8 +223,8 @@ nonisolated enum AgentToolPresentation {
|
||||
from: result, presentation: card.descriptor.presentation,
|
||||
review: card.review
|
||||
) else { return nil }
|
||||
return Detail(items: [
|
||||
.text(DetailTextBlock(title: nil, text: output, style: .monospaced))
|
||||
return AgentToolDetail(items: [
|
||||
.text(.init(title: nil, text: output, style: .monospaced))
|
||||
])
|
||||
}
|
||||
|
||||
@@ -293,706 +235,29 @@ nonisolated enum AgentToolPresentation {
|
||||
review: card.review
|
||||
) else { return nil }
|
||||
let text = output.isEmpty ? String(localized: "Completed without output") : output
|
||||
return Detail(items: [
|
||||
return AgentToolDetail(items: [
|
||||
.message(text, result.isError ? .failure : .secondary)
|
||||
])
|
||||
}
|
||||
|
||||
let arguments = card.invocation.call.arguments.objectValue ?? [:]
|
||||
let items: [DetailItem]? = switch card.descriptor.name {
|
||||
case "list_hosts": hostListing(value)
|
||||
case "ssh_exec", "ssh_wait": commandResult(value)
|
||||
case "terminal_suggest": terminalSuggestion(value, arguments: arguments)
|
||||
case "sftp_list": remoteFileList(value)
|
||||
case "sftp_stat": remoteFileStat(value)
|
||||
case "sftp_read": contentResult(value, title: String(localized: "Content"), includesPath: true)
|
||||
case "sftp_write": sftpWrite(value, arguments: arguments)
|
||||
case "sftp_mkdir": pathResult(value, result: String(localized: "Directory created"))
|
||||
case "sftp_move": moveResult(value)
|
||||
case "sftp_chmod": chmodResult(value)
|
||||
case "sftp_delete": deleteResult(value, arguments: arguments)
|
||||
case "fetch": fetchResult(value, arguments: arguments)
|
||||
case "web_search": webSearchResult(value)
|
||||
case "request_user_input": userInputResult(value, arguments: arguments)
|
||||
case "request_user_secret": secretResult(arguments: arguments)
|
||||
case "scratch_list": scratchList(value)
|
||||
case "scratch_read": scratchRead(value)
|
||||
case "scratch_write": scratchWrite(value)
|
||||
case "scratch_edit": scratchEdit(value)
|
||||
case "scratch_diff": scratchDiff(value)
|
||||
case "scratch_delete": scratchDelete(value)
|
||||
case "scratch_pull": scratchPull(value)
|
||||
case "scratch_push": scratchPush(value)
|
||||
case "present_plan": presentPlan(value)
|
||||
case "manage_tasks": taskResult(value)
|
||||
default: nil
|
||||
}
|
||||
|
||||
if let items { return Detail(items: items) }
|
||||
return Detail(items: genericItems(value))
|
||||
}
|
||||
|
||||
// MARK: - Built-in detail projections
|
||||
|
||||
private struct HostGroupProjection {
|
||||
let id: String
|
||||
let title: String
|
||||
}
|
||||
|
||||
private static func hostListing(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
let hostValues = object["hosts"]?.arrayValue ?? []
|
||||
let groupValues = object["groups"]?.arrayValue ?? []
|
||||
var groups: [HostGroupProjection] = []
|
||||
|
||||
func walk(_ rows: [AgentJSONValue], prefix: String = "") {
|
||||
for row in rows {
|
||||
guard let fields = row.objectValue else { continue }
|
||||
let name = nonempty(fields["name"]?.stringValue) ?? String(localized: "Unnamed group")
|
||||
let computed = prefix.isEmpty ? name : "\(prefix) / \(name)"
|
||||
let title = nonempty(fields["path"]?.stringValue) ?? computed
|
||||
if let id = fields["id"]?.stringValue {
|
||||
groups.append(HostGroupProjection(id: id, title: title))
|
||||
}
|
||||
walk(fields["children"]?.arrayValue ?? [], prefix: computed)
|
||||
}
|
||||
}
|
||||
walk(groupValues)
|
||||
|
||||
var grouped: [String: [DetailListRow]] = [:]
|
||||
var ungrouped: [DetailListRow] = []
|
||||
var temporary: [DetailListRow] = []
|
||||
var connected = 0
|
||||
|
||||
for hostValue in hostValues {
|
||||
guard let host = hostValue.objectValue else { continue }
|
||||
let state = host["connection_state"]?.stringValue ?? ""
|
||||
if state == "connected" { connected += 1 }
|
||||
let row = hostRow(host)
|
||||
if host["temporary"]?.boolValue == true {
|
||||
temporary.append(row)
|
||||
} else if let groupID = host["group_id"]?.stringValue,
|
||||
groups.contains(where: { $0.id == groupID })
|
||||
{
|
||||
grouped[groupID, default: []].append(row)
|
||||
} else {
|
||||
ungrouped.append(row)
|
||||
}
|
||||
}
|
||||
|
||||
guard !hostValues.isEmpty || !groups.isEmpty else {
|
||||
return [.message(String(localized: "No hosts available"), .secondary)]
|
||||
}
|
||||
|
||||
var summaryParts = [hostCount(hostValues.count)]
|
||||
if connected > 0 { summaryParts.append(String(localized: "\(connected) connected")) }
|
||||
if !temporary.isEmpty {
|
||||
summaryParts.append(String(localized: "\(temporary.count) temporary"))
|
||||
}
|
||||
var sections = groups.map { group in
|
||||
let rows = grouped[group.id] ?? []
|
||||
return DetailListSection(
|
||||
title: "\(group.title) · \(hostCount(rows.count))", rows: rows
|
||||
let presenter: AgentToolDetailPresenter?
|
||||
if let id = card.descriptor.presentation?.detailPresenterID {
|
||||
presenter = AgentBuiltInToolDetailPresenters.presenter(id: id)
|
||||
} else {
|
||||
presenter = AgentBuiltInToolDetailPresenters.legacyPresenter(
|
||||
forToolNamed: card.descriptor.name
|
||||
)
|
||||
}
|
||||
if !groups.isEmpty || !ungrouped.isEmpty {
|
||||
sections.append(DetailListSection(
|
||||
title: "\(String(localized: "Ungrouped")) · \(hostCount(ungrouped.count))",
|
||||
rows: ungrouped
|
||||
))
|
||||
}
|
||||
if !temporary.isEmpty {
|
||||
sections.append(DetailListSection(
|
||||
title: "\(String(localized: "Temporary")) · \(hostCount(temporary.count))",
|
||||
rows: temporary
|
||||
))
|
||||
}
|
||||
return [.groupedList(DetailGroupedList(
|
||||
summary: summaryParts.joined(separator: " · "), sections: sections,
|
||||
isScrollable: hostValues.count > 12
|
||||
))]
|
||||
}
|
||||
|
||||
private static func hostRow(_ host: [String: AgentJSONValue]) -> DetailListRow {
|
||||
let address = nonempty(host["address"]?.stringValue)
|
||||
let name = nonempty(host["display_name"]?.stringValue)
|
||||
?? address ?? String(localized: "Unnamed host")
|
||||
let state = host["connection_state"]?.stringValue ?? ""
|
||||
let statePresentation = connectionState(state)
|
||||
let subtitle = [address ?? String(localized: "Address unavailable"), statePresentation.label]
|
||||
.joined(separator: " · ")
|
||||
let tags = host["tags"]?.arrayValue?.compactMap { nonempty($0.stringValue) } ?? []
|
||||
return DetailListRow(
|
||||
title: name, subtitle: subtitle, badges: tags, symbol: "circle.fill",
|
||||
tint: statePresentation.tint
|
||||
)
|
||||
}
|
||||
|
||||
private static func connectionState(_ raw: String) -> (label: String, tint: DetailTint) {
|
||||
switch raw {
|
||||
case "connected": (String(localized: "Connected"), .success)
|
||||
case "connecting": (String(localized: "Connecting"), .warning)
|
||||
case "reconnecting": (String(localized: "Reconnecting"), .warning)
|
||||
case "disconnected": (String(localized: "Not connected"), .secondary)
|
||||
case "failed": (String(localized: "Failed"), .failure)
|
||||
default: (
|
||||
nonempty(humanized(raw))?.capitalized ?? String(localized: "Unknown"), .secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func commandResult(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
if let status = object["status"]?.stringValue {
|
||||
items.append(field("Status", statusLabel(status)))
|
||||
}
|
||||
if let code = object["exit_code"]?.integerValue {
|
||||
items.append(field("Exit code", String(code), monospaced: true))
|
||||
}
|
||||
if let signal = nonempty(object["signal"]?.stringValue) {
|
||||
items.append(field("Signal", signal, monospaced: true))
|
||||
}
|
||||
let stdout = object["stdout"]?.stringValue ?? ""
|
||||
let stderr = object["stderr"]?.stringValue ?? ""
|
||||
if !stdout.isEmpty { items.append(text("Standard output", stdout)) }
|
||||
if !stderr.isEmpty { items.append(text("Standard error", stderr)) }
|
||||
if stdout.isEmpty, stderr.isEmpty, object["note"] == nil {
|
||||
items.append(.message(String(localized: "No output"), .secondary))
|
||||
}
|
||||
if let note = nonempty(object["note"]?.stringValue) {
|
||||
items.append(.message(note, .warning))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
private static func terminalSuggestion(
|
||||
_ value: AgentJSONValue, arguments: [String: AgentJSONValue]
|
||||
) -> [DetailItem] {
|
||||
let object = value.objectValue ?? [:]
|
||||
let command = arguments["command"]?.stringValue ?? object["command"]?.stringValue ?? ""
|
||||
var items: [DetailItem] = [text("Command", command)]
|
||||
if let explanation = nonempty(
|
||||
arguments["explanation"]?.stringValue ?? object["explanation"]?.stringValue
|
||||
) {
|
||||
items.append(.message(explanation, .secondary))
|
||||
}
|
||||
let assessment = CommandRiskClassifier.assess(command)
|
||||
items.append(field("Risk", assessment.risk.label))
|
||||
items.append(contentsOf: assessment.reasons.map { .message($0, .warning) })
|
||||
items.append(.message(String(localized: "Requires user action"), .warning))
|
||||
return items
|
||||
}
|
||||
|
||||
private static func remoteFileList(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let rows = value.arrayValue else { return genericItems(value) }
|
||||
let items = rows.compactMap(remoteFileRow)
|
||||
return [
|
||||
.message(itemCount(items.count), .secondary),
|
||||
.list(DetailListBlock(
|
||||
title: nil, rows: items,
|
||||
emptyMessage: String(localized: "Directory is empty")
|
||||
)),
|
||||
]
|
||||
}
|
||||
|
||||
private static func remoteFileRow(_ value: AgentJSONValue) -> DetailListRow? {
|
||||
guard let object = value.objectValue else { return nil }
|
||||
let path = object["path"]?.stringValue ?? ""
|
||||
let name = nonempty(object["name"]?.stringValue)
|
||||
?? nonempty(path.split(separator: "/").last.map(String.init))
|
||||
?? String(localized: "Unnamed item")
|
||||
let kind = object["kind"]?.stringValue ?? ""
|
||||
var metadata = [kindLabel(kind)]
|
||||
if let bytes = object["size"]?.integerValue { metadata.append(byteCount(bytes)) }
|
||||
if let permissions = nonempty(object["permissions"]?.stringValue) {
|
||||
metadata.append(permissions)
|
||||
}
|
||||
let modified = object["modified_at"]?.stringValue.map(displayDate)
|
||||
return DetailListRow(
|
||||
title: name, subtitle: metadata.filter { !$0.isEmpty }.joined(separator: " · "),
|
||||
detail: modified, symbol: kind == "directory" ? "folder" : "doc"
|
||||
)
|
||||
}
|
||||
|
||||
private static func remoteFileStat(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
appendField(&items, "Path", object["path"]?.stringValue, monospaced: true)
|
||||
appendField(&items, "Kind", object["kind"]?.stringValue.map(kindLabel))
|
||||
if let bytes = object["size"]?.integerValue { items.append(field("Size", byteCount(bytes))) }
|
||||
appendField(&items, "Permissions", object["permissions"]?.stringValue, monospaced: true)
|
||||
appendField(&items, "Modified", object["modified_at"]?.stringValue.map(displayDate))
|
||||
return items
|
||||
}
|
||||
|
||||
private static func contentResult(
|
||||
_ value: AgentJSONValue, title: String, includesPath: Bool
|
||||
) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
if includesPath { appendField(&items, "Path", object["path"]?.stringValue, monospaced: true) }
|
||||
let content = object["content"]?.stringValue ?? ""
|
||||
items.append(content.isEmpty
|
||||
? .message(String(localized: "Empty file"), .secondary)
|
||||
: .text(DetailTextBlock(title: title, text: content, style: .monospaced)))
|
||||
if let bytes = object["bytes"]?.integerValue { items.append(field("Size", byteCount(bytes))) }
|
||||
return items
|
||||
}
|
||||
|
||||
private static func sftpWrite(
|
||||
_ value: AgentJSONValue, arguments: [String: AgentJSONValue]
|
||||
) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
appendField(&items, "Path", object["path"]?.stringValue, monospaced: true)
|
||||
if let content = arguments["content"]?.stringValue {
|
||||
items.append(field("Size", byteCount(content.utf8.count)))
|
||||
}
|
||||
items.append(field("Result", String(localized: "Written")))
|
||||
return items
|
||||
}
|
||||
|
||||
private static func pathResult(_ value: AgentJSONValue, result: String) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
appendField(&items, "Path", object["path"]?.stringValue, monospaced: true)
|
||||
items.append(field("Result", result))
|
||||
return items
|
||||
}
|
||||
|
||||
private static func moveResult(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
appendField(&items, "From", object["source"]?.stringValue, monospaced: true)
|
||||
appendField(&items, "To", object["destination"]?.stringValue, monospaced: true)
|
||||
return items
|
||||
}
|
||||
|
||||
private static func chmodResult(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
appendField(&items, "Path", object["path"]?.stringValue, monospaced: true)
|
||||
appendField(&items, "Permissions", object["mode"]?.stringValue, monospaced: true)
|
||||
return items
|
||||
}
|
||||
|
||||
private static func deleteResult(
|
||||
_ value: AgentJSONValue, arguments: [String: AgentJSONValue]
|
||||
) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
appendField(&items, "Path", object["path"]?.stringValue, monospaced: true)
|
||||
appendField(&items, "Kind", arguments["kind"]?.stringValue.map(kindLabel))
|
||||
items.append(field("Result", String(localized: "Deleted")))
|
||||
return items
|
||||
}
|
||||
|
||||
private static func fetchResult(
|
||||
_ value: AgentJSONValue, arguments: [String: AgentJSONValue]
|
||||
) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
if let url = nonempty(object["url"]?.stringValue) {
|
||||
items.append(.field(DetailField(
|
||||
label: String(localized: "URL"), value: url, isMonospaced: true, url: url
|
||||
)))
|
||||
}
|
||||
if let status = object["status"]?.integerValue {
|
||||
items.append(field("Status", String(status)))
|
||||
}
|
||||
if let headers = object["headers"]?.objectValue, !headers.isEmpty {
|
||||
let rows = headers.keys.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||||
.map { name in
|
||||
DetailListRow(title: name, subtitle: headers[name]?.stringValue ?? "")
|
||||
}
|
||||
items.append(.list(DetailListBlock(
|
||||
title: String(localized: "Response headers"), rows: rows
|
||||
)))
|
||||
}
|
||||
let method = arguments["method"]?.stringValue ?? "GET"
|
||||
if method != "HEAD" {
|
||||
let body = object["body"]?.stringValue ?? ""
|
||||
items.append(body.isEmpty
|
||||
? .message(String(localized: "Empty response body"), .secondary)
|
||||
: .text(DetailTextBlock(
|
||||
title: String(localized: "Response body"), text: body, style: .plain
|
||||
)))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
private static func webSearchResult(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let values = value.arrayValue else { return genericItems(value) }
|
||||
let rows = values.compactMap { entry -> DetailListRow? in
|
||||
guard let object = entry.objectValue else { return nil }
|
||||
let url = nonempty(object["url"]?.stringValue)
|
||||
let title = nonempty(object["title"]?.stringValue) ?? url
|
||||
?? String(localized: "Untitled result")
|
||||
let snippet = nonempty(object["snippet"]?.stringValue)
|
||||
let published = object["published_at"]?.stringValue.map(displayDate)
|
||||
return DetailListRow(
|
||||
title: title, subtitle: url, detail: [snippet, published].compactMap { $0 }
|
||||
.joined(separator: "\n"), symbol: "link", url: url
|
||||
guard let presenter else {
|
||||
return AgentToolDetail(
|
||||
items: AgentToolDetailFormatting.genericItems(value, locale: locale)
|
||||
)
|
||||
}
|
||||
return [
|
||||
.message(searchResultCount(rows.count), .secondary),
|
||||
.list(DetailListBlock(
|
||||
title: nil, rows: rows, emptyMessage: String(localized: "No results")
|
||||
)),
|
||||
]
|
||||
}
|
||||
|
||||
private static func userInputResult(
|
||||
_ value: AgentJSONValue, arguments: [String: AgentJSONValue]
|
||||
) -> [DetailItem] {
|
||||
guard let answers = value.objectValue?["answers"]?.arrayValue else {
|
||||
return genericItems(value)
|
||||
}
|
||||
let answerByID = Dictionary(
|
||||
answers.compactMap { row -> (String, [String: AgentJSONValue])? in
|
||||
guard let fields = row.objectValue,
|
||||
let id = fields["question_id"]?.stringValue
|
||||
else { return nil }
|
||||
return (id, fields)
|
||||
},
|
||||
uniquingKeysWith: { first, _ in first }
|
||||
)
|
||||
let questions = arguments["questions"]?.arrayValue ?? []
|
||||
var items: [DetailItem] = []
|
||||
for questionValue in questions {
|
||||
guard let question = questionValue.objectValue else { continue }
|
||||
let id = question["id"]?.stringValue ?? ""
|
||||
let prompt = question["prompt"]?.stringValue ?? String(localized: "Question unavailable")
|
||||
let header = nonempty(question["header"]?.stringValue)
|
||||
let displayedQuestion = header.map { "\($0) — \(prompt)" } ?? prompt
|
||||
let answerFields = answerByID[id]
|
||||
let answer = answerFields?["answer"]
|
||||
let displayedAnswer: String
|
||||
if let answer, answer != .null {
|
||||
let selected = answerFields?["selected_option_ids"]?.arrayValue?
|
||||
.compactMap(\.stringValue) ?? []
|
||||
let selectedSet = Set(selected)
|
||||
let labels = question["options"]?.arrayValue?.compactMap { option -> String? in
|
||||
guard let fields = option.objectValue,
|
||||
let optionID = fields["id"]?.stringValue,
|
||||
selectedSet.contains(optionID)
|
||||
else { return nil }
|
||||
return nonempty(fields["label"]?.stringValue)
|
||||
} ?? []
|
||||
let custom = nonempty(answerFields?["custom_text"]?.stringValue)
|
||||
let structured = labels + [custom].compactMap { $0 }
|
||||
displayedAnswer = structured.isEmpty
|
||||
? nonempty(answer.stringValue) ?? String(localized: "Skipped")
|
||||
: structured.joined(separator: ", ")
|
||||
} else {
|
||||
displayedAnswer = String(localized: "Skipped")
|
||||
}
|
||||
items.append(field("Question", displayedQuestion))
|
||||
items.append(field("Answer", displayedAnswer))
|
||||
}
|
||||
return items.isEmpty ? genericItems(value) : items
|
||||
}
|
||||
|
||||
private static func secretResult(arguments: [String: AgentJSONValue]) -> [DetailItem] {
|
||||
var items: [DetailItem] = []
|
||||
appendField(&items, "Request", arguments["prompt"]?.stringValue)
|
||||
appendField(&items, "Purpose", arguments["purpose"]?.stringValue)
|
||||
if arguments["consumer_tool"]?.stringValue == "ssh_exec" {
|
||||
items.append(field("Consumer", String(localized: "SSH command")))
|
||||
} else {
|
||||
appendField(&items, "Consumer", arguments["consumer_tool"]?.stringValue.map(humanized))
|
||||
}
|
||||
items.append(field("Status", String(localized: "Secret received securely")))
|
||||
return items
|
||||
}
|
||||
|
||||
private static func scratchList(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
let entries = object["entries"]?.arrayValue ?? []
|
||||
let entryCount = object["entry_count"]?.integerValue ?? entries.count
|
||||
let entryQuota = object["entry_quota"]?.integerValue
|
||||
let totalBytes = object["total_bytes"]?.integerValue ?? 0
|
||||
let byteQuota = object["byte_quota"]?.integerValue
|
||||
var summary = entryQuota.map { "\(entryCount) / \($0) entries" }
|
||||
?? String(localized: "\(entryCount) entries")
|
||||
if let byteQuota {
|
||||
summary += " · \(byteCount(totalBytes)) / \(byteCount(byteQuota))"
|
||||
} else {
|
||||
summary += " · \(byteCount(totalBytes))"
|
||||
}
|
||||
let rows = entries.compactMap { entry -> DetailListRow? in
|
||||
guard let fields = entry.objectValue else { return nil }
|
||||
let path = fields["path"]?.stringValue ?? String(localized: "Unnamed entry")
|
||||
let kind = fields["kind"]?.stringValue ?? ""
|
||||
var subtitle = kindLabel(kind)
|
||||
if let bytes = fields["bytes"]?.integerValue { subtitle += " · \(byteCount(bytes))" }
|
||||
return DetailListRow(
|
||||
title: path, subtitle: subtitle,
|
||||
detail: fields["modified_at"]?.stringValue.map(displayDate),
|
||||
symbol: kind == "directory" ? "folder" : "doc"
|
||||
)
|
||||
}
|
||||
return [
|
||||
.message(summary, .secondary),
|
||||
.list(DetailListBlock(
|
||||
title: nil, rows: rows, emptyMessage: String(localized: "Workspace is empty")
|
||||
)),
|
||||
]
|
||||
}
|
||||
|
||||
private static func scratchRead(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
appendField(&items, "Path", object["path"]?.stringValue, monospaced: true)
|
||||
let content = object["content"]?.stringValue ?? ""
|
||||
items.append(content.isEmpty
|
||||
? .message(String(localized: "Empty file"), .secondary)
|
||||
: text("Content", content))
|
||||
if let offset = object["offset"]?.integerValue,
|
||||
let returned = object["returned_lines"]?.integerValue,
|
||||
let total = object["total_lines"]?.integerValue
|
||||
{
|
||||
let start = returned == 0 ? offset : offset
|
||||
let end = returned == 0 ? offset : offset + returned - 1
|
||||
items.append(field("Lines", "\(start)–\(end) of \(total)"))
|
||||
}
|
||||
if let bytes = object["bytes"]?.integerValue { items.append(field("Size", byteCount(bytes))) }
|
||||
appendField(&items, "Line endings", object["line_ending"]?.stringValue?.uppercased())
|
||||
return items
|
||||
}
|
||||
|
||||
private static func scratchWrite(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
appendField(&items, "Path", object["path"]?.stringValue, monospaced: true)
|
||||
let outcome = object["created"]?.boolValue == true
|
||||
? String(localized: "Created") : String(localized: "Overwritten")
|
||||
items.append(field("Result", outcome))
|
||||
appendBytes(&items, "Size", object["bytes"])
|
||||
appendField(&items, "SHA-256", object["sha256"]?.stringValue, monospaced: true)
|
||||
appendBytes(&items, "Workspace usage", object["total_bytes"])
|
||||
return items
|
||||
}
|
||||
|
||||
private static func scratchEdit(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
appendField(&items, "Path", object["path"]?.stringValue, monospaced: true)
|
||||
if let count = object["edits_applied"]?.integerValue {
|
||||
items.append(field("Changes", String(localized: "\(count) edits applied")))
|
||||
}
|
||||
if let diff = object["diff"]?.stringValue, !diff.isEmpty { items.append(text("Diff", diff)) }
|
||||
appendBytes(&items, "Size", object["bytes"])
|
||||
appendField(&items, "Line endings", object["line_ending"]?.stringValue?.uppercased())
|
||||
appendField(&items, "SHA-256", object["sha256"]?.stringValue, monospaced: true)
|
||||
appendBytes(&items, "Workspace usage", object["total_bytes"])
|
||||
return items
|
||||
}
|
||||
|
||||
private static func scratchDiff(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
appendField(&items, "From", object["path_a"]?.stringValue, monospaced: true)
|
||||
appendField(&items, "To", object["path_b"]?.stringValue, monospaced: true)
|
||||
let diff = object["diff"]?.stringValue ?? ""
|
||||
let a = object["line_ending_a"]?.stringValue?.uppercased()
|
||||
let b = object["line_ending_b"]?.stringValue?.uppercased()
|
||||
if !diff.isEmpty {
|
||||
items.append(text("Diff", diff))
|
||||
} else if object["identical"]?.boolValue == true {
|
||||
items.append(.message(String(localized: "Files are identical"), .success))
|
||||
} else if let a, let b, a != b {
|
||||
items.append(.message(
|
||||
String(localized: "Text is identical; line endings differ: \(a) → \(b)"),
|
||||
.warning
|
||||
))
|
||||
} else {
|
||||
items.append(.message(String(localized: "No differences"), .secondary))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
private static func scratchDelete(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items = pathResult(value, result: String(localized: "Deleted"))
|
||||
appendField(&items, "Kind", object["kind"]?.stringValue.map(kindLabel))
|
||||
let count = object["entry_count"]?.integerValue
|
||||
let bytes = object["total_bytes"]?.integerValue
|
||||
if count != nil || bytes != nil {
|
||||
let parts = [count.map { String(localized: "\($0) entries") }, bytes.map(byteCount)]
|
||||
.compactMap { $0 }
|
||||
items.append(field("Workspace", parts.joined(separator: " · ")))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
private static func scratchPull(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
appendField(&items, "Remote path", object["remote_path"]?.stringValue, monospaced: true)
|
||||
appendField(&items, "Scratch path", object["scratch_path"]?.stringValue, monospaced: true)
|
||||
appendBytes(&items, "Size", object["bytes"])
|
||||
items.append(field(
|
||||
"Content type",
|
||||
object["is_utf8"]?.boolValue == true
|
||||
? String(localized: "UTF-8 text") : String(localized: "Binary")
|
||||
))
|
||||
if let lines = object["lines"]?.integerValue { items.append(field("Lines", String(lines))) }
|
||||
appendField(&items, "SHA-256", object["sha256"]?.stringValue, monospaced: true)
|
||||
appendBytes(&items, "Workspace usage", object["total_bytes"])
|
||||
return items
|
||||
}
|
||||
|
||||
private static func scratchPush(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
appendField(&items, "Scratch path", object["scratch_path"]?.stringValue, monospaced: true)
|
||||
appendField(&items, "Remote path", object["remote_path"]?.stringValue, monospaced: true)
|
||||
appendBytes(&items, "Size", object["bytes"])
|
||||
appendField(&items, "SHA-256", object["sha256"]?.stringValue, monospaced: true)
|
||||
items.append(field("Result", String(localized: "Written")))
|
||||
return items
|
||||
}
|
||||
|
||||
private static func presentPlan(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let path = value.objectValue?["scratch_path"]?.stringValue else {
|
||||
return genericItems(value)
|
||||
}
|
||||
return [.message(String(localized: "Plan presented from \(path)"), .success)]
|
||||
}
|
||||
|
||||
private static func taskResult(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue,
|
||||
let total = object["tasks"]?.integerValue,
|
||||
let completed = object["completed"]?.integerValue
|
||||
else { return genericItems(value) }
|
||||
return [.message(
|
||||
String(localized: "\(total) tasks · \(completed) completed"), .secondary
|
||||
)]
|
||||
}
|
||||
|
||||
// MARK: - Generic human-readable fallback
|
||||
|
||||
private static func genericItems(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
var fields: [DetailField] = []
|
||||
flatten(value, path: [], into: &fields)
|
||||
if fields.isEmpty {
|
||||
return [.message(String(localized: "Completed without output"), .secondary)]
|
||||
}
|
||||
return fields.map(DetailItem.field)
|
||||
}
|
||||
|
||||
private static func flatten(
|
||||
_ value: AgentJSONValue, path: [String], into fields: inout [DetailField]
|
||||
) {
|
||||
switch value {
|
||||
case .object(let object):
|
||||
for key in object.keys.sorted() {
|
||||
flatten(object[key] ?? .null, path: path + [humanized(key)], into: &fields)
|
||||
}
|
||||
case .array(let array):
|
||||
if array.isEmpty {
|
||||
fields.append(DetailField(label: label(path), value: String(localized: "None")))
|
||||
} else {
|
||||
for (index, entry) in array.enumerated() {
|
||||
flatten(entry, path: path + [String(index + 1)], into: &fields)
|
||||
}
|
||||
}
|
||||
case .string(let string):
|
||||
fields.append(DetailField(label: label(path), value: string))
|
||||
case .number(let number):
|
||||
let rendered = number.rounded() == number ? String(Int(number)) : String(number)
|
||||
fields.append(DetailField(label: label(path), value: rendered))
|
||||
case .bool(let bool):
|
||||
fields.append(DetailField(
|
||||
label: label(path), value: bool ? String(localized: "Yes") : String(localized: "No")
|
||||
))
|
||||
case .null:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private static func field(
|
||||
_ label: LocalizedStringResource, _ value: String, monospaced: Bool = false,
|
||||
url: String? = nil
|
||||
) -> DetailItem {
|
||||
.field(DetailField(
|
||||
label: String(localized: label), value: value, isMonospaced: monospaced, url: url
|
||||
))
|
||||
}
|
||||
|
||||
private static func appendField(
|
||||
_ items: inout [DetailItem], _ label: LocalizedStringResource, _ value: String?,
|
||||
monospaced: Bool = false
|
||||
) {
|
||||
guard let value = nonempty(value) else { return }
|
||||
items.append(field(label, value, monospaced: monospaced))
|
||||
}
|
||||
|
||||
private static func appendBytes(
|
||||
_ items: inout [DetailItem], _ label: LocalizedStringResource, _ value: AgentJSONValue?
|
||||
) {
|
||||
guard let bytes = value?.integerValue else { return }
|
||||
items.append(field(label, byteCount(bytes)))
|
||||
}
|
||||
|
||||
private static func text(_ title: LocalizedStringResource, _ value: String) -> DetailItem {
|
||||
.text(DetailTextBlock(
|
||||
title: String(localized: title), text: value, style: .monospaced
|
||||
))
|
||||
}
|
||||
|
||||
private static func nonempty(_ value: String?) -> String? {
|
||||
guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private static func byteCount(_ count: Int) -> String {
|
||||
ByteCountFormatter.string(fromByteCount: Int64(count), countStyle: .file)
|
||||
}
|
||||
|
||||
private static func displayDate(_ raw: String) -> String {
|
||||
guard let date = try? Date.ISO8601FormatStyle().parse(raw) else { return raw }
|
||||
return date.formatted(date: .abbreviated, time: .shortened)
|
||||
}
|
||||
|
||||
private static func statusLabel(_ status: String) -> String {
|
||||
switch status {
|
||||
case "connecting": String(localized: "Connecting")
|
||||
case "running": String(localized: "Running")
|
||||
case "exited": String(localized: "Exited")
|
||||
default: humanized(status).capitalized
|
||||
}
|
||||
}
|
||||
|
||||
private static func kindLabel(_ kind: String) -> String {
|
||||
switch kind {
|
||||
case "file": String(localized: "File")
|
||||
case "directory": String(localized: "Directory")
|
||||
case "symlink", "symbolic_link": String(localized: "Symlink")
|
||||
default: humanized(kind).capitalized
|
||||
}
|
||||
}
|
||||
|
||||
private static func hostCount(_ count: Int) -> String {
|
||||
String(localized: "\(count) hosts")
|
||||
}
|
||||
|
||||
private static func itemCount(_ count: Int) -> String {
|
||||
String(localized: "\(count) items")
|
||||
}
|
||||
|
||||
private static func searchResultCount(_ count: Int) -> String {
|
||||
String(localized: "\(count) results")
|
||||
}
|
||||
|
||||
private static func label(_ path: [String]) -> String {
|
||||
let rendered = path.joined(separator: " · ")
|
||||
return rendered.isEmpty ? String(localized: "Result") : rendered.capitalized
|
||||
return AgentToolDetail(items: presenter.present(.init(
|
||||
result: value,
|
||||
arguments: card.invocation.call.arguments.objectValue ?? [:],
|
||||
locale: locale
|
||||
)))
|
||||
}
|
||||
|
||||
/// A bounded rendering projection. The full transformed output stays beside
|
||||
|
||||
@@ -680,7 +680,7 @@ private struct AgentToolRowView: View {
|
||||
}
|
||||
|
||||
if let result = card.result {
|
||||
if let detail = AgentToolPresentation.detail(for: card) {
|
||||
if let detail = AgentToolPresentation.detail(for: card, locale: locale) {
|
||||
AgentToolDetailView(detail: detail, isError: result.isError)
|
||||
}
|
||||
|
||||
@@ -715,7 +715,7 @@ private struct AgentToolRowView: View {
|
||||
/// this view: labels, sections and text styles have already been chosen by the
|
||||
/// presentation projection, leaving SwiftUI responsible only for hierarchy.
|
||||
private struct AgentToolDetailView: View {
|
||||
let detail: AgentToolPresentation.Detail
|
||||
let detail: AgentToolDetail
|
||||
let isError: Bool
|
||||
|
||||
var body: some View {
|
||||
@@ -742,7 +742,7 @@ private struct AgentToolDetailView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func fieldView(_ field: AgentToolPresentation.DetailField) -> some View {
|
||||
private func fieldView(_ field: AgentToolDetail.Field) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: Metrics.Space.s) {
|
||||
Text(field.label)
|
||||
.font(.caption.weight(.medium))
|
||||
@@ -763,7 +763,7 @@ private struct AgentToolDetailView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func textBlock(_ block: AgentToolPresentation.DetailTextBlock) -> some View {
|
||||
private func textBlock(_ block: AgentToolDetail.TextBlock) -> some View {
|
||||
if let title = block.title {
|
||||
Text(title)
|
||||
.font(.caption.weight(.medium))
|
||||
@@ -787,7 +787,7 @@ private struct AgentToolDetailView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func listBlock(_ list: AgentToolPresentation.DetailListBlock) -> some View {
|
||||
private func listBlock(_ list: AgentToolDetail.ListBlock) -> some View {
|
||||
if let title = list.title {
|
||||
Text(title)
|
||||
.font(.caption.weight(.medium))
|
||||
@@ -805,7 +805,7 @@ private struct AgentToolDetailView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func groupedList(_ list: AgentToolPresentation.DetailGroupedList) -> some View {
|
||||
private func groupedList(_ list: AgentToolDetail.GroupedList) -> some View {
|
||||
Text(list.summary)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -821,7 +821,7 @@ private struct AgentToolDetailView: View {
|
||||
}
|
||||
|
||||
private func groupedListContents(
|
||||
_ list: AgentToolPresentation.DetailGroupedList
|
||||
_ list: AgentToolDetail.GroupedList
|
||||
) -> some View {
|
||||
LazyVStack(alignment: .leading, spacing: Metrics.Space.m) {
|
||||
ForEach(Array(list.sections.enumerated()), id: \.offset) { _, section in
|
||||
@@ -838,7 +838,7 @@ private struct AgentToolDetailView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func detailRow(_ row: AgentToolPresentation.DetailListRow) -> some View {
|
||||
private func detailRow(_ row: AgentToolDetail.ListRow) -> some View {
|
||||
HStack(alignment: .top, spacing: Metrics.Space.s) {
|
||||
if let symbol = row.symbol {
|
||||
Image(systemName: symbol)
|
||||
@@ -879,7 +879,7 @@ private struct AgentToolDetailView: View {
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private func color(_ tint: AgentToolPresentation.DetailTint) -> Color {
|
||||
private func color(_ tint: AgentToolDetail.Tint) -> Color {
|
||||
switch tint {
|
||||
case .secondary: .secondary
|
||||
case .warning: .orange
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import Termind
|
||||
@@ -27,4 +28,37 @@ struct AgentBuiltInToolAvailabilityTests {
|
||||
#expect(!mainAssistant.contains { $0.descriptor.name == "terminal_suggest" })
|
||||
#expect(workspaceSidebar.contains { $0.descriptor.name == "terminal_suggest" })
|
||||
}
|
||||
|
||||
@Test
|
||||
func everyBuiltInToolDeclaresARegisteredDetailPresenter() {
|
||||
let hosts = AgentHostResolver(store: HostLibraryModel())
|
||||
let connections = AgentConnectionPool(
|
||||
configuration: { _ in throw AgentToolError.invalidHost },
|
||||
routeFingerprint: { _ in "" },
|
||||
openExec: { _ in throw AgentToolError.invalidHost },
|
||||
openSFTP: { _ in throw AgentToolError.invalidHost },
|
||||
sweepInterval: nil
|
||||
)
|
||||
let builtIns = AgentBuiltInTools.make(
|
||||
hosts: hosts, connections: connections,
|
||||
search: WebSearchClient(backend: .bing),
|
||||
includesTerminalSuggestion: true
|
||||
)
|
||||
let workspace = AgentScratchWorkspace(
|
||||
conversationID: UUID(), base: FileManager.default.temporaryDirectory
|
||||
)
|
||||
let scratch = AgentScratchTools.make(workspace: workspace)
|
||||
let planning = AgentPlanTools.make(
|
||||
workspace: workspace, tasks: AgentTaskList { _ in },
|
||||
plans: AgentPlanRecorder { _ in }
|
||||
)
|
||||
|
||||
for tool in builtIns + scratch + planning {
|
||||
let id = tool.descriptor.presentation?.detailPresenterID
|
||||
#expect(id != nil)
|
||||
if let id {
|
||||
#expect(AgentBuiltInToolDetailPresenters.presenter(id: id) != nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,66 @@ import Testing
|
||||
@MainActor
|
||||
struct AgentPersistenceTests {
|
||||
|
||||
@Test
|
||||
func toolDetailPresenterSurvivesPersistenceAndRestoresTheSameDetail() async throws {
|
||||
let configuration = localModelConfiguration(
|
||||
"AgentDetailPresenterPersistenceTests", schema: HostPersistence.schema,
|
||||
isStoredInMemoryOnly: true
|
||||
)
|
||||
let container = try ModelContainer(
|
||||
for: HostPersistence.schema, configurations: [configuration]
|
||||
)
|
||||
let repository = SwiftDataAgentRunRepository(context: container.mainContext)
|
||||
let call = AgentToolCall(
|
||||
id: "search", name: "web_search",
|
||||
arguments: .object(["query": .string("example")])
|
||||
)
|
||||
let snapshot = AgentRunSnapshot(
|
||||
conversationID: UUID(), state: .completed,
|
||||
permissionMode: .askForApproval,
|
||||
messages: [AgentTranscriptMessage(role: .assistant, text: "", toolCalls: [call])]
|
||||
)
|
||||
try await repository.save(snapshot)
|
||||
let invocation = AgentToolInvocation(runID: snapshot.id, call: call)
|
||||
let presentation = AgentToolDescriptor.Presentation(
|
||||
symbol: "magnifyingglass",
|
||||
activity: .semanticArgument(key: "query", fallback: .web), output: .json,
|
||||
detailPresenterID: "builtin.web_search", actionKind: .search
|
||||
)
|
||||
let descriptor = AgentToolDescriptor(
|
||||
name: call.name, summary: "Search", inputSchema: .object([:]),
|
||||
target: .network, safety: .locallyReadOnly, presentation: presentation
|
||||
)
|
||||
var result = AgentToolResult(
|
||||
callID: call.id,
|
||||
content: AgentJSONValue.array([.object([
|
||||
"title": .string("Example"), "url": .string("https://example.com"),
|
||||
"snippet": .string("A result"), "published_at": .null,
|
||||
])]).encodedString
|
||||
)
|
||||
result.toolPresentation = presentation
|
||||
try await repository.record(.toolProposed(invocation, descriptor), runID: snapshot.id)
|
||||
try await repository.record(.toolFinished(invocation, result), runID: snapshot.id)
|
||||
|
||||
let stored = try #require(
|
||||
try await repository.loadConversations().first?.toolCards.first
|
||||
)
|
||||
let restored = AgentToolCard(
|
||||
invocation: stored.invocation, descriptor: stored.descriptor,
|
||||
state: .completed, result: stored.result, review: stored.review
|
||||
)
|
||||
let live = AgentToolCard(
|
||||
invocation: invocation, descriptor: descriptor,
|
||||
state: .completed, result: result
|
||||
)
|
||||
let locale = Locale(identifier: "en")
|
||||
#expect(
|
||||
AgentToolPresentation.detail(for: restored, locale: locale)
|
||||
== AgentToolPresentation.detail(for: live, locale: locale)
|
||||
)
|
||||
#expect(restored.descriptor.presentation?.detailPresenterID == "builtin.web_search")
|
||||
}
|
||||
|
||||
@Test
|
||||
func imagePartsRoundTripThroughConversationPersistence() async throws {
|
||||
let configuration = localModelConfiguration(
|
||||
|
||||
@@ -486,6 +486,29 @@ struct AgentToolPresentationTests {
|
||||
#expect(block.text.contains("\"internal_flag\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
func unknownPresenterIDCannotSelectOrImpersonateBuiltInCode() throws {
|
||||
var card = makeCard(
|
||||
name: "fetch", target: .network,
|
||||
presentation: .init(
|
||||
symbol: "globe", activity: .label("URL"), output: .json,
|
||||
detailPresenterID: "remote.fetch"
|
||||
)
|
||||
)
|
||||
card.result = AgentToolResult(
|
||||
callID: "call",
|
||||
content: AgentJSONValue.object([
|
||||
"url": .string("https://example.com"), "status": .number(200),
|
||||
]).encodedString
|
||||
)
|
||||
|
||||
let detail = try #require(AgentToolPresentation.detail(for: card))
|
||||
#expect(detail.items == [
|
||||
.field(.init(label: "Status", value: "200")),
|
||||
.field(.init(label: "Url", value: "https://example.com")),
|
||||
])
|
||||
}
|
||||
|
||||
@Test
|
||||
func everyBuiltInJSONToolProducesStructuredDetailWithoutJSONSyntax() throws {
|
||||
let file = AgentJSONValue.object([
|
||||
@@ -832,6 +855,7 @@ struct AgentToolPresentationTests {
|
||||
symbol: "terminal",
|
||||
activity: .argument(key: "command", fallback: "the SSH command"),
|
||||
output: .streams,
|
||||
detailPresenterID: "builtin.ssh_exec",
|
||||
groupsWithAdjacentTools: false,
|
||||
messages: ["running": "Connecting to {target}"]
|
||||
)
|
||||
@@ -842,12 +866,59 @@ struct AgentToolPresentationTests {
|
||||
#expect(result.metadata[AgentToolResult.toolPresentationKey] != nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func builtInDetailPresenterCatalogHasUniqueStableIDs() {
|
||||
let ids = AgentBuiltInToolDetailPresenters.ids
|
||||
#expect(Set(ids).count == ids.count)
|
||||
#expect(ids.allSatisfy { $0.hasPrefix("builtin.") })
|
||||
for id in ids {
|
||||
#expect(AgentBuiltInToolDetailPresenters.presenter(id: id) != nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func restoredAndLegacyCardsUseTheSameToolOwnedDetailPresenter() throws {
|
||||
let payload = AgentJSONValue.array([
|
||||
.object([
|
||||
"title": .string("Example"), "url": .string("https://example.com"),
|
||||
"snippet": .string("A result"), "published_at": .null,
|
||||
])
|
||||
])
|
||||
let livePresentation = AgentBuiltInTools.webSearchDetailPresenter.id
|
||||
var persisted = AgentToolResult(callID: "call", content: payload.encodedString)
|
||||
persisted.toolPresentation = .init(
|
||||
symbol: "magnifyingglass",
|
||||
activity: .semanticArgument(key: "query", fallback: .web), output: .json,
|
||||
detailPresenterID: livePresentation, actionKind: .search
|
||||
)
|
||||
|
||||
var restored = makeCard(
|
||||
name: "web_search", target: .network,
|
||||
arguments: ["query": .string("example")],
|
||||
presentation: persisted.toolPresentation
|
||||
)
|
||||
restored.result = persisted
|
||||
|
||||
var legacy = makeCard(
|
||||
name: "web_search", target: .network,
|
||||
arguments: ["query": .string("example")]
|
||||
)
|
||||
legacy.result = persisted
|
||||
legacy.descriptor.presentation = nil
|
||||
|
||||
#expect(
|
||||
AgentToolPresentation.detail(for: restored, locale: Locale(identifier: "en"))
|
||||
== AgentToolPresentation.detail(for: legacy, locale: Locale(identifier: "en"))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func semanticPresentationRoundTripsAndOldMetadataUsesSafeDefaults() throws {
|
||||
let semantic = AgentToolDescriptor.Presentation(
|
||||
symbol: "magnifyingglass",
|
||||
activity: .semanticArgument(key: "query", fallback: .web),
|
||||
output: .json, groupsWithAdjacentTools: false, actionKind: .search,
|
||||
output: .json, detailPresenterID: "builtin.web_search",
|
||||
groupsWithAdjacentTools: false, actionKind: .search,
|
||||
phaseKinds: ["running": .sshConnecting]
|
||||
)
|
||||
let encoded = try JSONEncoder().encode(semantic)
|
||||
@@ -865,6 +936,7 @@ struct AgentToolPresentationTests {
|
||||
object["groupsWithAdjacentTools"] = nil
|
||||
object["actionKind"] = nil
|
||||
object["phaseKinds"] = nil
|
||||
object["detailPresenterID"] = nil
|
||||
let decoded = try JSONDecoder().decode(
|
||||
AgentToolDescriptor.Presentation.self,
|
||||
from: JSONSerialization.data(withJSONObject: object)
|
||||
@@ -872,6 +944,7 @@ struct AgentToolPresentationTests {
|
||||
#expect(decoded.groupsWithAdjacentTools)
|
||||
#expect(decoded.actionKind == nil)
|
||||
#expect(decoded.phaseKinds.isEmpty)
|
||||
#expect(decoded.detailPresenterID == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in new issue
Block a user