diff --git a/Termind/Features/AI/AgentToolPresentation.swift b/Termind/Features/AI/AgentToolPresentation.swift index 7815ff5b..164b75b9 100644 --- a/Termind/Features/AI/AgentToolPresentation.swift +++ b/Termind/Features/AI/AgentToolPresentation.swift @@ -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( diff --git a/Termind/Features/AI/ConversationTranscriptView.swift b/Termind/Features/AI/ConversationTranscriptView.swift index 18c9991a..2038a57e 100644 --- a/Termind/Features/AI/ConversationTranscriptView.swift +++ b/Termind/Features/AI/ConversationTranscriptView.swift @@ -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 { diff --git a/TermindTests/AgentToolPresentationTests.swift b/TermindTests/AgentToolPresentationTests.swift index 8c1c1d3f..2e527092 100644 --- a/TermindTests/AgentToolPresentationTests.swift +++ b/TermindTests/AgentToolPresentationTests.swift @@ -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, + ]) + } }