From c8229ff64517bfd322ebfdbdea00ab94874fc5ff Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Thu, 27 Aug 2026 14:13:07 +0800 Subject: [PATCH] Improve Simplified Chinese tool presentation and localization --- Scripts/check-zh-localization.sh | 63 ++ Termind/App/AppLanguage.swift | 30 + .../Agent/AgentBuiltInSFTPTools.swift | 28 +- .../Agent/AgentBuiltInSSHTools.swift | 28 +- .../Agent/AgentBuiltInUserTools.swift | 10 +- .../Agent/AgentBuiltInWebTools.swift | 9 +- Termind/Application/Agent/AgentMCPTools.swift | 2 +- .../Application/Agent/AgentPlanTools.swift | 8 +- .../Application/Agent/AgentScratchTools.swift | 33 +- Termind/Application/Agent/AgentSecurity.swift | 9 + .../Application/Agent/AgentTurnDriver.swift | 16 +- Termind/Domain/Agent/AgentTypes.swift | 60 +- .../Features/AI/AgentToolPresentation.swift | 761 ++++++++++++++++-- Termind/Features/AI/ConversationModel.swift | 24 +- .../AI/ConversationTranscriptView.swift | 61 +- Termind/Features/AI/ConversationViews.swift | 5 +- .../Features/AI/MarkdownCodeBlockView.swift | 2 +- .../Features/Assistant/AssistantView.swift | 5 +- Termind/Resources/Localizable.xcstrings | 304 ++++--- TermindTests/AgentToolPresentationTests.swift | 195 +++++ TermindTests/AppLanguageTests.swift | 2 +- TermindTests/ChatComposerStateTests.swift | 41 +- 22 files changed, 1435 insertions(+), 261 deletions(-) create mode 100755 Scripts/check-zh-localization.sh diff --git a/Scripts/check-zh-localization.sh b/Scripts/check-zh-localization.sh new file mode 100755 index 00000000..51acc981 --- /dev/null +++ b/Scripts/check-zh-localization.sh @@ -0,0 +1,63 @@ +#!/bin/bash + +set -euo pipefail + +script_dir=$(cd "$(dirname "$0")" && pwd) +catalog=${1:-"$script_dir/../Termind/Resources/Localizable.xcstrings"} + +if ! jq empty "$catalog"; then + echo "Invalid string catalog: $catalog" >&2 + exit 1 +fi + +missing=$(jq -r ' + .strings | to_entries[] + | select(.value.localizations."zh-Hans" == null) + | .key +' "$catalog") +if [[ -n "$missing" ]]; then + echo "Missing zh-Hans localizations:" >&2 + echo "$missing" >&2 + exit 1 +fi + +unfinished=$(jq -r ' + .strings | to_entries[] as $entry + | [ + $entry.value.localizations."zh-Hans" + | .. | objects | .stringUnit? + | select(. != null and .state != "translated") + ] + | select(length > 0) + | $entry.key +' "$catalog") +if [[ -n "$unfinished" ]]; then + echo "Unfinished zh-Hans localizations:" >&2 + echo "$unfinished" >&2 + exit 1 +fi + +violations=$(jq -r ' + .strings | to_entries[] as $entry + | [ + ($entry.value.localizations."zh-Hans" // {}) + | .. | objects | .stringUnit?.value? // empty + ] + | .[] + | select( + test("\\p{Han}") + and ( + test("\\p{Han}[ \\t]+[A-Za-z0-9%@`]|[A-Za-z0-9%@`][ \\t]+\\p{Han}") + or test("[ \\t]+[—·→:,。!?;、/:]|[—·→:,。!?;、/:][ \\t]+") + or test("\\p{Han}[,.!?;]|[,.!?;]\\p{Han}") + ) + ) + | "\($entry.key): \(.)" +' "$catalog") +if [[ -n "$violations" ]]; then + echo "Invalid zh-Hans spacing:" >&2 + echo "$violations" >&2 + exit 1 +fi + +echo "zh-Hans localization checks passed." diff --git a/Termind/App/AppLanguage.swift b/Termind/App/AppLanguage.swift index ded868d8..e1c837f2 100644 --- a/Termind/App/AppLanguage.swift +++ b/Termind/App/AppLanguage.swift @@ -4,6 +4,36 @@ import Foundation import AppKit #endif +/// Resolves an explicitly requested app language instead of relying on the +/// process-wide bundle negotiation. `String(localized:locale:)` uses `locale` +/// for formatting, but an already-running app (and the test host) can still +/// have a different preferred localization. Loading the matching lproj bundle +/// keeps locale-injectable presentation code deterministic. +nonisolated enum AppLocalization { + static func string( + _ key: String.LocalizationValue, locale: Locale = .current + ) -> String { + String( + localized: key, + bundle: bundle(for: locale), + locale: locale + ) + } + + private static func bundle(for locale: Locale) -> Bundle { + let localization: String? = switch locale.language.languageCode?.identifier { + case "zh": "zh-Hans" + case "en": "en" + default: nil + } + guard let localization, + let path = Bundle.main.path(forResource: localization, ofType: "lproj"), + let bundle = Bundle(path: path) + else { return .main } + return bundle + } +} + nonisolated enum AppLanguage: String, CaseIterable, Identifiable, Sendable { case system case english = "en" diff --git a/Termind/Application/Agent/AgentBuiltInSFTPTools.swift b/Termind/Application/Agent/AgentBuiltInSFTPTools.swift index ecef3a0d..669e2aba 100644 --- a/Termind/Application/Agent/AgentBuiltInSFTPTools.swift +++ b/Termind/Application/Agent/AgentBuiltInSFTPTools.swift @@ -212,23 +212,20 @@ nonisolated extension AgentBuiltInTools { case "sftp_delete": "trash" default: "externaldrive.connected.to.line.below" } - let action: AgentToolDescriptor.Presentation.Action = switch name { - case "sftp_list": .init(infinitive: "list", progressive: "Listing", past: "Listed") - case "sftp_stat": .init(infinitive: "inspect", progressive: "Inspecting", past: "Inspected") - case "sftp_read": .init(infinitive: "read", progressive: "Reading", past: "Read") - case "sftp_write": .init(infinitive: "write", progressive: "Writing", past: "Wrote") - case "sftp_mkdir": .init(infinitive: "create", progressive: "Creating", past: "Created") - case "sftp_move": .init(infinitive: "move", progressive: "Moving", past: "Moved") - case "sftp_chmod": .init( - infinitive: "change permissions for", progressive: "Changing permissions for", - past: "Changed permissions for" - ) - case "sftp_delete": .init(infinitive: "delete", progressive: "Deleting", past: "Deleted") - default: .init(infinitive: "use", progressive: "Using", past: "Used") + let actionKind: AgentToolDescriptor.Presentation.ActionKind = switch name { + case "sftp_list": .list + case "sftp_stat": .inspect + case "sftp_read": .read + case "sftp_write": .write + case "sftp_mkdir": .create + case "sftp_move": .move + case "sftp_chmod": .changePermissions + case "sftp_delete": .delete + default: .use } return .init( - symbol: symbol, activity: .argument(key: "path", fallback: summary), output: .automatic, - action: action, includesTarget: true + symbol: symbol, activity: .semanticArgument(key: "path", fallback: .remoteFile), + output: .automatic, actionKind: actionKind, includesTarget: true ) } @@ -307,4 +304,3 @@ nonisolated extension AgentBuiltInTools { ]) } } - diff --git a/Termind/Application/Agent/AgentBuiltInSSHTools.swift b/Termind/Application/Agent/AgentBuiltInSSHTools.swift index 4d1959e0..908ae3fa 100644 --- a/Termind/Application/Agent/AgentBuiltInSSHTools.swift +++ b/Termind/Application/Agent/AgentBuiltInSSHTools.swift @@ -7,8 +7,8 @@ nonisolated extension AgentBuiltInTools { properties: [:], required: [], target: .host, safety: .locallyReadOnly, concurrency: .parallel, presentation: .init( - symbol: "server.rack", activity: .label("hosts"), output: .json, - action: .init(infinitive: "list", progressive: "Listing", past: "Listed") + symbol: "server.rack", activity: .semanticLabel(.hosts), output: .json, + actionKind: .list ) )) { invocation, _ in let listing = await hosts.listing() @@ -98,10 +98,8 @@ nonisolated extension AgentBuiltInTools { concurrency: .parallel, presentation: .init( symbol: "clock.arrow.circlepath", - activity: .label("the remote command"), output: .streams, - action: .init( - infinitive: "wait for", progressive: "Waiting for", past: "Waited for" - ) + activity: .semanticLabel(.remoteCommand), output: .streams, + actionKind: .wait ) ), execute: { invocation, context in @@ -151,8 +149,20 @@ nonisolated extension AgentBuiltInTools { ], required: ["host_id", "command"], target: .host, safety: .requiresAuthorization, presentation: .init( symbol: "terminal", - activity: .argument(key: "command", fallback: "the SSH command"), + activity: .semanticArgument(key: "command", fallback: .sshCommand), output: .streams, + actionKind: .run, + phaseKinds: [ + "proposed": .sshPreparingCommand, + "waitingForApproval": .sshWaitingForCommand, + "waitingForUser": .sshWaitingForAuthentication, + "running": .sshConnecting, + "command": .sshRunningCommand, + "completed": .sshCompletedCommand, + "denied": .sshDeniedCommand, + "failed": .sshFailedCommand, + "interrupted": .sshInterruptedCommand, + ], messages: [ "proposed": "Preparing to run “{activity}” on {target}", "waitingForApproval": "Waiting to run “{activity}” on {target}", @@ -337,9 +347,9 @@ nonisolated extension AgentBuiltInTools { // would do if the user ran it is the user's action, later. concurrency: .parallel, presentation: .init( - symbol: "terminal", activity: .argument(key: "command", fallback: "a command"), + symbol: "terminal", activity: .semanticArgument(key: "command", fallback: .command), groupsWithAdjacentTools: false, - action: .init(infinitive: "suggest", progressive: "Suggesting", past: "Suggested") + actionKind: .suggest ) )) { invocation, _ in let arguments = try Arguments(invocation) diff --git a/Termind/Application/Agent/AgentBuiltInUserTools.swift b/Termind/Application/Agent/AgentBuiltInUserTools.swift index ea8bd65e..5c9879ff 100644 --- a/Termind/Application/Agent/AgentBuiltInUserTools.swift +++ b/Termind/Application/Agent/AgentBuiltInUserTools.swift @@ -57,10 +57,10 @@ nonisolated extension AgentBuiltInTools { ], required: ["questions"], target: .user, safety: .locallyReadOnly, presentation: .init( symbol: "questionmark.circle", - activity: .localizedArrayCount( - key: "questions", unit: .question, fallback: "a question" + activity: .semanticArrayCount( + key: "questions", unit: .question, fallback: .question ), - action: .init(infinitive: "ask", progressive: "Asking", past: "Asked") + actionKind: .ask ) )) { invocation, context in let arguments = try Arguments(invocation) @@ -151,8 +151,8 @@ nonisolated extension AgentBuiltInTools { // means output arriving under a question that is still being read. target: .user, safety: .locallyReadOnly, presentation: .init( - symbol: "key", activity: .argument(key: "prompt", fallback: "a secret"), - action: .init(infinitive: "request", progressive: "Requesting", past: "Requested") + symbol: "key", activity: .semanticArgument(key: "prompt", fallback: .secret), + actionKind: .request ) )) { invocation, context in let arguments = try Arguments(invocation) diff --git a/Termind/Application/Agent/AgentBuiltInWebTools.swift b/Termind/Application/Agent/AgentBuiltInWebTools.swift index 05149339..c53ef5bb 100644 --- a/Termind/Application/Agent/AgentBuiltInWebTools.swift +++ b/Termind/Application/Agent/AgentBuiltInWebTools.swift @@ -23,9 +23,9 @@ nonisolated extension AgentBuiltInTools { // calls pay for themselves. concurrency: .parallel, presentation: .init( - symbol: "globe", activity: .argument(key: "url", fallback: "a URL"), + symbol: "globe", activity: .semanticArgument(key: "url", fallback: .url), output: .field("body"), - action: .init(infinitive: "fetch", progressive: "Fetching", past: "Fetched") + actionKind: .fetch ) ) return AnyAgentTool( @@ -116,8 +116,8 @@ nonisolated extension AgentBuiltInTools { concurrency: .parallel, presentation: .init( symbol: "magnifyingglass", - activity: .argument(key: "query", fallback: "the web"), output: .json, - action: .init(infinitive: "search", progressive: "Searching", past: "Searched") + activity: .semanticArgument(key: "query", fallback: .web), output: .json, + actionKind: .search ) ) return AnyAgentTool( @@ -152,4 +152,3 @@ nonisolated extension AgentBuiltInTools { ) } } - diff --git a/Termind/Application/Agent/AgentMCPTools.swift b/Termind/Application/Agent/AgentMCPTools.swift index c4484a1d..6b6f3474 100644 --- a/Termind/Application/Agent/AgentMCPTools.swift +++ b/Termind/Application/Agent/AgentMCPTools.swift @@ -47,7 +47,7 @@ nonisolated enum AgentMCPTools { namespace: server.effectiveNamespaceID, function: name )), output: .json, - action: .init(infinitive: "run", progressive: "Running", past: "Ran") + actionKind: .run ) ) return AnyAgentTool( diff --git a/Termind/Application/Agent/AgentPlanTools.swift b/Termind/Application/Agent/AgentPlanTools.swift index a12498d0..991a7995 100644 --- a/Termind/Application/Agent/AgentPlanTools.swift +++ b/Termind/Application/Agent/AgentPlanTools.swift @@ -76,8 +76,8 @@ nonisolated enum AgentPlanTools: AgentToolSchemaBuilding { required: ["title", "scratch_path"], target: .user, safety: .locallyReadOnly, presentation: .init( symbol: "lightbulb.max", - activity: .argument(key: "title", fallback: "a plan"), output: .field("plan"), - action: .init(infinitive: "present", progressive: "Writing", past: "Presented") + activity: .semanticArgument(key: "title", fallback: .plan), output: .field("plan"), + actionKind: .present ) )) { invocation, _ in let arguments = try Arguments(invocation) @@ -135,9 +135,9 @@ nonisolated enum AgentPlanTools: AgentToolSchemaBuilding { properties: ["tasks": array(items: task, max: AgentTaskList.maximumTasks)], required: ["tasks"], target: .user, safety: .locallyReadOnly, presentation: .init( - symbol: "checklist", activity: .label(String(localized: "the task list")), + symbol: "checklist", activity: .semanticLabel(.taskList), output: .json, - action: .init(infinitive: "update", progressive: "Updating", past: "Updated") + actionKind: .update ) )) { invocation, _ in let updated = try Arguments(invocation).agentTasks() diff --git a/Termind/Application/Agent/AgentScratchTools.swift b/Termind/Application/Agent/AgentScratchTools.swift index 5c450f6e..26f0e11d 100644 --- a/Termind/Application/Agent/AgentScratchTools.swift +++ b/Termind/Application/Agent/AgentScratchTools.swift @@ -58,9 +58,10 @@ nonisolated enum AgentScratchTools: AgentToolSchemaBuilding { properties: ["path": string(max: pathBytes), "recursive": boolean()], required: [], target: .local, safety: .locallyReadOnly, concurrency: .parallel, presentation: .init( - symbol: "folder", activity: .argument(key: "path", fallback: "the scratch workspace"), + symbol: "folder", + activity: .semanticArgument(key: "path", fallback: .scratchWorkspace), output: .json, - action: .init(infinitive: "list", progressive: "Listing", past: "Listed") + actionKind: .list ) )) { invocation, _ in let arguments = try Arguments(invocation) @@ -101,9 +102,9 @@ nonisolated enum AgentScratchTools: AgentToolSchemaBuilding { ], required: ["path"], target: .local, safety: .locallyReadOnly, concurrency: .parallel, presentation: .init( - symbol: "doc.text", activity: .argument(key: "path", fallback: "a scratch file"), + symbol: "doc.text", activity: .semanticArgument(key: "path", fallback: .scratchFile), output: .field("content"), - action: .init(infinitive: "read", progressive: "Reading", past: "Read") + actionKind: .read ) )) { invocation, _ in let arguments = try Arguments(invocation) @@ -137,8 +138,8 @@ nonisolated enum AgentScratchTools: AgentToolSchemaBuilding { concurrency: .parallel, presentation: .init( symbol: "square.and.pencil", - activity: .argument(key: "path", fallback: "a scratch file"), output: .json, - action: .init(infinitive: "write", progressive: "Writing", past: "Wrote") + activity: .semanticArgument(key: "path", fallback: .scratchFile), output: .json, + actionKind: .write ) )) { invocation, _ in let arguments = try Arguments(invocation) @@ -185,9 +186,9 @@ nonisolated enum AgentScratchTools: AgentToolSchemaBuilding { required: ["path", "edits"], target: .local, safety: .locallyContained, concurrency: .parallel, presentation: .init( - symbol: "pencil", activity: .argument(key: "path", fallback: "a scratch file"), + symbol: "pencil", activity: .semanticArgument(key: "path", fallback: .scratchFile), output: .field("diff"), - action: .init(infinitive: "edit", progressive: "Editing", past: "Edited") + actionKind: .edit ) )) { invocation, _ in let arguments = try Arguments(invocation) @@ -226,8 +227,8 @@ nonisolated enum AgentScratchTools: AgentToolSchemaBuilding { concurrency: .parallel, presentation: .init( symbol: "arrow.left.arrow.right", - activity: .argument(key: "path_a", fallback: "scratch files"), output: .field("diff"), - action: .init(infinitive: "compare", progressive: "Comparing", past: "Compared") + activity: .semanticArgument(key: "path_a", fallback: .scratchFiles), + output: .field("diff"), actionKind: .compare ) )) { invocation, _ in let arguments = try Arguments(invocation) @@ -264,9 +265,9 @@ nonisolated enum AgentScratchTools: AgentToolSchemaBuilding { required: ["path", "kind"], target: .local, safety: .locallyContained, concurrency: .parallel, presentation: .init( - symbol: "trash", activity: .argument(key: "path", fallback: "a scratch entry"), + symbol: "trash", activity: .semanticArgument(key: "path", fallback: .scratchEntry), output: .json, - action: .init(infinitive: "delete", progressive: "Deleting", past: "Deleted") + actionKind: .delete ) )) { invocation, _ in let arguments = try Arguments(invocation) @@ -311,8 +312,8 @@ nonisolated enum AgentScratchTools: AgentToolSchemaBuilding { safety: .locallyContained, concurrency: .parallel, presentation: .init( symbol: "arrow.down.doc", - activity: .argument(key: "remote_path", fallback: "a remote file"), output: .json, - action: .init(infinitive: "pull", progressive: "Pulling", past: "Pulled"), + activity: .semanticArgument(key: "remote_path", fallback: .remoteFile), + output: .json, actionKind: .pull, includesTarget: true ) ) @@ -380,8 +381,8 @@ nonisolated enum AgentScratchTools: AgentToolSchemaBuilding { safety: .requiresAuthorization, presentation: .init( symbol: "arrow.up.doc", - activity: .argument(key: "remote_path", fallback: "a remote file"), output: .json, - action: .init(infinitive: "push", progressive: "Pushing", past: "Pushed"), + activity: .semanticArgument(key: "remote_path", fallback: .remoteFile), + output: .json, actionKind: .push, includesTarget: true ) ) diff --git a/Termind/Application/Agent/AgentSecurity.swift b/Termind/Application/Agent/AgentSecurity.swift index b28e4d74..0764650e 100644 --- a/Termind/Application/Agent/AgentSecurity.swift +++ b/Termind/Application/Agent/AgentSecurity.swift @@ -15,6 +15,15 @@ nonisolated enum AgentApprovalDecision: Hashable, Sendable { nonisolated enum SecurityReviewRisk: String, Hashable, Sendable, Codable { case low, medium, high, critical + + var displayName: String { + switch self { + case .low: String(localized: "Low") + case .medium: String(localized: "Medium") + case .high: String(localized: "High") + case .critical: String(localized: "Critical") + } + } } nonisolated enum SecurityReviewVerdict: String, Hashable, Sendable, Codable { diff --git a/Termind/Application/Agent/AgentTurnDriver.swift b/Termind/Application/Agent/AgentTurnDriver.swift index 4b283bad..9307f2bb 100644 --- a/Termind/Application/Agent/AgentTurnDriver.swift +++ b/Termind/Application/Agent/AgentTurnDriver.swift @@ -191,15 +191,14 @@ nonisolated struct AgentTurnDriver: Sendable { concurrency: .parallel, presentation: .init( symbol: "magnifyingglass", - activity: .argument(key: "query", fallback: "the web"), output: .json, - action: .init(infinitive: "search", progressive: "Searching", past: "Searched") + activity: .semanticArgument(key: "query", fallback: .web), output: .json, + actionKind: .search ) ) let sources = AgentJSONValue.array(activity.sources.map { source in .object(["title": .string(source.title), "url": .string(source.url)]) }) - channel.emit(.toolProposed(invocation, descriptor)) - channel.emit(.toolFinished(invocation, AgentToolResult( + var result = AgentToolResult( callID: invocation.call.id, content: sources.encodedString, // The flag every reader of this event needs: `callID` here names @@ -207,6 +206,13 @@ nonisolated struct AgentTurnDriver: Sendable { // results must leave this one out. See // `AgentToolResult.isProviderNative`. metadata: [AgentToolResult.providerNativeKey: .bool(true)] - ))) + ) + // Native searches bypass AgentToolExecutor, whose normal finish path + // persists presentation metadata. Store it here as well so a restored + // card can still derive its activity from `query` instead of exposing + // the internal `web_search` protocol name. + result.toolPresentation = descriptor.presentation + channel.emit(.toolProposed(invocation, descriptor)) + channel.emit(.toolFinished(invocation, result)) } } diff --git a/Termind/Domain/Agent/AgentTypes.swift b/Termind/Domain/Agent/AgentTypes.swift index 74390e74..806f0f3a 100644 --- a/Termind/Domain/Agent/AgentTypes.swift +++ b/Termind/Domain/Agent/AgentTypes.swift @@ -72,6 +72,30 @@ nonisolated enum AgentJSONValue: Hashable, Sendable, Codable { nonisolated struct AgentToolDescriptor: Identifiable, Hashable, Sendable, Codable { nonisolated struct Presentation: Hashable, Sendable, Codable { + /// Stable application-owned semantics. Display strings are derived at + /// render time so a persisted card follows the viewer's current locale + /// instead of freezing whichever English template created it. + nonisolated enum ActionKind: String, Hashable, Sendable, Codable { + case ask, compare, create, delete, edit, fetch, inspect, list, move + case present, pull, push, read, request, run, search, suggest, update + case wait, write, changePermissions, use + } + + nonisolated enum ActivityKind: String, Hashable, Sendable, Codable { + case command, hosts, plan, question, remoteCommand, remoteFile, scratchEntry + case scratchFile, scratchFiles, scratchWorkspace, secret, sshCommand, taskList + case url, web + } + + /// Semantic overrides for phases whose grammar is more specific than + /// the ordinary action lifecycle. Currently SSH is the only built-in + /// with connection and authentication phases of its own. + nonisolated enum PhaseKind: String, Hashable, Sendable, Codable { + case sshPreparingCommand, sshWaitingForCommand, sshWaitingForAuthentication + case sshConnecting, sshRunningCommand, sshCompletedCommand + case sshDeniedCommand, sshFailedCommand, sshInterruptedCommand + } + nonisolated struct Action: Hashable, Sendable, Codable { var infinitive: String var progressive: String @@ -87,9 +111,15 @@ nonisolated struct AgentToolDescriptor: Identifiable, Hashable, Sendable, Codabl nonisolated enum Activity: Hashable, Sendable, Codable { case label(String) case argument(key: String, fallback: String) + /// Persisted by releases before counted activities became + /// semantic. Keep decoding it so old conversations remain usable. + case arrayCount(key: String, singular: String, plural: String, fallback: String) /// Names an activity from the number of entries in an array /// argument. The unit selects a locale-aware plural resource. case localizedArrayCount(key: String, unit: CountedUnit, fallback: String) + case semanticLabel(ActivityKind) + case semanticArgument(key: String, fallback: ActivityKind) + case semanticArrayCount(key: String, unit: CountedUnit, fallback: ActivityKind) } nonisolated enum Output: Hashable, Sendable, Codable { @@ -105,6 +135,8 @@ nonisolated struct AgentToolDescriptor: Identifiable, Hashable, Sendable, Codabl /// 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 + var actionKind: ActionKind? = nil + var phaseKinds: [String: PhaseKind] = [:] /// Templates keyed by card state or a tool-reported live phase. /// Supported placeholders are `{activity}` and `{target}`. var messages: [String: String] = [:] @@ -112,13 +144,17 @@ nonisolated struct AgentToolDescriptor: Identifiable, Hashable, Sendable, Codabl init( symbol: String, activity: Activity, output: Output = .automatic, groupsWithAdjacentTools: Bool = true, - action: Action? = nil, includesTarget: Bool = false, + action: Action? = nil, actionKind: ActionKind? = nil, + includesTarget: Bool = false, + phaseKinds: [String: PhaseKind] = [:], messages overrides: [String: String] = [:] ) { self.symbol = symbol self.activity = activity self.output = output self.groupsWithAdjacentTools = groupsWithAdjacentTools + self.actionKind = actionKind + self.phaseKinds = phaseKinds let target = includesTarget ? " on {target}" : "" if let action { messages = [ @@ -135,6 +171,28 @@ nonisolated struct AgentToolDescriptor: Identifiable, Hashable, Sendable, Codabl messages.merge(overrides) { _, override in override } } + private enum CodingKeys: String, CodingKey { + case symbol, activity, output, groupsWithAdjacentTools, messages + case actionKind, phaseKinds + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + 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 + groupsWithAdjacentTools = try container.decodeIfPresent( + Bool.self, forKey: .groupsWithAdjacentTools + ) ?? true + messages = try container.decodeIfPresent( + [String: String].self, forKey: .messages + ) ?? [:] + actionKind = try container.decodeIfPresent(ActionKind.self, forKey: .actionKind) + phaseKinds = try container.decodeIfPresent( + [String: PhaseKind].self, forKey: .phaseKinds + ) ?? [:] + } + } nonisolated enum Target: String, Hashable, Sendable, Codable { diff --git a/Termind/Features/AI/AgentToolPresentation.swift b/Termind/Features/AI/AgentToolPresentation.swift index 9d39f019..8dca62bb 100644 --- a/Termind/Features/AI/AgentToolPresentation.swift +++ b/Termind/Features/AI/AgentToolPresentation.swift @@ -130,9 +130,10 @@ nonisolated enum AgentToolPresentation { static func summary( for card: AgentToolCard, - hostName: String? = nil + hostName: String? = nil, + locale: Locale = .current ) -> String { - let operation = operationName(for: card) + let operation = operation(for: card, locale: locale) let phase: String if card.state == .running { phase = card.result?.presentationPhase ?? card.state.rawValue @@ -141,8 +142,20 @@ nonisolated enum AgentToolPresentation { } else { phase = card.state.rawValue } + if let phaseKind = phaseKind(for: card, phase: phase) { + return phaseSummary( + phaseKind, activity: operation.text, target: hostName, locale: locale + ) + } + if let actionKind = actionKind(for: card) { + return actionSummary( + actionKind, state: card.state, activity: operation.text, + isArgumentValue: operation.isArgumentValue, + target: hostName, locale: locale + ) + } if let template = card.descriptor.presentation?.messages[phase] { - var rendered = template.replacingOccurrences(of: "{activity}", with: operation) + var rendered = template.replacingOccurrences(of: "{activity}", with: operation.text) if let hostName, !hostName.isEmpty { rendered = rendered.replacingOccurrences(of: "{target}", with: hostName) } else { @@ -159,18 +172,22 @@ nonisolated enum AgentToolPresentation { let action: String switch card.state { - case .proposed: action = String(localized: "Preparing \(operation)") - case .waitingForApproval: action = String(localized: "Waiting to run \(operation)") - case .waitingForUser: action = String(localized: "Waiting to run \(operation)") - case .reviewing: action = String(localized: "Preparing \(operation)") - case .running: action = String(localized: "Running \(operation)") - case .completed: action = String(localized: "Ran \(operation)") - case .denied: action = String(localized: "Denied \(operation)") - case .failed: action = String(localized: "Failed \(operation)") - case .interrupted: action = String(localized: "Interrupted \(operation)") + case .proposed: action = localized("Preparing \(operation.text)", locale: locale) + case .waitingForApproval: + action = localized("Waiting to run \(operation.text)", locale: locale) + case .waitingForUser: + action = localized("Waiting to run \(operation.text)", locale: locale) + case .reviewing: action = localized("Preparing \(operation.text)", locale: locale) + case .running: action = localized("Running \(operation.text)", locale: locale) + case .completed: action = localized("Ran \(operation.text)", locale: locale) + case .denied: action = localized("Denied \(operation.text)", locale: locale) + case .failed: action = localized("Failed \(operation.text)", locale: locale) + case .interrupted: action = localized("Interrupted \(operation.text)", locale: locale) } - if let hostName, !hostName.isEmpty { return String(localized: "\(action) on \(hostName)") } + if let hostName, !hostName.isEmpty { + return localized("\(action) on \(hostName)", locale: locale) + } return action } @@ -199,7 +216,9 @@ nonisolated enum AgentToolPresentation { /// metadata as its individual rows but with repeated activities counted. /// Calls in different terminal states stay separate, so one failure can /// never be rewritten as a successful action by the batch title. - static func batchSummary(for cards: [AgentToolCard]) -> String { + static func batchSummary( + for cards: [AgentToolCard], locale: Locale = .current + ) -> String { struct Key: Hashable { let name: String let state: AgentToolCard.State @@ -212,8 +231,13 @@ nonisolated enum AgentToolPresentation { if grouped[key] == nil { order.append(key) } grouped[key, default: []].append(card) } - return order.compactMap { grouped[$0].map(batchClause) } - .enumerated() + let clauses = order.compactMap { key in + grouped[key].map { batchClause($0, locale: locale) } + } + if locale.language.languageCode?.identifier == "zh" { + return clauses.joined(separator: ",") + } + return clauses.enumerated() .map { index, clause in index == 0 ? clause : lowercaseFirst(clause) } .joined(separator: ", ") } @@ -479,7 +503,7 @@ nonisolated enum AgentToolPresentation { ?? nonempty(path.split(separator: "/").last.map(String.init)) ?? String(localized: "Unnamed item") let kind = object["kind"]?.stringValue ?? "" - var metadata = [humanized(kind).capitalized] + var metadata = [kindLabel(kind)] if let bytes = object["size"]?.integerValue { metadata.append(byteCount(bytes)) } if let permissions = nonempty(object["permissions"]?.stringValue) { metadata.append(permissions) @@ -495,7 +519,7 @@ nonisolated enum AgentToolPresentation { guard let object = value.objectValue else { return genericItems(value) } var items: [DetailItem] = [] appendField(&items, "Path", object["path"]?.stringValue, monospaced: true) - appendField(&items, "Kind", object["kind"]?.stringValue.map { humanized($0).capitalized }) + 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)) @@ -559,7 +583,7 @@ nonisolated enum AgentToolPresentation { guard let object = value.objectValue else { return genericItems(value) } var items: [DetailItem] = [] appendField(&items, "Path", object["path"]?.stringValue, monospaced: true) - appendField(&items, "Kind", arguments["kind"]?.stringValue.map { humanized($0).capitalized }) + appendField(&items, "Kind", arguments["kind"]?.stringValue.map(kindLabel)) items.append(field("Result", String(localized: "Deleted"))) return items } @@ -702,7 +726,7 @@ nonisolated enum AgentToolPresentation { guard let fields = entry.objectValue else { return nil } let path = fields["path"]?.stringValue ?? String(localized: "Unnamed entry") let kind = fields["kind"]?.stringValue ?? "" - var subtitle = humanized(kind).capitalized + var subtitle = kindLabel(kind) if let bytes = fields["bytes"]?.integerValue { subtitle += " · \(byteCount(bytes))" } return DetailListRow( title: path, subtitle: subtitle, @@ -793,7 +817,7 @@ nonisolated enum AgentToolPresentation { private static func scratchDelete(_ value: AgentJSONValue) -> [DetailItem] { guard let object = value.objectValue else { return genericItems(value) } var items = pathResult(value, result: String(localized: "Deleted")) - appendField(&items, "Kind", object["kind"]?.stringValue.map { humanized($0).capitalized }) + 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 { @@ -945,6 +969,15 @@ nonisolated enum AgentToolPresentation { } } + 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") } @@ -1084,46 +1117,238 @@ nonisolated enum AgentToolPresentation { return prettyJSON(value) } - private static func operationName(for card: AgentToolCard) -> String { + private struct RenderedOperation { + var text: String + var isArgumentValue: Bool + } + + private static func operation( + for card: AgentToolCard, locale: Locale + ) -> RenderedOperation { guard let presentation = card.descriptor.presentation else { - return humanized(card.descriptor.qualifiedName) + if isChinese(locale), let recovered = recoveredBuiltInOperation( + for: card, locale: locale + ) { + return recovered + } + return RenderedOperation( + text: humanized(card.descriptor.qualifiedName), isArgumentValue: false + ) } switch presentation.activity { - case .label(let label): return label + case .label(let label): + return RenderedOperation( + text: legacyActivity( + label, descriptorName: card.descriptor.name, locale: locale + ), + isArgumentValue: false + ) case .argument(let key, let fallback): guard let value = card.invocation.call.arguments.objectValue?[key]?.stringValue else { - return fallback + return RenderedOperation( + text: legacyActivity( + fallback, descriptorName: card.descriptor.name, locale: locale + ), + isArgumentValue: false + ) } let oneLine = value.split(whereSeparator: \.isNewline).joined(separator: " ") - return oneLine.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? fallback : oneLine + guard !oneLine.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return RenderedOperation( + text: legacyActivity( + fallback, descriptorName: card.descriptor.name, locale: locale + ), + isArgumentValue: false + ) + } + return RenderedOperation(text: oneLine, isArgumentValue: true) + case .arrayCount(let key, let singular, let plural, let fallback): + guard let count = card.invocation.call.arguments.objectValue?[key]?.arrayValue?.count, + count > 0 + else { + return RenderedOperation( + text: legacyActivity( + fallback, descriptorName: card.descriptor.name, locale: locale + ), + isArgumentValue: false + ) + } + if card.descriptor.name == "request_user_input" { + return RenderedOperation( + text: countDescription(count, unit: .question, locale: locale), + isArgumentValue: false + ) + } + return RenderedOperation( + text: count == 1 ? singular : "\(count) \(plural)", isArgumentValue: false + ) case .localizedArrayCount(let key, let unit, let fallback): guard let count = card.invocation.call.arguments.objectValue?[key]?.arrayValue?.count, count > 0 - else { return fallback } - return countDescription(count, unit: unit) + else { + return RenderedOperation( + text: legacyActivity( + fallback, descriptorName: card.descriptor.name, locale: locale + ), + isArgumentValue: false + ) + } + return RenderedOperation( + text: countDescription(count, unit: unit, locale: locale), + isArgumentValue: false + ) + case .semanticLabel(let kind): + return RenderedOperation( + text: activityName(kind, locale: locale), isArgumentValue: false + ) + case .semanticArgument(let key, let fallback): + guard let value = card.invocation.call.arguments.objectValue?[key]?.stringValue else { + if card.descriptor.name == "web_search" { + return RenderedOperation(text: "", isArgumentValue: false) + } + return RenderedOperation( + text: activityName(fallback, locale: locale), isArgumentValue: false + ) + } + let oneLine = value.split(whereSeparator: \.isNewline).joined(separator: " ") + guard !oneLine.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + if card.descriptor.name == "web_search" { + return RenderedOperation(text: "", isArgumentValue: false) + } + return RenderedOperation( + text: activityName(fallback, locale: locale), isArgumentValue: false + ) + } + return RenderedOperation(text: oneLine, isArgumentValue: true) + case .semanticArrayCount(let key, let unit, let fallback): + guard let count = card.invocation.call.arguments.objectValue?[key]?.arrayValue?.count, + count > 0 + else { + return RenderedOperation( + text: activityName(fallback, locale: locale), isArgumentValue: false + ) + } + return RenderedOperation( + text: countDescription(count, unit: unit, locale: locale), + isArgumentValue: false + ) } } - private static func batchClause(_ cards: [AgentToolCard]) -> String { + /// Cards persisted before semantic presentations were stored still know + /// their tool name and arguments. Recover the same user-facing activity + /// from those stable protocol fields instead of exposing names such as + /// `web_search` in a translated summary. + private static func recoveredBuiltInOperation( + for card: AgentToolCard, locale: Locale + ) -> RenderedOperation? { + let semanticArgument: (key: String, fallback: AgentToolDescriptor.Presentation.ActivityKind)? = + switch card.descriptor.name { + case "fetch": ("url", .url) + case "web_search": ("query", .web) + case "ssh_exec", "terminal_suggest": ("command", .command) + case "sftp_list", "sftp_stat", "sftp_read", "sftp_write", "sftp_mkdir", + "sftp_move", "sftp_chmod", "sftp_delete": + ("path", .remoteFile) + case "request_user_secret": ("prompt", .secret) + case "scratch_list": ("path", .scratchWorkspace) + case "scratch_read", "scratch_write", "scratch_edit": ("path", .scratchFile) + case "scratch_diff": ("path_a", .scratchFiles) + case "scratch_delete": ("path", .scratchEntry) + case "scratch_pull", "scratch_push": ("remote_path", .remoteFile) + case "present_plan": ("title", .plan) + default: nil + } + if let semanticArgument { + if let value = card.invocation.call.arguments.objectValue?[semanticArgument.key]? + .stringValue + { + let oneLine = value.split(whereSeparator: \.isNewline).joined(separator: " ") + if !oneLine.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return RenderedOperation(text: oneLine, isArgumentValue: true) + } + } + if card.descriptor.name == "web_search" { + return RenderedOperation(text: "", isArgumentValue: false) + } + return RenderedOperation( + text: activityName(semanticArgument.fallback, locale: locale), + isArgumentValue: false + ) + } + + let semanticLabel: AgentToolDescriptor.Presentation.ActivityKind? = + switch card.descriptor.name { + case "list_hosts": .hosts + case "ssh_wait": .remoteCommand + case "manage_tasks": .taskList + default: nil + } + if let semanticLabel { + return RenderedOperation( + text: activityName(semanticLabel, locale: locale), isArgumentValue: false + ) + } + + if card.descriptor.name == "request_user_input" { + let count = card.invocation.call.arguments.objectValue?["questions"]?.arrayValue?.count + return RenderedOperation( + text: count.map { countDescription($0, unit: .question, locale: locale) } + ?? activityName(.question, locale: locale), + isArgumentValue: false + ) + } + return nil + } + + private static func batchClause( + _ cards: [AgentToolCard], locale: Locale + ) -> String { guard let first = cards.first else { return "" } guard let presentation = first.descriptor.presentation else { let activity = cards.count == 1 - ? humanized(first.descriptor.qualifiedName) : toolCallCount(cards.count) - return fallbackAction(for: first.state, activity: activity) + ? operation(for: first, locale: locale) + : RenderedOperation( + text: toolCallCount(cards.count, locale: locale), isArgumentValue: false + ) + if let actionKind = actionKind(for: first) { + if isChinese(locale), cards.count > 1 { + return repeatedActionSummary( + actionKind, state: first.state, count: cards.count, locale: locale + ) + } + return actionSummary( + actionKind, state: first.state, activity: activity.text, + isArgumentValue: activity.isArgumentValue, + target: nil, locale: locale + ) + } + return fallbackAction(for: first.state, activity: activity.text, locale: locale) } - let activity = batchActivity(presentation.activity, cards: cards) + let activity = batchActivity(presentation.activity, cards: cards, locale: locale) + if let actionKind = actionKind(for: first) { + if isChinese(locale), cards.count > 1 { + return repeatedActionSummary( + actionKind, state: first.state, count: cards.count, locale: locale + ) + } + return actionSummary( + actionKind, state: first.state, activity: activity.text, + isArgumentValue: activity.isArgumentValue, + target: nil, locale: locale + ) + } if let template = presentation.messages[first.state.rawValue] { - return replacingQuotedActivity(in: template, with: activity) - .replacingOccurrences(of: "{activity}", with: activity) + return replacingQuotedActivity(in: template, with: activity.text) + .replacingOccurrences(of: "{activity}", with: activity.text) .replacingOccurrences(of: " on {target}", with: "") .replacingOccurrences(of: " to {target}", with: "") .replacingOccurrences(of: " with {target}", with: "") .replacingOccurrences(of: "{target}", with: "") .trimmingCharacters(in: .whitespacesAndNewlines) } - return fallbackAction(for: first.state, activity: activity) + return fallbackAction(for: first.state, activity: activity.text, locale: locale) } private static func replacingQuotedActivity(in template: String, with activity: String) -> String { @@ -1137,49 +1362,475 @@ nonisolated enum AgentToolPresentation { private static func batchActivity( _ activity: AgentToolDescriptor.Presentation.Activity, - cards: [AgentToolCard] - ) -> String { + cards: [AgentToolCard], locale: Locale + ) -> RenderedOperation { switch activity { case .localizedArrayCount(let key, let unit, let fallback): let count = cards.reduce(0) { total, card in total + (card.invocation.call.arguments.objectValue?[key]?.arrayValue?.count ?? 0) } - guard count > 0 else { return fallback } - return countDescription(count, unit: unit) + guard count > 0 else { + return RenderedOperation(text: fallback, isArgumentValue: false) + } + return RenderedOperation( + text: countDescription(count, unit: unit, locale: locale), + isArgumentValue: false + ) + case .semanticArrayCount(let key, let unit, let fallback): + let count = cards.reduce(0) { total, card in + total + (card.invocation.call.arguments.objectValue?[key]?.arrayValue?.count ?? 0) + } + guard count > 0 else { + return RenderedOperation( + text: activityName(fallback, locale: locale), isArgumentValue: false + ) + } + return RenderedOperation( + text: countDescription(count, unit: unit, locale: locale), + isArgumentValue: false + ) + case .arrayCount(let key, _, _, let fallback): + let count = cards.reduce(0) { total, card in + total + (card.invocation.call.arguments.objectValue?[key]?.arrayValue?.count ?? 0) + } + guard count > 0 else { + return RenderedOperation(text: fallback, isArgumentValue: false) + } + if cards.first?.descriptor.name == "request_user_input" { + return RenderedOperation( + text: countDescription(count, unit: .question, locale: locale), + isArgumentValue: false + ) + } + return RenderedOperation( + text: cards.count == 1 ? fallback : toolCallCount(cards.count, locale: locale), + isArgumentValue: false + ) case .argument(_, let fallback): - return cards.count == 1 ? fallback : toolCallCount(cards.count) + return cards.count == 1 + ? RenderedOperation( + text: legacyActivity( + fallback, descriptorName: cards[0].descriptor.name, locale: locale + ), + isArgumentValue: false + ) + : RenderedOperation( + text: toolCallCount(cards.count, locale: locale), isArgumentValue: false + ) case .label(let label): - return cards.count == 1 ? label : toolCallCount(cards.count) + return cards.count == 1 + ? RenderedOperation( + text: legacyActivity( + label, descriptorName: cards[0].descriptor.name, locale: locale + ), + isArgumentValue: false + ) + : RenderedOperation( + text: toolCallCount(cards.count, locale: locale), isArgumentValue: false + ) + case .semanticArgument(_, let fallback): + if cards.count == 1, cards[0].descriptor.name == "web_search" { + return operation(for: cards[0], locale: locale) + } + return RenderedOperation( + text: cards.count == 1 + ? activityName(fallback, locale: locale) + : toolCallCount(cards.count, locale: locale), + isArgumentValue: false + ) + case .semanticLabel(let kind): + return RenderedOperation( + text: cards.count == 1 + ? activityName(kind, locale: locale) + : toolCallCount(cards.count, locale: locale), + isArgumentValue: false + ) } } private static func countDescription( - _ count: Int, unit: AgentToolDescriptor.Presentation.CountedUnit + _ count: Int, unit: AgentToolDescriptor.Presentation.CountedUnit, + locale: Locale ) -> String { switch unit { - case .question: String(localized: "\(count) questions") + case .question: localized("\(count) questions", locale: locale) } } - private static func toolCallCount(_ count: Int) -> String { - String(localized: "\(count) tool calls") + private static func toolCallCount(_ count: Int, locale: Locale) -> String { + localized("\(count) tool calls", locale: locale) } private static func fallbackAction( - for state: AgentToolCard.State, activity: String + for state: AgentToolCard.State, activity: String, locale: Locale ) -> String { switch state { - case .proposed: "Preparing \(activity)" - case .waitingForApproval, .waitingForUser: "Waiting to run \(activity)" - case .reviewing: "Preparing \(activity)" - case .running: "Running \(activity)" - case .completed: "Ran \(activity)" - case .denied: "Denied \(activity)" - case .failed: "Failed \(activity)" - case .interrupted: "Interrupted \(activity)" + case .proposed: localized("Preparing \(activity)", locale: locale) + case .waitingForApproval, .waitingForUser: + localized("Waiting to run \(activity)", locale: locale) + case .reviewing: localized("Preparing \(activity)", locale: locale) + case .running: localized("Running \(activity)", locale: locale) + case .completed: localized("Ran \(activity)", locale: locale) + case .denied: localized("Denied \(activity)", locale: locale) + case .failed: localized("Failed \(activity)", locale: locale) + case .interrupted: localized("Interrupted \(activity)", locale: locale) } } + private enum ActionForm { case infinitive, progressive, past } + + private static func actionKind( + for card: AgentToolCard + ) -> AgentToolDescriptor.Presentation.ActionKind? { + if let kind = card.descriptor.presentation?.actionKind { return kind } + return switch card.descriptor.name { + case "fetch": .fetch + case "web_search": .search + case "list_hosts", "sftp_list", "scratch_list": .list + case "ssh_wait": .wait + case "ssh_exec": .run + case "terminal_suggest": .suggest + case "sftp_stat": .inspect + case "sftp_read", "scratch_read": .read + case "sftp_write", "scratch_write": .write + case "sftp_mkdir": .create + case "sftp_move": .move + case "sftp_chmod": .changePermissions + case "sftp_delete", "scratch_delete": .delete + case "request_user_input": .ask + case "request_user_secret": .request + case "scratch_edit": .edit + case "scratch_diff": .compare + case "scratch_pull": .pull + case "scratch_push": .push + case "present_plan": .present + case "manage_tasks": .update + default: card.descriptor.target == .mcp ? .run : nil + } + } + + private static func phaseKind( + for card: AgentToolCard, phase: String + ) -> AgentToolDescriptor.Presentation.PhaseKind? { + if let kind = card.descriptor.presentation?.phaseKinds[phase] { return kind } + guard card.descriptor.name == "ssh_exec" else { return nil } + return switch phase { + case "proposed": .sshPreparingCommand + case "waitingForApproval": .sshWaitingForCommand + case "waitingForUser": .sshWaitingForAuthentication + case "running": .sshConnecting + case "command": .sshRunningCommand + case "completed": .sshCompletedCommand + case "denied": .sshDeniedCommand + case "failed": .sshFailedCommand + case "interrupted": .sshInterruptedCommand + default: nil + } + } + + private static func actionSummary( + _ kind: AgentToolDescriptor.Presentation.ActionKind, + state: AgentToolCard.State, activity: String, isArgumentValue: Bool, + target: String?, locale: Locale + ) -> String { + if isChinese(locale) { + let action = localizedActionVerb(kind, locale: locale) + let displayedActivity = isArgumentValue ? "“\(activity)”" : activity + let rendered: String = switch state { + case .proposed, .reviewing: + localized("Tool action preparing: \(action) \(displayedActivity)", locale: locale) + case .waitingForApproval, .waitingForUser: + localized("Tool action waiting: \(action) \(displayedActivity)", locale: locale) + case .running: + localized("Tool action running: \(action) \(displayedActivity)", locale: locale) + case .completed: + localized("Tool action completed: \(action) \(displayedActivity)", locale: locale) + case .denied: + localized("Tool action denied: \(action) \(displayedActivity)", locale: locale) + case .failed: + localized("Tool action failed: \(action) \(displayedActivity)", locale: locale) + case .interrupted: + localized("Tool action interrupted: \(action) \(displayedActivity)", locale: locale) + } + guard let target, !target.isEmpty else { return rendered } + return localized("Tool action target: \(rendered) \(target)", locale: locale) + } + if activity.isEmpty { + let rendered: String = switch state { + case .proposed, .reviewing: + "Preparing to \(actionName(kind, form: .infinitive, locale: locale))" + case .waitingForApproval, .waitingForUser: + "Waiting to \(actionName(kind, form: .infinitive, locale: locale))" + case .running: actionName(kind, form: .progressive, locale: locale) + case .completed: actionName(kind, form: .past, locale: locale) + case .denied: + "Did not \(actionName(kind, form: .infinitive, locale: locale))" + case .failed: + "Failed to \(actionName(kind, form: .infinitive, locale: locale))" + case .interrupted: + "Interrupted while " + + lowercaseFirst(actionName(kind, form: .progressive, locale: locale)) + } + guard let target, !target.isEmpty else { return rendered } + return "\(rendered) on \(target)" + } + let rendered: String + switch state { + case .proposed, .reviewing: + rendered = "Preparing to \(actionName(kind, form: .infinitive, locale: locale)) \(activity)" + case .waitingForApproval, .waitingForUser: + rendered = "Waiting to \(actionName(kind, form: .infinitive, locale: locale)) \(activity)" + case .running: + rendered = "\(actionName(kind, form: .progressive, locale: locale)) \(activity)" + case .completed: + rendered = "\(actionName(kind, form: .past, locale: locale)) \(activity)" + case .denied: + rendered = "Did not \(actionName(kind, form: .infinitive, locale: locale)) \(activity)" + case .failed: + rendered = "Failed to \(actionName(kind, form: .infinitive, locale: locale)) \(activity)" + case .interrupted: + let action = lowercaseFirst(actionName(kind, form: .progressive, locale: locale)) + rendered = "Interrupted while \(action) \(activity)" + } + guard let target, !target.isEmpty else { return rendered } + return "\(rendered) on \(target)" + } + + private static func repeatedActionSummary( + _ kind: AgentToolDescriptor.Presentation.ActionKind, + state: AgentToolCard.State, count: Int, locale: Locale + ) -> String { + let action = localizedActionVerb(kind, locale: locale) + switch state { + case .failed: + return localized("Repeated tool action failed: \(count) \(action)", locale: locale) + case .interrupted: + return localized( + "Repeated tool action interrupted: \(count) \(action)", locale: locale + ) + default: + return actionSummary( + kind, state: state, activity: localized("\(count) times", locale: locale), + isArgumentValue: false, target: nil, locale: locale + ) + } + } + + private static func phaseSummary( + _ kind: AgentToolDescriptor.Presentation.PhaseKind, + activity: String, target: String?, locale: Locale + ) -> String { + switch kind { + case .sshPreparingCommand: + return target.map { + localized("Preparing to run “\(activity)” on \($0)", locale: locale) + } ?? localized("Preparing to run “\(activity)”", locale: locale) + case .sshWaitingForCommand: + return target.map { + localized("Waiting to run “\(activity)” on \($0)", locale: locale) + } ?? localized("Waiting to run “\(activity)”", locale: locale) + case .sshWaitingForAuthentication: + return target.map { + localized("Waiting to authenticate with \($0)", locale: locale) + } ?? localized("Waiting for authentication", locale: locale) + case .sshConnecting: + return target.map { + localized("Connecting to \($0)", locale: locale) + } ?? localized("Connecting", locale: locale) + case .sshRunningCommand: + return target.map { + localized("Running “\(activity)” on \($0)", locale: locale) + } ?? localized("Running “\(activity)”", locale: locale) + case .sshCompletedCommand: + return target.map { + localized("Ran “\(activity)” on \($0)", locale: locale) + } ?? localized("Ran “\(activity)”", locale: locale) + case .sshDeniedCommand: + return target.map { + localized("Did not run “\(activity)” on \($0)", locale: locale) + } ?? localized("Did not run “\(activity)”", locale: locale) + case .sshFailedCommand: + return target.map { + localized("Failed to run “\(activity)” on \($0)", locale: locale) + } ?? localized("Failed to run “\(activity)”", locale: locale) + case .sshInterruptedCommand: + return target.map { + localized("Stopped waiting for “\(activity)” on \($0)", locale: locale) + } ?? localized("Stopped waiting for “\(activity)”", locale: locale) + } + } + + private static func actionName( + _ kind: AgentToolDescriptor.Presentation.ActionKind, + form: ActionForm, locale: Locale + ) -> String { + if isChinese(locale) { return localizedActionVerb(kind, locale: locale) } + return switch (kind, form) { + case (.ask, .infinitive): "ask" + case (.ask, .progressive): "Asking" + case (.ask, .past): "Asked" + case (.compare, .infinitive): "compare" + case (.compare, .progressive): "Comparing" + case (.compare, .past): "Compared" + case (.create, .infinitive): "create" + case (.create, .progressive): "Creating" + case (.create, .past): "Created" + case (.delete, .infinitive): "delete" + case (.delete, .progressive): "Deleting" + case (.delete, .past): "Deleted" + case (.edit, .infinitive): "edit" + case (.edit, .progressive): "Editing" + case (.edit, .past): "Edited" + case (.fetch, .infinitive): "fetch" + case (.fetch, .progressive): "Fetching" + case (.fetch, .past): "Fetched" + case (.inspect, .infinitive): "inspect" + case (.inspect, .progressive): "Inspecting" + case (.inspect, .past): "Inspected" + case (.list, .infinitive): "list" + case (.list, .progressive): "Listing" + case (.list, .past): "Listed" + case (.move, .infinitive): "move" + case (.move, .progressive): "Moving" + case (.move, .past): "Moved" + case (.present, .infinitive): "present" + case (.present, .progressive): "Writing" + case (.present, .past): "Presented" + case (.pull, .infinitive): "pull" + case (.pull, .progressive): "Pulling" + case (.pull, .past): "Pulled" + case (.push, .infinitive): "push" + case (.push, .progressive): "Pushing" + case (.push, .past): "Pushed" + case (.read, .infinitive): "read" + case (.read, .progressive): "Reading" + case (.read, .past): "Read" + case (.request, .infinitive): "request" + case (.request, .progressive): "Requesting" + case (.request, .past): "Requested" + case (.run, .infinitive): "run" + case (.run, .progressive): "Running" + case (.run, .past): "Ran" + case (.search, .infinitive): "search" + case (.search, .progressive): "Searching" + case (.search, .past): "Searched" + case (.suggest, .infinitive): "suggest" + case (.suggest, .progressive): "Suggesting" + case (.suggest, .past): "Suggested" + case (.update, .infinitive): "update" + case (.update, .progressive): "Updating" + case (.update, .past): "Updated" + case (.wait, .infinitive): "wait for" + case (.wait, .progressive): "Waiting for" + case (.wait, .past): "Waited for" + case (.write, .infinitive): "write" + case (.write, .progressive): "Writing" + case (.write, .past): "Wrote" + case (.changePermissions, .infinitive): "change permissions for" + case (.changePermissions, .progressive): "Changing permissions for" + case (.changePermissions, .past): "Changed permissions for" + case (.use, .infinitive): "use" + case (.use, .progressive): "Using" + case (.use, .past): "Used" + } + } + + private static func localizedActionVerb( + _ kind: AgentToolDescriptor.Presentation.ActionKind, locale: Locale + ) -> String { + switch kind { + case .ask: localized("ask", locale: locale) + case .compare: localized("compare", locale: locale) + case .create: localized("create", locale: locale) + case .delete: localized("delete", locale: locale) + case .edit: localized("edit", locale: locale) + case .fetch: localized("fetch", locale: locale) + case .inspect: localized("inspect", locale: locale) + case .list: localized("list", locale: locale) + case .move: localized("move", locale: locale) + case .present: localized("present", locale: locale) + case .pull: localized("pull", locale: locale) + case .push: localized("push", locale: locale) + case .read: localized("read", locale: locale) + case .request: localized("request", locale: locale) + case .run: localized("run", locale: locale) + case .search: localized("search", locale: locale) + case .suggest: localized("suggest", locale: locale) + case .update: localized("update", locale: locale) + case .wait: localized("wait for", locale: locale) + case .write: localized("write", locale: locale) + case .changePermissions: localized("change permissions for", locale: locale) + case .use: localized("use", locale: locale) + } + } + + private static func isChinese(_ locale: Locale) -> Bool { + locale.language.languageCode?.identifier == "zh" + } + + private static func activityName( + _ kind: AgentToolDescriptor.Presentation.ActivityKind, locale: Locale + ) -> String { + switch kind { + case .command: localized("a command", locale: locale) + case .hosts: localized("hosts", locale: locale) + case .plan: localized("a plan", locale: locale) + case .question: localized("a question", locale: locale) + case .remoteCommand: localized("the remote command", locale: locale) + case .remoteFile: localized("a remote file", locale: locale) + case .scratchEntry: localized("a scratch entry", locale: locale) + case .scratchFile: localized("a scratch file", locale: locale) + case .scratchFiles: localized("scratch files", locale: locale) + case .scratchWorkspace: localized("the scratch workspace", locale: locale) + case .secret: localized("a secret", locale: locale) + case .sshCommand: localized("the SSH command", locale: locale) + case .taskList: localized("the task list", locale: locale) + case .url: localized("a URL", locale: locale) + case .web: localized("the web", locale: locale) + } + } + + private static func localized( + _ key: String.LocalizationValue, locale: Locale + ) -> String { + AppLocalization.string(key, locale: locale) + } + + private static func legacyActivity( + _ fallback: String, descriptorName: String, locale: Locale + ) -> String { + let sftpNames = [ + "sftp_list", "sftp_stat", "sftp_read", "sftp_write", "sftp_mkdir", + "sftp_move", "sftp_chmod", "sftp_delete", + ] + if sftpNames.contains(descriptorName) { + return activityName(.remoteFile, locale: locale) + } + let kind: AgentToolDescriptor.Presentation.ActivityKind? = switch ( + descriptorName, fallback + ) { + case ("fetch", "a URL"): .url + case ("web_search", "the web"): .web + case ("list_hosts", "hosts"): .hosts + case ("ssh_wait", "the remote command"): .remoteCommand + case ("ssh_exec", "the SSH command"): .sshCommand + case ("terminal_suggest", "a command"): .command + case ("request_user_input", "a question"): .question + case ("request_user_secret", "a secret"): .secret + case ("present_plan", "a plan"): .plan + case ("manage_tasks", "the task list"): .taskList + case ("scratch_list", "the scratch workspace"): .scratchWorkspace + case ("scratch_read", "a scratch file"), ("scratch_write", "a scratch file"), + ("scratch_edit", "a scratch file"): .scratchFile + case ("scratch_diff", "scratch files"): .scratchFiles + case ("scratch_delete", "a scratch entry"): .scratchEntry + case ("scratch_pull", "a remote file"), ("scratch_push", "a remote file"): + .remoteFile + default: nil + } + return kind.map { activityName($0, locale: locale) } ?? fallback + } + private static func lowercaseFirst(_ text: String) -> String { guard let first = text.first else { return text } return first.lowercased() + String(text.dropFirst()) diff --git a/Termind/Features/AI/ConversationModel.swift b/Termind/Features/AI/ConversationModel.swift index 1b6fc70a..28be7bc3 100644 --- a/Termind/Features/AI/ConversationModel.swift +++ b/Termind/Features/AI/ConversationModel.swift @@ -366,32 +366,38 @@ nonisolated enum AgentWorkPresentation { /// A compact, stable duration for the work disclosure. Seconds are floored /// so a completed run never appears to gain time after its finish event. - static func duration(from startedAt: Date, to finishedAt: Date) -> String { + static func duration( + from startedAt: Date, to finishedAt: Date, locale: Locale = .current + ) -> String { let seconds = max(0, Int(finishedAt.timeIntervalSince(startedAt))) let hours = seconds / 3_600 let minutes = (seconds % 3_600) / 60 let remainingSeconds = seconds % 60 if hours > 0 { - return minutes > 0 ? "\(hours)h \(minutes)m" : "\(hours)h" + return minutes > 0 + ? AppLocalization.string("\(hours)h \(minutes)m", locale: locale) + : AppLocalization.string("\(hours)h", locale: locale) } if minutes > 0 { return remainingSeconds > 0 - ? "\(minutes)m \(remainingSeconds)s" : "\(minutes)m" + ? AppLocalization.string("\(minutes)m \(remainingSeconds)s", locale: locale) + : AppLocalization.string("\(minutes)m", locale: locale) } - return "\(remainingSeconds)s" + return AppLocalization.string("\(remainingSeconds)s", locale: locale) } static func title( - for state: AgentRunState, from startedAt: Date, to finishedAt: Date + for state: AgentRunState, from startedAt: Date, to finishedAt: Date, + locale: Locale = .current ) -> String { - let elapsed = duration(from: startedAt, to: finishedAt) + let elapsed = duration(from: startedAt, to: finishedAt, locale: locale) switch state { case .idle, .running, .waitingForUser, .waitingForApproval, .reviewing: - return "Working for \(elapsed)" + return AppLocalization.string("Working for \(elapsed)", locale: locale) case .cancelled: - return "You stopped after \(elapsed)" + return AppLocalization.string("You stopped after \(elapsed)", locale: locale) case .completed, .failed, .interrupted: - return "Worked for \(elapsed)" + return AppLocalization.string("Worked for \(elapsed)", locale: locale) } } } diff --git a/Termind/Features/AI/ConversationTranscriptView.swift b/Termind/Features/AI/ConversationTranscriptView.swift index 497e69a4..3f53c502 100644 --- a/Termind/Features/AI/ConversationTranscriptView.swift +++ b/Termind/Features/AI/ConversationTranscriptView.swift @@ -362,6 +362,7 @@ private struct AgentWorkGroupView: View { let onExpansion: () -> Void @State private var isExpanded: Bool + @Environment(\.locale) private var locale init(group: AgentWorkGroup, isCompact: Bool, onExpansion: @escaping () -> Void) { self.group = group @@ -426,11 +427,17 @@ private struct AgentWorkGroupView: View { .contentShape(.rect) } .buttonStyle(.plain) - .help(Text(isExpanded ? "Hide work" : "Show work")) + .help(Text( + isExpanded ? String(localized: "Hide work") : String(localized: "Show work") + )) .accessibilityLabel(Text(title(at: group.run.finishedAt ?? .now))) - .accessibilityValue(Text(isExpanded ? "Expanded" : "Collapsed")) + .accessibilityValue(Text( + isExpanded ? String(localized: "Expanded") : String(localized: "Collapsed") + )) .accessibilityHint(Text( - isExpanded ? "Collapse work process" : "Expand work process" + isExpanded + ? String(localized: "Collapse work process") + : String(localized: "Expand work process") )) if isExpanded { workItems } @@ -487,7 +494,7 @@ private struct AgentWorkGroupView: View { private func title(at now: Date) -> String { let end = group.run.finishedAt ?? now return AgentWorkPresentation.title( - for: group.run.state, from: group.run.startedAt, to: end + for: group.run.state, from: group.run.startedAt, to: end, locale: locale ) } } @@ -500,6 +507,7 @@ private struct AgentToolBatchView: View { let onExpansion: () -> Void @Environment(AppModel.self) private var app + @Environment(\.locale) private var locale @State private var isExpanded = false private var cards: [AgentToolCard] { batch.cards } @@ -522,11 +530,13 @@ private struct AgentToolBatchView: View { private var title: String { guard !unfinished.isEmpty else { - return AgentToolPresentation.batchSummary(for: cards) + return AgentToolPresentation.batchSummary(for: cards, locale: locale) } if let displayedReasoning { return displayedReasoning } return unfinished.map { card in - AgentToolPresentation.summary(for: card, hostName: hostName(for: card)) + AgentToolPresentation.summary( + for: card, hostName: hostName(for: card), locale: locale + ) }.joined(separator: "; ") } @@ -556,9 +566,13 @@ private struct AgentToolBatchView: View { .contentShape(.rect) } .buttonStyle(.plain) - .help(Text(isExpanded ? "Hide tools" : "Show tools")) + .help(Text( + isExpanded ? String(localized: "Hide tools") : String(localized: "Show tools") + )) .accessibilityLabel(Text(title)) - .accessibilityValue(Text(isExpanded ? "Expanded" : "Collapsed")) + .accessibilityValue(Text( + isExpanded ? String(localized: "Expanded") : String(localized: "Collapsed") + )) if isExpanded { LazyVStack(alignment: .leading, spacing: Metrics.Chat.blockSpacing) { @@ -600,10 +614,11 @@ private struct AgentToolRowView: View { let onExpansion: () -> Void @Environment(AppModel.self) private var app + @Environment(\.locale) private var locale @State private var isExpanded = false private var summary: String { - AgentToolPresentation.summary(for: card, hostName: hostName) + AgentToolPresentation.summary(for: card, hostName: hostName, locale: locale) } var body: some View { @@ -641,7 +656,10 @@ private struct AgentToolRowView: View { .contentShape(.rect) } .buttonStyle(.plain) - .help(Text(isExpanded ? "Hide details" : "Show details")) + .help(Text( + isExpanded + ? String(localized: "Hide details") : String(localized: "Show details") + )) if isExpanded { detail } } @@ -651,7 +669,7 @@ private struct AgentToolRowView: View { VStack(alignment: .leading, spacing: Metrics.Space.s) { if let review = card.review { Label( - "Security Review: \(review.risk.rawValue.capitalized) — \(review.reason)", + "Security Review: \(review.risk.displayName) — \(review.reason)", systemImage: "checkmark.shield" ) .font(.caption) @@ -882,6 +900,18 @@ private struct AgentCompactionActivityRowView: View { return reason } + private var statusTitle: String { + failure == nil + ? String(localized: "Compacting context") + : String(localized: "Couldn’t compact context") + } + + private var helpTitle: String { + failure == nil + ? String(localized: "Compacting context") + : String(localized: "Show failure details") + } + var body: some View { VStack(alignment: .leading, spacing: Metrics.Space.s) { Button { @@ -900,7 +930,7 @@ private struct AgentCompactionActivityRowView: View { .frame(width: Metrics.Sidebar.rowIcon) } - Text(failure == nil ? "Compacting context" : "Couldn’t compact context") + Text(statusTitle) .font(MarkdownTypography.body) .foregroundStyle(failure == nil ? .secondary : .primary) .modifier(ScanningTextMask(isActive: failure == nil)) @@ -916,7 +946,7 @@ private struct AgentCompactionActivityRowView: View { .contentShape(.rect) } .buttonStyle(.plain) - .help(Text(failure == nil ? "Compacting context" : "Show failure details")) + .help(Text(helpTitle)) if isExpanded, let failure { HStack(alignment: .top, spacing: Metrics.Space.m) { @@ -1246,7 +1276,10 @@ private struct AgentPlanRowView: View { .contentShape(.rect) } .buttonStyle(.plain) - .help(Text(isExpanded ? "Hide the plan" : "Show the plan")) + .help(Text( + isExpanded + ? String(localized: "Hide the plan") : String(localized: "Show the plan") + )) } private var actions: some View { diff --git a/Termind/Features/AI/ConversationViews.swift b/Termind/Features/AI/ConversationViews.swift index fe161fc1..41b19a35 100644 --- a/Termind/Features/AI/ConversationViews.swift +++ b/Termind/Features/AI/ConversationViews.swift @@ -144,7 +144,10 @@ private struct ChipView: View { styledContent(removable: false) } } - .help(Text(remove == nil ? "Always included" : "Remove attachment")) + .help(Text( + remove == nil + ? String(localized: "Always included") : String(localized: "Remove attachment") + )) } private func styledContent(removable: Bool) -> some View { diff --git a/Termind/Features/AI/MarkdownCodeBlockView.swift b/Termind/Features/AI/MarkdownCodeBlockView.swift index 8563fa9f..cde0c57e 100644 --- a/Termind/Features/AI/MarkdownCodeBlockView.swift +++ b/Termind/Features/AI/MarkdownCodeBlockView.swift @@ -141,7 +141,7 @@ private struct ShellCodeBlockView: View { CopyButton(text: command) } .controlSize(.small) - .help(Text(target == nil ? "No shell in the foreground" : "")) + .help(Text(target == nil ? String(localized: "No shell in the foreground") : "")) } private func run() { diff --git a/Termind/Features/Assistant/AssistantView.swift b/Termind/Features/Assistant/AssistantView.swift index f0c660d5..afa25fe0 100644 --- a/Termind/Features/Assistant/AssistantView.swift +++ b/Termind/Features/Assistant/AssistantView.swift @@ -408,7 +408,10 @@ private struct AssistantConversationRow: View { .font(.caption) } .buttonStyle(.plain) - .help(Text(conversation.isArchived ? "Unarchive" : "Archive")) + .help(Text( + conversation.isArchived + ? String(localized: "Unarchive") : String(localized: "Archive") + )) } } } diff --git a/Termind/Resources/Localizable.xcstrings b/Termind/Resources/Localizable.xcstrings index 6d0a6377..71f924df 100644 --- a/Termind/Resources/Localizable.xcstrings +++ b/Termind/Resources/Localizable.xcstrings @@ -16,7 +16,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : " 复制" + "value" : "复制" } } } @@ -26,7 +26,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "[… %lld字符省略...]" + "value" : "[…%lld字符省略…]" } } } @@ -42,7 +42,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@ — %2$@" + "value" : "%1$@—%2$@" } } } @@ -73,7 +73,7 @@ "other" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@ — %2$lld个工具" + "value" : "%1$@—%2$lld个工具" } } } @@ -87,7 +87,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "%@ — 1个工具" + "value" : "%@—1个工具" } } } @@ -283,7 +283,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@, %2$@" + "value" : "%1$@、%2$@" } } } @@ -299,7 +299,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@%2$@ / %3$@(%4$lld%%)" + "value" : "%1$@%2$@/%3$@(%4$lld%%)" } } } @@ -535,7 +535,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "已完成%1$lld / %2$lld个任务" + "value" : "已完成%1$lld/%2$lld个任务" } } } @@ -659,7 +659,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "%1$lld任务 ·%2$lld已完成" + "value" : "%1$lld个任务·已完成%2$lld个" } } } @@ -674,6 +674,16 @@ } } }, + "%lld times" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld次" + } + } + } + }, "%lld tool calls" : { "localizations" : { "en" : { @@ -743,7 +753,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "`%@` 可以不可逆转地破坏数据或状态。" + "value" : "`%@`可以不可逆地破坏数据或状态。" } } } @@ -753,7 +763,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "`%@` 更改系统或文件状态。" + "value" : "`%@`会更改系统或文件状态。" } } } @@ -763,7 +773,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "`%@` 不是一个只读的命令。" + "value" : "`%@`不是可识别的只读命令。" } } } @@ -773,7 +783,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "`crontab -r` 会直接清空crontab,不作确认。" + "value" : "`crontab -r`会直接清空crontab且不作确认。" } } } @@ -783,7 +793,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "`docker system prune` 广泛回收和删除。" + "value" : "`docker system prune`会大范围回收并删除数据。" } } } @@ -793,7 +803,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "`find -delete` 删除每个匹配。" + "value" : "`find -delete`会删除每个匹配项。" } } } @@ -803,7 +813,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "`find -exec` 运行每个匹配的命令。" + "value" : "`find -exec`会为每个匹配项运行命令。" } } } @@ -813,7 +823,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "`git clean` 删除未跟踪的文件。" + "value" : "`git clean`会删除未跟踪的文件。" } } } @@ -823,7 +833,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "`git reset --hard` 丢弃未承诺的工作。" + "value" : "`git reset --hard`会丢弃未提交的更改。" } } } @@ -833,7 +843,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "`rsync --delete` 删除来源中缺失的文件。" + "value" : "`rsync --delete`会删除源中不存在的文件。" } } } @@ -843,7 +853,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "`sed -i` 编辑已设置的文件。" + "value" : "`sed -i`会直接修改文件。" } } } @@ -935,7 +945,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "1 / %lld" + "value" : "1/%lld" } } } @@ -1836,7 +1846,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Bearer Token" + "value" : "Bearer令牌" } } } @@ -1886,7 +1896,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "将输入广播到此工作区的每个SSH会话 — ⌘B" + "value" : "将输入广播到此工作区的每个SSH会话—⌘B" } } } @@ -1896,7 +1906,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "使用SFTP浏览 ·%@" + "value" : "使用SFTP浏览·%@" } } } @@ -2236,7 +2246,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "清除此主机的命令历史 ?" + "value" : "清除此主机的命令历史?" } } } @@ -2296,7 +2306,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "点击可插入 ^ 双击可执行" + "value" : "单击插入·双击执行" } } } @@ -2326,7 +2336,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "关闭查找—— Esc" + "value" : "关闭查找—Esc" } } } @@ -2386,7 +2396,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "关闭标签 - 单击以关闭其他标签" + "value" : "关闭标签—按住⌥单击可关闭其他标签" } } } @@ -2416,7 +2426,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "折叠工作流程" + "value" : "收起工作过程" } } } @@ -2582,7 +2592,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "紧凑—— %1$@汇总为%2$@" + "value" : "已压缩:%1$@汇总为%2$@" } } } @@ -2592,7 +2602,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "紧凑的背景" + "value" : "正在压缩上下文" } } } @@ -2722,7 +2732,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "使用SSH连接 ·%@" + "value" : "使用SSH连接·%@" } } } @@ -3182,7 +3192,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "创建" + "value" : "已创建" } } } @@ -3342,7 +3352,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "删除%lld物品吗?" + "value" : "删除%lld项吗?" } } } @@ -3362,7 +3372,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "删除对话吗 ?" + "value" : "删除对话吗?" } } } @@ -3532,7 +3542,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "删除" + "value" : "已删除" } } } @@ -3832,7 +3842,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "动态代理 (-D)" + "value" : "动态代理(-D)" } } } @@ -4122,7 +4132,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "执行破坏命令?" + "value" : "执行破坏性命令?" } } } @@ -4132,7 +4142,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "执行毁灭剧本?" + "value" : "执行破坏性脚本?" } } } @@ -4142,7 +4152,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "执行破坏性剧本?" + "value" : "执行破坏性脚本?" } } } @@ -4212,7 +4222,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "扩大工作进程" + "value" : "展开工作过程" } } } @@ -4278,7 +4288,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "导出%lld 1主机的密钥" + "value" : "已将%lld个密钥导出到1台主机" } } } @@ -4348,7 +4358,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "提取器会写文件,也许会覆盖" + "value" : "提取操作会写入文件,并可能覆盖现有内容。" } } } @@ -4848,7 +4858,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Google拒绝登录- %@" + "value" : "Google拒绝登录:%@" } } } @@ -4929,7 +4939,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "隐藏细节" + "value" : "收起详情" } } } @@ -4969,7 +4979,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "隐藏工具" + "value" : "收起工具" } } } @@ -4979,7 +4989,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "隐藏工作" + "value" : "收起工作过程" } } } @@ -5069,7 +5079,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "主机 : %@" + "value" : "主机:%@" } } } @@ -5451,7 +5461,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "跳板机%1$lld (%2$@): %3$@" + "value" : "跳板机%1$lld(%2$@):%3$@" } } } @@ -5531,7 +5541,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "密钥生成失败 : %@" + "value" : "密钥生成失败:%@" } } } @@ -6037,7 +6047,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "模型%1$@,努力时%2$@" + "value" : "模型%1$@,推理强度%2$@" } } } @@ -6327,7 +6337,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "下一个匹配项 — ⇧Return、⌘G、F3" + "value" : "下一个匹配项—⇧Return、⌘G、F3" } } } @@ -6357,7 +6367,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "NIST P-256信息技术科" + "value" : "NIST P-256曲线" } } } @@ -6457,7 +6467,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "没有命令在那个句柄下运行。 报告完成后已经报告了,或者在经过长时间的询问后被释放。" + "value" : "该句柄下没有正在运行的命令。命令可能已结束并报告结果,或因长时间无人查询而被释放。" } } } @@ -6707,7 +6717,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "没有可辨认的言论被俘获. 靠近麦克风说话" + "value" : "未捕获到可识别的语音。请靠近麦克风后重试。" } } } @@ -6907,7 +6917,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "非基数64 PEM" + "value" : "不是Base64编码的PEM" } } } @@ -7027,7 +7037,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "在助手的模型选择器和 /model命令中提供此模型。" + "value" : "在助手模型选择器和/model命令中提供此模型。" } } } @@ -7237,7 +7247,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "覆盖 `%@`。" + "value" : "覆盖`%@`。" } } } @@ -7497,7 +7507,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "权限 : %@" + "value" : "权限:%@" } } } @@ -7717,7 +7727,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "上一个匹配项 — Return、⇧⌘G、⇧F3" + "value" : "上一个匹配项—Return、⇧⌘G、⇧F3" } } } @@ -7953,7 +7963,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "问题%1$lld / %2$lld" + "value" : "问题%1$lld/%2$lld" } } } @@ -7973,7 +7983,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "加入当前代理的队列 — ⏎" + "value" : "加入当前代理的队列—⏎" } } } @@ -8053,7 +8063,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "到达了后端,但没有结果。" + "value" : "已连接后端,但未返回结果。" } } } @@ -8159,7 +8169,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "第%1$lld次重连 ·%2$lld秒后重试" + "value" : "第%1$lld次重连·%2$lld秒后重试" } } } @@ -8199,7 +8209,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "正在重新连接... 尝试%lld" + "value" : "正在重新连接…第%lld次尝试" } } } @@ -8485,7 +8495,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "替换此私钥会改变其公钥. 使用旧密钥的主机可能拒绝下一个连接。" + "value" : "替换此私钥会改变其公钥。使用旧密钥的主机可能拒绝下次连接。" } } } @@ -8875,7 +8885,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "运行... %lld连接" + "value" : "正在运行·%lld个连接" } } } @@ -8885,7 +8895,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "运行中... 1个连接" + "value" : "正在运行·1个连接" } } } @@ -9115,7 +9125,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "无法配置安全飞地访问 : %@" + "value" : "无法配置安全隔区访问:%@" } } } @@ -9135,7 +9145,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "安全飞地密钥无法移动到另一个存储位置。" + "value" : "安全隔区密钥无法移动到其他存储位置。" } } } @@ -9145,7 +9155,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "安全飞地密钥无法存储SSH证书。" + "value" : "安全隔区密钥无法存储SSH证书。" } } } @@ -9231,7 +9241,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "安全审查: %1$@ — %2$@" + "value" : "安全审查:%1$@—%2$@" } } } @@ -9291,7 +9301,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "选择至少两个主机。 包含一个主机的工作区是一个标签。" + "value" : "请至少选择两台主机。仅含一台主机时应使用标签页。" } } } @@ -9351,7 +9361,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "发送 — ⌘⏎" + "value" : "发送—⌘⏎" } } } @@ -9381,7 +9391,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "在授权信头中发送。 下面的自定义头作为纯文本存储。" + "value" : "通过Authorization标头发送。下方的自定义标头以纯文本存储。" } } } @@ -9541,7 +9551,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "显示细节" + "value" : "展开详情" } } } @@ -9551,7 +9561,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "显示失败细节" + "value" : "展开失败详情" } } } @@ -9631,7 +9641,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "显示工具" + "value" : "展开工具" } } } @@ -9641,7 +9651,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "显示工作" + "value" : "展开工作过程" } } } @@ -9771,7 +9781,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "这台Mac上的SOCKS5 → SSH主机 → 请求目标" + "value" : "这台Mac上的SOCKS5→SSH主机→请求目标" } } } @@ -9871,7 +9881,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "SSH连接失败 : %@我该检查什么?" + "value" : "SSH连接失败:%@。我该检查什么?" } } } @@ -9891,7 +9901,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "SSH主机 → 这台Mac → 目标" + "value" : "SSH主机→这台Mac→目标" } } } @@ -9927,7 +9937,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "标准输出 :\n%1$@\n标准错误 :\n%2$@" + "value" : "标准输出:\n%1$@\n标准错误:\n%2$@" } } } @@ -10027,7 +10037,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "追加指示" + "value" : "追加" } } } @@ -10043,7 +10053,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "步骤%1$lld / %2$lld" + "value" : "步骤%1$lld/%2$lld" } } } @@ -10379,7 +10389,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "任务,已完成%1$lld / %2$lld" + "value" : "任务,已完成%1$lld/%2$lld" } } } @@ -10449,7 +10459,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Termind无法启动音频输入。 检查是否有麦克风连接和可用。" + "value" : "Termind无法启动音频输入。请检查麦克风是否已连接且可用。" } } } @@ -10555,7 +10565,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "文本相同,但换行符不同:%1$@ → %2$@" + "value" : "文本相同,但换行符不同:%1$@→%2$@" } } } @@ -10635,7 +10645,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "对话太大,无法概括。" + "value" : "对话过长,无法生成摘要。" } } } @@ -10701,7 +10711,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "不同的区域范围%1$lld线条,通过%2$lld- 线diff限制。 用每一边的定界读数来缩小它" + "value" : "差异区域跨越%1$lld行,超过%2$lld行的差异限制。请分别读取两侧的有限范围以缩小差异。" } } } @@ -10791,7 +10801,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "文件大于25 MB。" + "value" : "文件大于25MB。" } } } @@ -11017,7 +11027,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "钥匙串 项并不包含预期的凭据类型。" + "value" : "钥匙串项目不包含预期的凭据类型。" } } } @@ -11163,7 +11173,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "处理后的图像大于5 MB。" + "value" : "处理后的图像大于5MB。" } } } @@ -11193,7 +11203,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "私钥不能离开安全飞地 只有它的名字可以改变。" + "value" : "私钥无法离开安全隔区,只能更改其名称。" } } } @@ -11203,7 +11213,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "无法读取私钥 : %@" + "value" : "无法读取私钥:%@" } } } @@ -11313,7 +11323,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此Mac上没有保存的密码。 在更改存储前再次输入。" + "value" : "这台Mac上无法使用已保存的密码。更改存储方式前请重新输入。" } } } @@ -11413,7 +11423,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "选中的模型无法读取图像。 发送前选择可视化模型。" + "value" : "所选模型无法读取图像。发送前请选择支持视觉的模型。" } } } @@ -11463,7 +11473,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "服务器仅监听127.0.0.1和 ::1,默认关闭。启用后,外部调用无需逐次批准即可执行。" + "value" : "服务器仅监听127.0.0.1和::1,默认关闭。启用后,外部调用无需逐次批准即可执行。" } } } @@ -11771,7 +11781,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "这个Mac SSH主机 目标" + "value" : "这台Mac→SSH主机→目标" } } } @@ -11811,7 +11821,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此PDF没有可选择的文本。 不支持扫描的PDF。" + "value" : "此PDF没有可选择的文本,不支持扫描版PDF。" } } } @@ -11921,7 +11931,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "工具输入被拒绝 : %@" + "value" : "工具输入被拒绝:%@" } } } @@ -12191,7 +12201,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "未知或已禁用的工具 : %@" + "value" : "未知或已禁用的工具:%@" } } } @@ -12221,7 +12231,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "未知工具 : %@" + "value" : "未知工具:%@" } } } @@ -12301,7 +12311,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "不支持的SSH证书算法 : %@" + "value" : "不支持的SSH证书算法:%@" } } } @@ -12311,7 +12321,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "不支持的SSH密钥算法 : %@" + "value" : "不支持的SSH密钥算法:%@" } } } @@ -12341,7 +12351,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "已是最新 · %@" + "value" : "已是最新·%@" } } } @@ -12431,7 +12441,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "使用Vertex AI软件" + "value" : "使用Vertex AI" } } } @@ -12441,7 +12451,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "使用您的安全飞地SSH密钥" + "value" : "使用你的安全隔区SSH密钥" } } } @@ -12741,7 +12751,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "怎样强调?" + "value" : "需要强调的内容" } } } @@ -12886,6 +12896,80 @@ } } }, + "%lldh %lldm" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"%1$lld小时%2$lld分钟"}}}}, + "%lldh" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"%lld小时"}}}}, + "%lldm %llds" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"%1$lld分钟%2$lld秒"}}}}, + "%lldm" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"%lld分钟"}}}}, + "%llds" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"%lld秒"}}}}, + "Working for %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"已工作%@"}}}}, + "Worked for %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"工作了%@"}}}}, + "You stopped after %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"你在%@后停止了"}}}}, + "ask" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"询问"}}}}, + "compare" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"比较"}}}}, + "create" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"创建"}}}}, + "delete" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"删除"}}}}, + "edit" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"编辑"}}}}, + "fetch" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"获取"}}}}, + "inspect" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"检查"}}}}, + "list" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"列出"}}}}, + "move" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"移动"}}}}, + "present" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"提交"}}}}, + "pull" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"拉取"}}}}, + "push" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"推送"}}}}, + "read" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"读取"}}}}, + "request" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"请求"}}}}, + "run" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"运行"}}}}, + "search" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"搜索"}}}}, + "suggest" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"生成建议"}}}}, + "update" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"更新"}}}}, + "wait for" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"等待"}}}}, + "write" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"写入"}}}}, + "change permissions for" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"更改权限"}}}}, + "use" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"使用"}}}}, + "a command" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"命令"}}}}, + "hosts" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"主机"}}}}, + "a plan" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"计划"}}}}, + "a question" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"问题"}}}}, + "the remote command" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"远程命令"}}}}, + "a remote file" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"远程文件"}}}}, + "a scratch entry" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"暂存条目"}}}}, + "a scratch file" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"暂存文件"}}}}, + "scratch files" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"暂存文件"}}}}, + "the scratch workspace" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"暂存工作区"}}}}, + "a secret" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"密钥信息"}}}}, + "the SSH command" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"SSH命令"}}}}, + "a URL" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"URL"}}}}, + "the web" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"网络"}}}}, + "Repeated tool action failed: %lld %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"%1$lld次%2$@失败"}}}}, + "Repeated tool action interrupted: %lld %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"%1$lld次%2$@已中断"}}}}, + "Tool action preparing: %@ %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"正在准备%1$@%2$@"}}}}, + "Tool action waiting: %@ %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"等待%1$@%2$@"}}}}, + "Tool action running: %@ %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"正在%1$@%2$@"}}}}, + "Tool action completed: %@ %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"已%1$@%2$@"}}}}, + "Tool action denied: %@ %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"未%1$@%2$@"}}}}, + "Tool action failed: %@ %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"%1$@%2$@失败"}}}}, + "Tool action interrupted: %@ %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"%1$@%2$@已中断"}}}}, + "Tool action target: %@ %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"%1$@(%2$@)"}}}}, + "Preparing to run “%@” on %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"正在准备在%2$@上运行“%1$@”"}}}}, + "Preparing to run “%@”" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"正在准备运行“%@”"}}}}, + "Waiting to run “%@” on %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"等待在%2$@上运行“%1$@”"}}}}, + "Waiting to run “%@”" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"等待运行“%@”"}}}}, + "Waiting to authenticate with %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"等待向%@进行身份验证"}}}}, + "Connecting to %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"正在连接%@"}}}}, + "Running “%@” on %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"正在%2$@上运行“%1$@”"}}}}, + "Running “%@”" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"正在运行“%@”"}}}}, + "Ran “%@” on %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"已在%2$@上运行“%1$@”"}}}}, + "Ran “%@”" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"已运行“%@”"}}}}, + "Did not run “%@” on %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"未在%2$@上运行“%1$@”"}}}}, + "Did not run “%@”" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"未运行“%@”"}}}}, + "Failed to run “%@” on %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"未能在%2$@上运行“%1$@”"}}}}, + "Failed to run “%@”" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"未能运行“%@”"}}}}, + "Stopped waiting for “%@” on %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"已停止等待%2$@上的“%1$@”"}}}}, + "Stopped waiting for “%@”" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"已停止等待“%@”"}}}}, + "File" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"文件"}}}}, + "Directory" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"目录"}}}}, + "Symlink" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"符号链接"}}}}, + "Critical" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"严重"}}}}, "You said" : { "localizations" : { "zh-Hans" : { @@ -12898,4 +12982,4 @@ } }, "version" : "1.2" -} \ No newline at end of file +} diff --git a/TermindTests/AgentToolPresentationTests.swift b/TermindTests/AgentToolPresentationTests.swift index 6d3c1482..ab00f755 100644 --- a/TermindTests/AgentToolPresentationTests.swift +++ b/TermindTests/AgentToolPresentationTests.swift @@ -142,6 +142,144 @@ struct AgentToolPresentationTests { ) } + @Test + func semanticActionsLocalizeEveryLifecycleWithoutChangingArguments() { + let zh = Locale(identifier: "zh-Hans") + let actions: [(AgentToolDescriptor.Presentation.ActionKind, String)] = [ + (.ask, "询问"), (.compare, "比较"), (.create, "创建"), (.delete, "删除"), + (.edit, "编辑"), (.fetch, "获取"), (.inspect, "检查"), (.list, "列出"), + (.move, "移动"), (.present, "提交"), (.pull, "拉取"), (.push, "推送"), + (.read, "读取"), (.request, "请求"), (.run, "运行"), (.search, "搜索"), + (.suggest, "生成建议"), (.update, "更新"), (.wait, "等待"), (.write, "写入"), + (.changePermissions, "更改权限"), (.use, "使用"), + ] + + for (kind, verb) in actions { + var card = makeCard( + name: "semantic_\(kind.rawValue)", target: .local, + arguments: ["value": .string("Target 42")], + presentation: .init( + symbol: "wrench", activity: .semanticArgument(key: "value", fallback: .command), + actionKind: kind + ) + ) + let expected: [(AgentToolCard.State, String)] = [ + (.proposed, "正在准备\(verb)“Target 42”"), + (.waitingForApproval, "等待\(verb)“Target 42”"), + (.running, "正在\(verb)“Target 42”"), + (.completed, "已\(verb)“Target 42”"), + (.denied, "未\(verb)“Target 42”"), + (.failed, "\(verb)“Target 42”失败"), + (.interrupted, "\(verb)“Target 42”已中断"), + ] + for (state, title) in expected { + card.state = state + #expect(AgentToolPresentation.summary(for: card, locale: zh) == title) + } + } + } + + @Test + func localizedSearchBatchSSHAndMCPKeepUserAndRemoteTextVerbatim() { + let zh = Locale(identifier: "zh-Hans") + var search = makeCard( + name: "web_search", target: .network, + arguments: ["query": .string("Swift 6 concurrency")], + presentation: .init( + symbol: "magnifyingglass", + activity: .semanticArgument(key: "query", fallback: .web), + actionKind: .search + ) + ) + search.state = .completed + #expect( + AgentToolPresentation.summary(for: search, locale: zh) + == "已搜索“Swift 6 concurrency”" + ) + + var fetch = makeCard( + name: "fetch", target: .network, + presentation: .init( + symbol: "globe", activity: .semanticArgument(key: "url", fallback: .url), + actionKind: .fetch + ) + ) + fetch.state = .failed + #expect( + AgentToolPresentation.batchSummary(for: [search, fetch], locale: zh) + == "已搜索“Swift 6 concurrency”,获取URL失败" + ) + + var ssh = makeCard( + name: "ssh_exec", target: .host, + arguments: ["command": .string("printf 'hello world'")], + presentation: AgentBuiltInTools.terminalSuggest().descriptor.presentation + ) + ssh.state = .completed + #expect( + AgentToolPresentation.summary(for: ssh, hostName: "production-1", locale: zh) + == "已在production-1上运行“printf 'hello world'”" + ) + + var mcp = makeCard( + name: "mcp__airtable__find_records", target: .mcp, + presentation: .init( + symbol: "puzzlepiece.extension", activity: .label("airtable.find_records"), + actionKind: .run + ) + ) + mcp.state = .running + #expect( + AgentToolPresentation.summary(for: mcp, locale: zh) + == "正在运行airtable.find_records" + ) + mcp.state = .failed + #expect( + AgentToolPresentation.batchSummary(for: [mcp, mcp], locale: zh) + == "2次运行失败" + ) + mcp.state = .interrupted + #expect( + AgentToolPresentation.batchSummary(for: [mcp, mcp], locale: zh) + == "2次运行已中断" + ) + } + + @Test + func appOwnedActivitiesAreUnquotedAndLegacySearchUsesItsQuery() { + let zh = Locale(identifier: "zh-Hans") + + var hosts = makeCard(name: "list_hosts", target: .host) + hosts.state = .completed + #expect(AgentToolPresentation.summary(for: hosts, locale: zh) == "已列出主机") + + var restoredSearch = makeCard( + name: "web_search", target: .network, + arguments: ["query": .string("Swift concurrency")] + ) + restoredSearch.state = .completed + #expect( + AgentToolPresentation.summary(for: restoredSearch, locale: zh) + == "已搜索“Swift concurrency”" + ) + + var restoredSearchWithoutQuery = makeCard(name: "web_search", target: .network) + restoredSearchWithoutQuery.state = .completed + #expect( + AgentToolPresentation.summary(for: restoredSearchWithoutQuery, locale: zh) + == "已搜索" + ) + #expect( + AgentToolPresentation.batchSummary(for: [restoredSearchWithoutQuery], locale: zh) + == "已搜索" + ) + #expect( + AgentToolPresentation.batchSummary( + for: [restoredSearchWithoutQuery, restoredSearchWithoutQuery], locale: zh + ) == "已搜索2次" + ) + } + @Test func userInputSummaryCountsQuestionsAndUsesAskGrammar() { let descriptor = AgentBuiltInTools.requestUserInput().descriptor @@ -704,6 +842,63 @@ struct AgentToolPresentationTests { #expect(result.metadata[AgentToolResult.toolPresentationKey] != nil) } + @Test + func semanticPresentationRoundTripsAndOldMetadataUsesSafeDefaults() throws { + let semantic = AgentToolDescriptor.Presentation( + symbol: "magnifyingglass", + activity: .semanticArgument(key: "query", fallback: .web), + output: .json, groupsWithAdjacentTools: false, actionKind: .search, + phaseKinds: ["running": .sshConnecting] + ) + let encoded = try JSONEncoder().encode(semantic) + #expect( + try JSONDecoder().decode(AgentToolDescriptor.Presentation.self, from: encoded) + == semantic + ) + + let legacy = AgentToolDescriptor.Presentation( + symbol: "terminal", activity: .argument(key: "command", fallback: "a command") + ) + var object = try #require( + JSONSerialization.jsonObject(with: JSONEncoder().encode(legacy)) as? [String: Any] + ) + object["groupsWithAdjacentTools"] = nil + object["actionKind"] = nil + object["phaseKinds"] = nil + let decoded = try JSONDecoder().decode( + AgentToolDescriptor.Presentation.self, + from: JSONSerialization.data(withJSONObject: object) + ) + #expect(decoded.groupsWithAdjacentTools) + #expect(decoded.actionKind == nil) + #expect(decoded.phaseKinds.isEmpty) + } + + @Test + func legacyCountedArrayActivityStillDecodesAndLocalizesKnownQuestions() throws { + let activity = AgentToolDescriptor.Presentation.Activity.arrayCount( + key: "questions", singular: "a question", plural: "questions", + fallback: "a question" + ) + let data = try JSONEncoder().encode(activity) + #expect(try JSONDecoder().decode(type(of: activity), from: data) == activity) + + var card = makeCard( + name: "request_user_input", target: .user, + arguments: ["questions": .array([.object([:]), .object([:])])], + presentation: .init( + symbol: "questionmark.circle", activity: activity, + action: .init(infinitive: "ask", progressive: "Asking", past: "Asked") + ) + ) + card.state = .completed + #expect( + AgentToolPresentation.summary( + for: card, locale: Locale(identifier: "zh-Hans") + ) == "已询问2个问题" + ) + } + @Test func builtInToolsKeepDistinctStableIcons() { #expect( diff --git a/TermindTests/AppLanguageTests.swift b/TermindTests/AppLanguageTests.swift index deb649bd..02660234 100644 --- a/TermindTests/AppLanguageTests.swift +++ b/TermindTests/AppLanguageTests.swift @@ -99,7 +99,7 @@ struct AppLanguageTests { ) let strings = try #require(root["strings"] as? [String: Any]) let expected = [ - "App": "App", + "App": "应用", "Version": "版本", "Welcome": "欢迎", "Snippets": "片段", diff --git a/TermindTests/ChatComposerStateTests.swift b/TermindTests/ChatComposerStateTests.swift index 71ddcbb7..6ccf035a 100644 --- a/TermindTests/ChatComposerStateTests.swift +++ b/TermindTests/ChatComposerStateTests.swift @@ -1136,7 +1136,8 @@ struct ChatComposerStateTests { let run = conversation.workGroups.first?.run #expect(run.map { AgentWorkPresentation.title( - for: $0.state, from: $0.startedAt, to: $0.finishedAt ?? start + for: $0.state, from: $0.startedAt, to: $0.finishedAt ?? start, + locale: Locale(identifier: "en") ) } == "You stopped after 1m 2s") } @@ -1196,9 +1197,17 @@ struct ChatComposerStateTests { @Test func workDurationUsesAtMostTwoUnitsAndSuccessfulRunsCollapseByDefault() { let start = Date(timeIntervalSince1970: 1_000) - #expect(AgentWorkPresentation.duration(from: start, to: start.addingTimeInterval(42)) == "42s") - #expect(AgentWorkPresentation.duration(from: start, to: start.addingTimeInterval(68)) == "1m 8s") - #expect(AgentWorkPresentation.duration(from: start, to: start.addingTimeInterval(4_320)) == "1h 12m") + let en = Locale(identifier: "en") + let zh = Locale(identifier: "zh-Hans") + #expect(AgentWorkPresentation.duration( + from: start, to: start.addingTimeInterval(42), locale: en + ) == "42s") + #expect(AgentWorkPresentation.duration( + from: start, to: start.addingTimeInterval(68), locale: en + ) == "1m 8s") + #expect(AgentWorkPresentation.duration( + from: start, to: start.addingTimeInterval(4_320), locale: en + ) == "1h 12m") #expect(!AgentWorkPresentation.isExpandedByDefault(for: .completed)) #expect(AgentWorkPresentation.isExpandedByDefault(for: .running)) #expect(AgentWorkPresentation.isExpandedByDefault(for: .waitingForUser)) @@ -1206,14 +1215,32 @@ struct ChatComposerStateTests { #expect(AgentWorkPresentation.isExpandedByDefault(for: .cancelled)) #expect(AgentWorkPresentation.isExpandedByDefault(for: .interrupted)) #expect(AgentWorkPresentation.title( - for: .running, from: start, to: start.addingTimeInterval(68) + for: .running, from: start, to: start.addingTimeInterval(68), locale: en ) == "Working for 1m 8s") #expect(AgentWorkPresentation.title( - for: .completed, from: start, to: start.addingTimeInterval(68) + for: .completed, from: start, to: start.addingTimeInterval(68), locale: en ) == "Worked for 1m 8s") #expect(AgentWorkPresentation.title( - for: .cancelled, from: start, to: start.addingTimeInterval(62) + for: .cancelled, from: start, to: start.addingTimeInterval(62), locale: en ) == "You stopped after 1m 2s") + #expect(AgentWorkPresentation.duration( + from: start, to: start.addingTimeInterval(42), locale: zh + ) == "42秒") + #expect(AgentWorkPresentation.duration( + from: start, to: start.addingTimeInterval(68), locale: zh + ) == "1分钟8秒") + #expect(AgentWorkPresentation.duration( + from: start, to: start.addingTimeInterval(4_320), locale: zh + ) == "1小时12分钟") + #expect(AgentWorkPresentation.title( + for: .running, from: start, to: start.addingTimeInterval(68), locale: zh + ) == "已工作1分钟8秒") + #expect(AgentWorkPresentation.title( + for: .completed, from: start, to: start.addingTimeInterval(68), locale: zh + ) == "工作了1分钟8秒") + #expect(AgentWorkPresentation.title( + for: .cancelled, from: start, to: start.addingTimeInterval(62), locale: zh + ) == "你在1分钟2秒后停止了") } /// A turn that only called tools has nothing to say, and an empty bubble