refactor agent tool detail presentation

This commit is contained in:
Kaede Akino committed 2026-08-27 14:47:10 +08:00
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
)
)
+10 -1
View File
@@ -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
+20 -755
View File
@@ -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)
}
}
}
}
+60
View File
@@ -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(
+74 -1
View File
@@ -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