Improve built-in tool result presentation

This commit is contained in:
Kaede Akino committed 2026-08-27 04:27:33 +08:00
1 parent fa38f89f85
commit cbc34748c3
3 files changed
+1257 -20

No files matched your search

@@ -17,6 +17,66 @@ 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"
}
@@ -184,6 +244,724 @@ nonisolated enum AgentToolPresentation {
return nil
}
/// 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? {
guard let result = card.result else { return nil }
// MCP output is remote-defined and intentionally keeps the established
// JSON presentation. Built-ins below never fall back to JSON syntax.
if card.descriptor.target == .mcp {
guard let output = detailOutput(
from: result, presentation: card.descriptor.presentation,
review: card.review
) else { return nil }
return Detail(items: [
.text(DetailTextBlock(title: nil, text: output, style: .monospaced))
])
}
let raw = result.untrustedPayload ?? result.content
guard let value = try? AgentJSONValue.decode(Data(raw.utf8)) else {
guard let output = detailOutput(
from: result, presentation: card.descriptor.presentation,
review: card.review
) else { return nil }
let text = output.isEmpty ? String(localized: "Completed without output") : output
return Detail(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
)
}
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 = [humanized(kind).capitalized]
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 { humanized($0).capitalized })
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 { humanized($0).capitalized })
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
)
}
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 = humanized(kind).capitalized
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 { humanized($0).capitalized })
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 hostCount(_ count: Int) -> String {
count == 1 ? String(localized: "1 host") : String(localized: "\(count) hosts")
}
private static func itemCount(_ count: Int) -> String {
count == 1 ? String(localized: "1 item") : String(localized: "\(count) items")
}
private static func searchResultCount(_ count: Int) -> String {
count == 1 ? String(localized: "1 result") : String(localized: "\(count) results")
}
private static func label(_ path: [String]) -> String {
let rendered = path.joined(separator: " · ")
return rendered.isEmpty ? String(localized: "Result") : rendered.capitalized
}
/// A bounded rendering projection. The full transformed output stays beside
/// it for Copy, while the Text view only receives the small preview.
static func preview(
@@ -597,26 +597,8 @@ private struct AgentToolRowView: View {
}
if let result = card.result {
if let output = AgentToolPresentation.detailOutput(
from: result,
presentation: card.descriptor.presentation,
review: card.review
) {
let preview = AgentToolPresentation.preview(output)
Text(preview.displayed)
.frame(maxWidth: .infinity, alignment: .leading)
.font(.caption.monospaced())
.foregroundStyle(result.isError ? .red : .secondary)
.textSelection(.enabled)
if preview.isShortened {
HStack(spacing: Metrics.Space.s) {
Text("Output shortened for display")
.font(.caption2)
.foregroundStyle(.secondary)
CopyButton(text: preview.full, title: "Copy Full Output")
}
}
if let detail = AgentToolPresentation.detail(for: card) {
AgentToolDetailView(detail: detail, isError: result.isError)
}
if result.isTruncated {
@@ -646,6 +628,184 @@ private struct AgentToolRowView: View {
}
}
/// Structured, deliberately low-chrome tool output. Protocol keys never reach
/// 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 isError: Bool
var body: some View {
VStack(alignment: .leading, spacing: Metrics.Space.s) {
ForEach(Array(detail.items.enumerated()), id: \.offset) { _, item in
switch item {
case .field(let field):
fieldView(field)
case .message(let message, let tint):
Text(message)
.font(.caption)
.foregroundStyle(color(tint))
.fixedSize(horizontal: false, vertical: true)
.textSelection(.enabled)
case .text(let block):
textBlock(block)
case .list(let list):
listBlock(list)
case .groupedList(let list):
groupedList(list)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func fieldView(_ field: AgentToolPresentation.DetailField) -> some View {
HStack(alignment: .firstTextBaseline, spacing: Metrics.Space.s) {
Text(field.label)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.frame(minWidth: 76, alignment: .leading)
if let rawURL = field.url, let url = URL(string: rawURL) {
Link(field.value, destination: url)
.font(field.isMonospaced ? .caption.monospaced() : .caption)
} else {
Text(field.value)
.font(field.isMonospaced ? .caption.monospaced() : .caption)
.foregroundStyle(isError ? .red : .primary)
.fixedSize(horizontal: false, vertical: true)
.textSelection(.enabled)
}
}
.accessibilityElement(children: .combine)
}
@ViewBuilder
private func textBlock(_ block: AgentToolPresentation.DetailTextBlock) -> some View {
if let title = block.title {
Text(title)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
}
let preview = AgentToolPresentation.preview(block.text)
Text(preview.displayed)
.frame(maxWidth: .infinity, alignment: .leading)
.font(block.style == .monospaced ? .caption.monospaced() : .caption)
.foregroundStyle(isError ? .red : .secondary)
.textSelection(.enabled)
if preview.isShortened {
HStack(spacing: Metrics.Space.s) {
Text("Output shortened for display")
.font(.caption2)
.foregroundStyle(.secondary)
CopyButton(text: preview.full, title: "Copy Full Output")
}
}
}
@ViewBuilder
private func listBlock(_ list: AgentToolPresentation.DetailListBlock) -> some View {
if let title = list.title {
Text(title)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
}
if list.rows.isEmpty, let empty = list.emptyMessage {
Text(empty).font(.caption).foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: Metrics.Space.s) {
ForEach(Array(list.rows.enumerated()), id: \.offset) { _, row in
detailRow(row)
}
}
}
}
@ViewBuilder
private func groupedList(_ list: AgentToolPresentation.DetailGroupedList) -> some View {
Text(list.summary)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
if list.isScrollable {
ScrollView {
groupedListContents(list)
}
.frame(maxHeight: 360)
.scrollBounceBehavior(.basedOnSize)
} else {
groupedListContents(list)
}
}
private func groupedListContents(
_ list: AgentToolPresentation.DetailGroupedList
) -> some View {
LazyVStack(alignment: .leading, spacing: Metrics.Space.m) {
ForEach(Array(list.sections.enumerated()), id: \.offset) { _, section in
VStack(alignment: .leading, spacing: Metrics.Space.s) {
Text(section.title)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
ForEach(Array(section.rows.enumerated()), id: \.offset) { _, row in
detailRow(row)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func detailRow(_ row: AgentToolPresentation.DetailListRow) -> some View {
HStack(alignment: .top, spacing: Metrics.Space.s) {
if let symbol = row.symbol {
Image(systemName: symbol)
.font(.caption)
.foregroundStyle(color(row.tint))
.frame(width: Metrics.Sidebar.rowIcon)
}
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
if let rawURL = row.url, let url = URL(string: rawURL) {
Link(row.title, destination: url)
.font(.caption.weight(.medium))
} else {
Text(row.title).font(.caption.weight(.medium))
}
if let subtitle = row.subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
if let detail = row.detail, !detail.isEmpty {
Text(detail)
.font(.caption2)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.textSelection(.enabled)
}
if !row.badges.isEmpty {
FlowLayout(spacing: Metrics.Space.xs, fillsWidth: false) {
ForEach(Array(row.badges.enumerated()), id: \.offset) { _, badge in
CardChip(text: badge)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.accessibilityElement(children: .combine)
}
private func color(_ tint: AgentToolPresentation.DetailTint) -> Color {
switch tint {
case .secondary: .secondary
case .warning: .orange
case .success: .green
case .failure: .red
}
}
}
// MARK: - Compaction
private struct AgentCompactionActivityRowView: View {
@@ -183,6 +183,292 @@ struct AgentToolPresentationTests {
)
}
@Test
func userInputDetailShowsOneQuestionAndAnswerFieldPerLine() throws {
let arguments: [String: AgentJSONValue] = [
"questions": .array([
.object([
"id": .string("storage"), "header": .string("Storage"),
"prompt": .string("Which format should we use?"),
"options": .array([
.object(["id": .string("jsonb"), "label": .string("JSONB")])
]),
]),
.object([
"id": .string("region"), "prompt": .string("Which region?")
]),
])
]
var card = makeCard(
name: "request_user_input", target: .user, arguments: arguments,
presentation: AgentBuiltInTools.requestUserInput().descriptor.presentation
)
card.state = .completed
card.result = AgentToolResult(
callID: "call",
content: AgentJSONValue.object([
"answers": .array([
.object([
"question_id": .string("storage"), "answer": .string("With audit log"),
"selected_option_ids": .array([.string("jsonb")]),
"custom_text": .string("With audit log"),
]),
.object([
"question_id": .string("region"), "answer": .null,
"selected_option_ids": .array([]), "custom_text": .null,
]),
])
]).encodedString
)
let detail = try #require(AgentToolPresentation.detail(for: card))
#expect(detail.items == [
.field(.init(label: "Question", value: "Storage — Which format should we use?")),
.field(.init(label: "Answer", value: "JSONB, With audit log")),
.field(.init(label: "Question", value: "Which region?")),
.field(.init(label: "Answer", value: "Skipped")),
])
#expect(!String(describing: detail).contains("selected_option_ids"))
#expect(!String(describing: detail).contains("jsonb"))
}
@Test
func hostListingDetailPreservesGroupsAndHidesProtocolIdentifiers() throws {
let productionID = "11111111-1111-1111-1111-111111111111"
let childID = "22222222-2222-2222-2222-222222222222"
let result = AgentJSONValue.object([
"groups": .array([
.object([
"id": .string(productionID), "name": .string("Production"),
"path": .string("Production"),
"children": .array([
.object([
"id": .string(childID), "name": .string("Edge"),
"path": .string("Production / Edge"), "children": .array([]),
])
]),
])
]),
"hosts": .array([
hostJSON(
id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", name: "API Server",
address: "deploy@api.example.com", state: "connected",
tags: ["prod", "nginx"], groupID: productionID
),
hostJSON(
id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", name: "Loose Box",
address: "root@10.0.0.2", state: "failed", tags: [],
groupID: "ffffffff-ffff-ffff-ffff-ffffffffffff"
),
hostJSON(
id: "cccccccc-cccc-cccc-cccc-cccccccccccc", name: "Current Terminal",
address: "root@10.0.0.8", state: "reconnecting", tags: [],
temporary: true
),
]),
])
var card = makeCard(name: "list_hosts", target: .host)
card.state = .completed
card.result = AgentToolResult(callID: "call", content: result.encodedString)
let detail = try #require(AgentToolPresentation.detail(for: card))
guard case .groupedList(let listing) = try #require(detail.items.first) else {
Issue.record("Expected a grouped host listing")
return
}
#expect(listing.summary == "3 hosts · 1 connected · 1 temporary")
#expect(listing.sections.map(\.title) == [
"Production · 1 host", "Production / Edge · 0 hosts",
"Ungrouped · 1 host", "Temporary · 1 host",
])
#expect(listing.sections[0].rows[0].title == "API Server")
#expect(listing.sections[0].rows[0].subtitle == "deploy@api.example.com · Connected")
#expect(listing.sections[0].rows[0].badges == ["prod", "nginx"])
#expect(listing.sections[2].rows[0].subtitle == "root@10.0.0.2 · Failed")
#expect(listing.sections[3].rows[0].subtitle == "root@10.0.0.8 · Reconnecting")
let rendered = String(describing: detail)
#expect(!rendered.contains(productionID))
#expect(!rendered.contains("aaaaaaaa-aaaa"))
#expect(!rendered.contains("group_id"))
#expect(!rendered.contains("connection_state"))
}
@Test
func hostListingBecomesScrollablePastTwelveHosts() throws {
let hosts = (0..<13).map { index in
hostJSON(
id: UUID().uuidString, name: "Host \(index)",
address: "host\(index).example.com", state: "disconnected", tags: []
)
}
var card = makeCard(name: "list_hosts", target: .host)
card.result = AgentToolResult(
callID: "call",
content: AgentJSONValue.object([
"groups": .array([]), "hosts": .array(hosts),
]).encodedString
)
let detail = try #require(AgentToolPresentation.detail(for: card))
guard case .groupedList(let listing) = try #require(detail.items.first) else {
Issue.record("Expected a grouped host listing")
return
}
#expect(listing.isScrollable)
#expect(listing.sections[0].rows.count == 13)
}
@Test
func builtInFallbackUsesFieldsWhileMCPKeepsJSON() throws {
let payload = AgentJSONValue.object([
"internal_flag": .bool(true), "count": .number(2),
]).encodedString
var builtIn = makeCard(name: "future_tool", target: .local)
builtIn.result = AgentToolResult(callID: "call", content: payload)
let builtInDetail = try #require(AgentToolPresentation.detail(for: builtIn))
#expect(builtInDetail.items == [
.field(.init(label: "Count", value: "2")),
.field(.init(label: "Internal Flag", value: "Yes")),
])
#expect(!String(describing: builtInDetail).contains("{"))
var mcp = makeCard(
name: "remote_tool", target: .mcp,
presentation: .init(
symbol: "puzzlepiece.extension", activity: .label("remote tool"), output: .json
)
)
mcp.result = AgentToolResult(callID: "call", content: payload)
let mcpDetail = try #require(AgentToolPresentation.detail(for: mcp))
guard case .text(let block) = try #require(mcpDetail.items.first) else {
Issue.record("Expected MCP JSON text")
return
}
#expect(block.text.contains("{"))
#expect(block.text.contains("\"internal_flag\""))
}
@Test
func everyBuiltInJSONToolProducesStructuredDetailWithoutJSONSyntax() throws {
let file = AgentJSONValue.object([
"path": .string("/tmp/a"), "name": .string("a"), "kind": .string("file"),
"size": .number(4), "permissions": .string("644"), "modified_at": .null,
])
let cases: [(String, AgentToolDescriptor.Target, [String: AgentJSONValue], AgentJSONValue)] = [
("list_hosts", .host, [:], .object(["groups": .array([]), "hosts": .array([])])),
("ssh_exec", .host, [:], .object([
"status": .string("exited"), "stdout": .string("ok"),
"stderr": .string(""), "exit_code": .number(0),
])),
("ssh_wait", .host, [:], .object([
"status": .string("running"), "stdout": .string(""),
"stderr": .string(""), "note": .string("Still running"),
])),
("terminal_suggest", .user, ["command": .string("pwd")], .object([
"command": .string("pwd"), "requires_user_action": .bool(true),
])),
("sftp_list", .host, [:], .array([file])),
("sftp_stat", .host, [:], file),
("sftp_read", .host, [:], .object([
"path": .string("/tmp/a"), "content": .string("text"), "bytes": .number(4),
])),
("sftp_write", .host, ["content": .string("text")], .object([
"path": .string("/tmp/a"), "written": .bool(true),
])),
("sftp_mkdir", .host, [:], .object([
"path": .string("/tmp/new"), "created": .bool(true),
])),
("sftp_move", .host, [:], .object([
"source": .string("/tmp/a"), "destination": .string("/tmp/b"),
"moved": .bool(true),
])),
("sftp_chmod", .host, [:], .object([
"path": .string("/tmp/a"), "mode": .string("644"),
])),
("sftp_delete", .host, ["kind": .string("file")], .object([
"path": .string("/tmp/a"), "deleted": .bool(true),
])),
("fetch", .network, ["method": .string("GET")], .object([
"url": .string("https://example.com"), "status": .number(200),
"headers": .object(["content-type": .string("text/plain")]),
"body": .string("hello"),
])),
("web_search", .network, [:], .array([
.object([
"title": .string("Example"), "url": .string("https://example.com"),
"snippet": .string("Snippet"), "published_at": .null,
])
])),
("request_user_input", .user, [
"questions": .array([
.object(["id": .string("q"), "prompt": .string("Question?")])
])
], .object([
"answers": .array([
.object(["question_id": .string("q"), "answer": .string("Answer")])
])
])),
("request_user_secret", .user, [
"prompt": .string("Password"), "purpose": .string("Login"),
"consumer_tool": .string("ssh_exec"),
], .object(["secret_handle": .string("hidden"), "single_use": .bool(true)])),
("scratch_list", .local, [:], .object([
"entries": .array([]), "entry_count": .number(0),
"entry_quota": .number(100), "total_bytes": .number(0),
"byte_quota": .number(1000),
])),
("scratch_read", .local, [:], .object([
"path": .string("a"), "content": .string("text"), "bytes": .number(4),
"offset": .number(1), "returned_lines": .number(1),
"total_lines": .number(1), "line_ending": .string("lf"),
])),
("scratch_write", .local, [:], .object([
"path": .string("a"), "bytes": .number(4), "sha256": .string("abcd"),
"created": .bool(true), "total_bytes": .number(4),
])),
("scratch_edit", .local, [:], .object([
"path": .string("a"), "edits_applied": .number(1),
"diff": .string("-old\n+new"), "bytes": .number(3),
"line_ending": .string("lf"), "sha256": .string("abcd"),
"total_bytes": .number(3),
])),
("scratch_diff", .local, [:], .object([
"path_a": .string("a"), "path_b": .string("b"),
"identical": .bool(true), "diff": .string(""),
"line_ending_a": .string("lf"), "line_ending_b": .string("lf"),
])),
("scratch_delete", .local, [:], .object([
"path": .string("a"), "kind": .string("file"), "deleted": .bool(true),
"entry_count": .number(0), "total_bytes": .number(0),
])),
("scratch_pull", .host, [:], .object([
"remote_path": .string("/tmp/a"), "scratch_path": .string("a"),
"bytes": .number(4), "sha256": .string("abcd"),
"is_utf8": .bool(true), "lines": .number(1), "total_bytes": .number(4),
])),
("scratch_push", .host, [:], .object([
"scratch_path": .string("a"), "remote_path": .string("/tmp/a"),
"bytes": .number(4), "sha256": .string("abcd"), "written": .bool(true),
])),
("present_plan", .user, [:], .object([
"presented": .bool(true), "scratch_path": .string("plan.md"),
"plan": .string("Plan"),
])),
("manage_tasks", .user, [:], .object([
"tasks": .number(3), "completed": .number(1),
])),
]
for (name, target, arguments, result) in cases {
var card = makeCard(name: name, target: target, arguments: arguments)
card.result = AgentToolResult(callID: "call", content: result.encodedString)
let detail = try #require(AgentToolPresentation.detail(for: card), "Missing \(name)")
let rendered = String(describing: detail)
#expect(!rendered.contains("{"), "\(name) exposed JSON")
#expect(!rendered.contains("}"), "\(name) exposed JSON")
}
}
@Test
func finishedBatchSummaryCountsRepeatedActivitiesAndPreservesFailures() {
let questionPresentation = AgentBuiltInTools.requestUserInput().descriptor.presentation
@@ -479,4 +765,17 @@ struct AgentToolPresentationTests {
)
)
}
private func hostJSON(
id: String, name: String, address: String, state: String, tags: [String],
groupID: String? = nil, temporary: Bool = false
) -> AgentJSONValue {
.object([
"id": .string(id), "display_name": .string(name), "address": .string(address),
"tags": .array(tags.map(AgentJSONValue.string)),
"connection_state": .string(state), "temporary": .bool(temporary),
"group_id": groupID.map(AgentJSONValue.string) ?? .null,
"group_path": .null,
])
}
}