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