Format with swift-format

`swift-format lint --strict --recursive Termind TermindTests
TermindMCPBridge` is in the contribution instructions but had never been
run to completion: 4133 violations across 141 files, most of them
indentation and line breaking the pretty-printer would have settled on
its own.

`swift-format format -i` accounts for all but five. The rest are
lint-only rules it will not rewrite for you:

- `SSHKeyMaterial` declared its libssh2 entry point in the C symbol's
  own spelling. `@_silgen_name` already carries the linkage name, so the
  Swift side is free to be `libssh2PublicKeyFromPrivateMemory`.
- Four `.forEach { ... }` calls become for-in loops, two in
  `PortForwardingController` and two in tests.

Formatting only, and checked as such rather than assumed: all 5409
string literals under `Termind` are byte-identical across the change, so
no localization key moved. Comparing whitespace-stripped token streams
leaves 43 files with any real change, and each is one of the rules
above, a dropped semicolon, a `case a, b` split onto separate lines with
its raw values kept explicit, or a file-scope `private` respelled
`fileprivate` — which is the same access level in that position.

Builds, and the suite stays at 975 of 975.
This commit is contained in:
Kaede Akino committed 2026-09-02 18:09:30 +08:00
1 parent 41404ca48d
commit ba46e90e30
139 files changed
+4709 -3401

No files matched your search

+6 -5
View File
@@ -21,11 +21,12 @@ nonisolated enum AppLocalization {
}
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
}
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)
+40 -30
View File
@@ -266,13 +266,14 @@ final class AppModel {
for hostID in changedHostIDs {
let oldHost = hosts.host(hostID)
let newHost = snapshot.hosts.first { $0.id == hostID }
let routeChanged = oldHost.map { old in
newHost.map {
old.host != $0.host || old.port != $0.port
|| old.identityID != $0.identityID || old.jumpHostID != $0.jumpHostID
|| old.proxy != $0.proxy
} ?? true
} ?? false
let routeChanged =
oldHost.map { old in
newHost.map {
old.host != $0.host || old.port != $0.port
|| old.identityID != $0.identityID || old.jumpHostID != $0.jumpHostID
|| old.proxy != $0.proxy
} ?? true
} ?? false
if routeChanged {
hosts.willDeleteHost?(hostID, hosts.portForwardings(for: hostID).map(\.id))
}
@@ -334,10 +335,12 @@ final class AppModel {
prompt
?? lastUserTurn?.text
?? ""
let userImages = promptImages
let userImages =
promptImages
?? lastUserTurn?.images
?? []
let userContextAttachments = promptContextAttachments
let userContextAttachments =
promptContextAttachments
?? lastUserTurn?.contextAttachments
?? []
guard !userPrompt.isEmpty || !userImages.isEmpty || !userContextAttachments.isEmpty
@@ -406,7 +409,8 @@ final class AppModel {
// thinking must not change what this run was told it had. Empty when the
// model cannot call tools, which is what keeps the catalog block from
// naming a tool this run was never given.
let skillCatalog = chat.model.abilities.contains(.toolCall)
let skillCatalog =
chat.model.abilities.contains(.toolCall)
? skills.catalog : AgentSkillCatalog()
var registry = AgentToolRegistry([AnyAgentTool]())
@@ -447,13 +451,14 @@ final class AppModel {
mcpServers: servers,
mcpClient: MCPClient(clientInfo: TermindAgentPolicy.mcpClientInfo)
),
additional: TermindAgentTools.tools(in: TermindAgentToolEnvironment(
hosts: agentHosts, connections: agentConnections,
authentication: agentAuthentication,
background: agentBackgroundCommands,
includesTerminalSuggestion: includesTerminalSuggestion,
workspace: scratch
))
additional: TermindAgentTools.tools(
in: TermindAgentToolEnvironment(
hosts: agentHosts, connections: agentConnections,
authentication: agentAuthentication,
background: agentBackgroundCommands,
includesTerminalSuggestion: includesTerminalSuggestion,
workspace: scratch
))
)
}
// The one place the resolved tool set exists. Recorded on the
@@ -537,9 +542,10 @@ final class AppModel {
guard proAccess.require(.assistant) else { return false }
guard !conversation.isStreaming, !conversation.isCompacting else { return false }
conversation.beginCompaction()
guard let span = conversation.compactionSpan(
keepRecentTokens: compactionBudget.keepRecentTokens
)
guard
let span = conversation.compactionSpan(
keepRecentTokens: compactionBudget.keepRecentTokens
)
else {
if isManual {
conversation.failCompaction(
@@ -560,9 +566,11 @@ final class AppModel {
span.summarize, instructions: instructions,
toolResultLimit: compactionBudget.toolResultLimit, model: model
)
guard let record = conversation.prepareCompaction(
summary: summary, cut: span.cut
) else {
guard
let record = conversation.prepareCompaction(
summary: summary, cut: span.cut
)
else {
throw AgentCompactionError.nothingToCompact
}
try await persistAndApplyCompaction(record, to: conversation, cut: span.cut)
@@ -682,7 +690,8 @@ final class AppModel {
$0.role == .assistant && !$0.text.isEmpty
})?.text
else { return nil }
let user = firstUser.text.isEmpty
let user =
firstUser.text.isEmpty
? firstUser.images.first?.label
?? firstUser.contextAttachments.first?.label
?? ""
@@ -781,7 +790,8 @@ final class AppModel {
let library = window?.assistantLibrary
else { return }
let toPinned = !conversation.isPinned
let nextID = (toPinned
let nextID =
(toPinned
? library.pinnedConversations
: library.activeConversations).first?.id
moveConversation(
@@ -868,9 +878,10 @@ final class AppModel {
) {
guard let source = conversation(id) else { return }
let title = assistantConversations.nextForkTitle(for: source)
guard let fork = source.forked(
throughAssistantMessage: messageID, title: title
)
guard
let fork = source.forked(
throughAssistantMessage: messageID, title: title
)
else { return }
let stored = StoredAgentConversation(
id: fork.id, messages: fork.transcript,
@@ -896,8 +907,7 @@ final class AppModel {
try await agentRunRepository.createConversation(stored)
guard let self else { return }
self.assistantConversations.insert(fork)
if inSidebar { window?.openAssistantInSidebar(fork.id) }
else { window?.selectAssistant(fork.id) }
if inSidebar { window?.openAssistantInSidebar(fork.id) } else { window?.selectAssistant(fork.id) }
} catch {
try? await AgentScratchWorkspace(conversationID: fork.id).removeScope()
window?.assistantOperationError = error.localizedDescription
+7 -3
View File
@@ -7,7 +7,9 @@ final class MCPServerController {
static let tokenReference = "termind.ai.mcpserver.token"
enum Status: Equatable {
case stopped, starting, ready(port: UInt16), failed(String)
case stopped, starting
case ready(port: UInt16)
case failed(String)
var label: String {
switch self {
@@ -58,9 +60,11 @@ final class MCPServerController {
self.defaults = defaults
self.canUsePro = canUsePro
self.requestPro = requestPro
isEnabled = defaults.object(forKey: SettingsKey.mcpServerEnabled) as? Bool
isEnabled =
defaults.object(forKey: SettingsKey.mcpServerEnabled) as? Bool
?? SettingsKey.defaultMCPServerEnabled
port = defaults.object(forKey: SettingsKey.mcpServerPort) as? Int
port =
defaults.object(forKey: SettingsKey.mcpServerPort) as? Int
?? SettingsKey.defaultMCPServerPort
let initialDisabledTools = Self.readDisabledTools(defaults)
disabledTools = initialDisabledTools
+19 -15
View File
@@ -39,21 +39,21 @@ struct RootView: View {
)
} detail: {
detail
// Floating over the pane rather than docked under it, so switching
// to a Workspace no longer resizes the content area — and so the
// bar reads as belonging to the session it describes.
.overlay(alignment: .bottomTrailing) {
if case .workspace = window.selectedDestination {
StatusBarView()
.padding(Metrics.StatusBar.floatingInset)
// Floating over the pane rather than docked under it, so switching
// to a Workspace no longer resizes the content area — and so the
// bar reads as belonging to the session it describes.
.overlay(alignment: .bottomTrailing) {
if case .workspace = window.selectedDestination {
StatusBarView()
.padding(Metrics.StatusBar.floatingInset)
}
}
}
.frame(
minWidth: Metrics.Window.detailMin,
maxWidth: .infinity,
minHeight: Metrics.Window.detailMinHeight,
maxHeight: .infinity
)
.frame(
minWidth: Metrics.Window.detailMin,
maxWidth: .infinity,
minHeight: Metrics.Window.detailMinHeight,
maxHeight: .infinity
)
}
.navigationSplitViewStyle(.balanced)
.background {
@@ -74,7 +74,11 @@ struct RootView: View {
maxHeight: .infinity
)
.background { FullHeightWindowContent() }
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { layoutWidth = $0 }
.onGeometryChange(for: CGFloat.self) {
$0.size.width
} action: {
layoutWidth = $0
}
.toolbarBackground(.hidden, for: .windowToolbar)
.navigationTitle(
WindowTitleCatalog.standard(hosts: app.hostLibrary)
+13 -9
View File
@@ -159,14 +159,16 @@
}
let first = Pane(.sshSession(firstSession))
first.customTitle = showsCommandResult
first.customTitle =
showsCommandResult
? text(en: "nginx logs", zh: "Nginx 日志")
: "api-01"
let sftpSession = SFTPSessionModel.demo(hostID: saved[0].id)
sftpSession.selection = ["/etc/nginx/nginx.conf"]
let sftp = Pane(.sftpSession(sftpSession))
sftp.customTitle = text(en: "nginx config", zh: "Nginx 配置")
let secondSession = showsCommandResult
let secondSession =
showsCommandResult
? SSHSessionModel.demo(host: saved[1])
: SSHSessionModel.demo(
host: saved[1],
@@ -196,7 +198,8 @@
)
}
workspace.focusedPaneID = first.id
workspace.customTitle = showsCommandResult
workspace.customTitle =
showsCommandResult
? text(en: "Log investigation", zh: "日志调查")
: text(en: "Production diagnostics", zh: "生产环境诊断")
workspace.broadcastsInput = !showsCommandResult
@@ -236,12 +239,13 @@
"Aug 27 09:14:03 web-02 systemd[1]: Started nginx.service",
]
}
return ([
"\u{001B}]7;file://\(host)/var/log/nginx\u{0007}",
"\u{001B}[2mLast login: Thu Aug 27 16:42:18 from 10.0.0.12\u{001B}[0m",
] + history + [
prompt + " tail -f /var/log/nginx/access.log",
]).joined(separator: "\r\n")
return
([
"\u{001B}]7;file://\(host)/var/log/nginx\u{0007}",
"\u{001B}[2mLast login: Thu Aug 27 16:42:18 from 10.0.0.12\u{001B}[0m",
] + history + [
prompt + " tail -f /var/log/nginx/access.log"
]).joined(separator: "\r\n")
}
private func text(en: String, zh: String) -> String {
+2 -2
View File
@@ -32,8 +32,8 @@ struct TermindCommands: Commands {
app.onboarding.recordUse(of: .quickOpen)
window?.isQuickOpenPresented = true
}
.termindShortcut("p")
.disabled(window == nil)
.termindShortcut("p")
.disabled(window == nil)
Divider()
@@ -75,8 +75,10 @@ nonisolated struct SFTPListTool: SFTPToolDefinition {
)
let authentication: AgentSSHAuthenticationRegistry
let connections: AgentConnectionPool
func perform(client: any SFTPClientProtocol, path: String, arguments: Arguments,
invocation: AgentToolInvocation) async throws -> AgentToolResult {
func perform(
client: any SFTPClientProtocol, path: String, arguments: Arguments,
invocation: AgentToolInvocation
) async throws -> AgentToolResult {
let files = try await client.listDirectory(at: path)
return Self.result(invocation, .array(files.map(AgentToolSupport.remoteFileJSON)))
}
@@ -95,8 +97,10 @@ nonisolated struct SFTPStatTool: SFTPToolDefinition {
)
let authentication: AgentSSHAuthenticationRegistry
let connections: AgentConnectionPool
func perform(client: any SFTPClientProtocol, path: String, arguments: Arguments,
invocation: AgentToolInvocation) async throws -> AgentToolResult {
func perform(
client: any SFTPClientProtocol, path: String, arguments: Arguments,
invocation: AgentToolInvocation
) async throws -> AgentToolResult {
guard let file = try await client.attributes(at: path) else { throw AgentToolError.notFound }
return Self.result(invocation, AgentToolSupport.remoteFileJSON(file))
}
@@ -115,15 +119,19 @@ nonisolated struct SFTPReadTool: SFTPToolDefinition {
)
let authentication: AgentSSHAuthenticationRegistry
let connections: AgentConnectionPool
func perform(client: any SFTPClientProtocol, path: String, arguments: Arguments,
invocation: AgentToolInvocation) async throws -> AgentToolResult {
func perform(
client: any SFTPClientProtocol, path: String, arguments: Arguments,
invocation: AgentToolInvocation
) async throws -> AgentToolResult {
let data = try await client.readFile(at: path, maximumBytes: 128 * 1_024)
guard let text = String(data: data, encoding: .utf8) else {
throw AgentToolError.invalidArguments("The remote file is not UTF-8 text.")
}
return Self.result(invocation, .object([
"path": .string(path), "content": .string(text), "bytes": .number(Double(data.count)),
]))
return Self.result(
invocation,
.object([
"path": .string(path), "content": .string(text), "bytes": .number(Double(data.count)),
]))
}
}
@@ -143,10 +151,13 @@ nonisolated struct SFTPWriteTool: SFTPToolDefinition {
)
let authentication: AgentSSHAuthenticationRegistry
let connections: AgentConnectionPool
func perform(client: any SFTPClientProtocol, path: String, arguments: Arguments,
invocation: AgentToolInvocation) async throws -> AgentToolResult {
try await client.writeFile(Data(try arguments.string("content").utf8), at: path,
overwrite: arguments.optionalBool("overwrite") ?? false)
func perform(
client: any SFTPClientProtocol, path: String, arguments: Arguments,
invocation: AgentToolInvocation
) async throws -> AgentToolResult {
try await client.writeFile(
Data(try arguments.string("content").utf8), at: path,
overwrite: arguments.optionalBool("overwrite") ?? false)
return Self.result(invocation, .object(["path": .string(path), "written": .bool(true)]))
}
}
@@ -165,8 +176,10 @@ nonisolated struct SFTPMkdirTool: SFTPToolDefinition {
)
let authentication: AgentSSHAuthenticationRegistry
let connections: AgentConnectionPool
func perform(client: any SFTPClientProtocol, path: String, arguments: Arguments,
invocation: AgentToolInvocation) async throws -> AgentToolResult {
func perform(
client: any SFTPClientProtocol, path: String, arguments: Arguments,
invocation: AgentToolInvocation
) async throws -> AgentToolResult {
try await client.createDirectory(at: path)
return Self.result(invocation, .object(["path": .string(path), "created": .bool(true)]))
}
@@ -187,14 +200,19 @@ nonisolated struct SFTPMoveTool: SFTPToolDefinition {
)
let authentication: AgentSSHAuthenticationRegistry
let connections: AgentConnectionPool
func perform(client: any SFTPClientProtocol, path: String, arguments: Arguments,
invocation: AgentToolInvocation) async throws -> AgentToolResult {
func perform(
client: any SFTPClientProtocol, path: String, arguments: Arguments,
invocation: AgentToolInvocation
) async throws -> AgentToolResult {
let destination = try AgentToolSupport.normalizeRemotePath(arguments.string("destination"))
try await client.rename(from: path, to: destination,
overwrite: arguments.optionalBool("overwrite") ?? false)
return Self.result(invocation, .object([
"source": .string(path), "destination": .string(destination), "moved": .bool(true),
]))
try await client.rename(
from: path, to: destination,
overwrite: arguments.optionalBool("overwrite") ?? false)
return Self.result(
invocation,
.object([
"source": .string(path), "destination": .string(destination), "moved": .bool(true),
]))
}
}
@@ -213,11 +231,14 @@ nonisolated struct SFTPChmodTool: SFTPToolDefinition {
)
let authentication: AgentSSHAuthenticationRegistry
let connections: AgentConnectionPool
func perform(client: any SFTPClientProtocol, path: String, arguments: Arguments,
invocation: AgentToolInvocation) async throws -> AgentToolResult {
func perform(
client: any SFTPClientProtocol, path: String, arguments: Arguments,
invocation: AgentToolInvocation
) async throws -> AgentToolResult {
let raw = try arguments.string("mode")
guard (3...4).contains(raw.count), raw.allSatisfy({ ("0"..."7").contains(String($0)) }),
let mode = UInt32(raw, radix: 8) else {
let mode = UInt32(raw, radix: 8)
else {
throw AgentToolError.invalidArguments("mode must be a 3- or 4-digit octal value.")
}
try await client.setPermissions(at: path, to: mode)
@@ -240,10 +261,15 @@ nonisolated struct SFTPDeleteTool: SFTPToolDefinition {
)
let authentication: AgentSSHAuthenticationRegistry
let connections: AgentConnectionPool
func perform(client: any SFTPClientProtocol, path: String, arguments: Arguments,
invocation: AgentToolInvocation) async throws -> AgentToolResult {
if try arguments.string("kind") == "directory" { try await client.removeDirectory(at: path) }
else { try await client.removeFile(at: path) }
func perform(
client: any SFTPClientProtocol, path: String, arguments: Arguments,
invocation: AgentToolInvocation
) async throws -> AgentToolResult {
if try arguments.string("kind") == "directory" {
try await client.removeDirectory(at: path)
} else {
try await client.removeFile(at: path)
}
return Self.result(invocation, .object(["path": .string(path), "deleted": .bool(true)]))
}
}
@@ -257,10 +283,11 @@ nonisolated extension SFTPListTool {
}
return [
.message(String(localized: "\(rows.count) items", locale: input.locale), .secondary),
.list(.init(
title: nil, rows: rows,
emptyMessage: F.localized("Directory is empty", locale: input.locale)
)),
.list(
.init(
title: nil, rows: rows,
emptyMessage: F.localized("Directory is empty", locale: input.locale)
)),
]
}
}
@@ -270,18 +297,23 @@ nonisolated extension SFTPStatTool {
typealias F = AgentToolDetailFormatting
return F.objectItems(input) { object in
var items: [AgentToolDetail.Item] = []
F.appendField(&items, "Path", object["path"]?.stringValue,
locale: input.locale, monospaced: true)
F.appendField(&items, "Kind", object["kind"]?.stringValue.map {
F.kindLabel($0, locale: input.locale)
}, locale: input.locale)
F.appendField(
&items, "Path", object["path"]?.stringValue,
locale: input.locale, monospaced: true)
F.appendField(
&items, "Kind",
object["kind"]?.stringValue.map {
F.kindLabel($0, locale: input.locale)
}, locale: input.locale)
if let bytes = object["size"]?.integerValue {
items.append(F.field("Size", F.byteCount(bytes), locale: input.locale))
}
F.appendField(&items, "Permissions", object["permissions"]?.stringValue,
locale: input.locale, monospaced: true)
F.appendField(&items, "Modified", object["modified_at"]?.stringValue.map(F.displayDate),
locale: input.locale)
F.appendField(
&items, "Permissions", object["permissions"]?.stringValue,
locale: input.locale, monospaced: true)
F.appendField(
&items, "Modified", object["modified_at"]?.stringValue.map(F.displayDate),
locale: input.locale)
return items
}
}
@@ -291,13 +323,17 @@ nonisolated extension SFTPReadTool {
typealias F = AgentToolDetailFormatting
return F.objectItems(input) { object in
var items: [AgentToolDetail.Item] = []
F.appendField(&items, "Path", object["path"]?.stringValue,
locale: input.locale, monospaced: true)
F.appendField(
&items, "Path", object["path"]?.stringValue,
locale: input.locale, monospaced: true)
let content = object["content"]?.stringValue ?? ""
items.append(content.isEmpty
? .message(F.localized("Empty file", locale: input.locale), .secondary)
: .text(.init(title: F.localized("Content", locale: input.locale),
text: content, style: .monospaced)))
items.append(
content.isEmpty
? .message(F.localized("Empty file", locale: input.locale), .secondary)
: .text(
.init(
title: F.localized("Content", locale: input.locale),
text: content, style: .monospaced)))
if let bytes = object["bytes"]?.integerValue {
items.append(F.field("Size", F.byteCount(bytes), locale: input.locale))
}
@@ -310,13 +346,16 @@ nonisolated extension SFTPWriteTool {
typealias F = AgentToolDetailFormatting
return F.objectItems(input) { object in
var items: [AgentToolDetail.Item] = []
F.appendField(&items, "Path", object["path"]?.stringValue,
locale: input.locale, monospaced: true)
F.appendField(
&items, "Path", object["path"]?.stringValue,
locale: input.locale, monospaced: true)
if let content = input.arguments["content"]?.stringValue {
items.append(F.field("Size", F.byteCount(content.utf8.count), locale: input.locale))
}
items.append(F.field("Result", F.localized("Written", locale: input.locale),
locale: input.locale))
items.append(
F.field(
"Result", F.localized("Written", locale: input.locale),
locale: input.locale))
return items
}
}
@@ -324,7 +363,8 @@ nonisolated extension SFTPWriteTool {
nonisolated extension SFTPMkdirTool {
static func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
SFTPToolPresentation.pathDetail(
input, result: AgentToolDetailFormatting.localized(
input,
result: AgentToolDetailFormatting.localized(
"Directory created", locale: input.locale
)
)
@@ -335,10 +375,12 @@ nonisolated extension SFTPMoveTool {
typealias F = AgentToolDetailFormatting
return F.objectItems(input) { object in
var items: [AgentToolDetail.Item] = []
F.appendField(&items, "Source", object["source"]?.stringValue,
locale: input.locale, monospaced: true)
F.appendField(&items, "Destination", object["destination"]?.stringValue,
locale: input.locale, monospaced: true)
F.appendField(
&items, "Source", object["source"]?.stringValue,
locale: input.locale, monospaced: true)
F.appendField(
&items, "Destination", object["destination"]?.stringValue,
locale: input.locale, monospaced: true)
return items
}
}
@@ -348,10 +390,12 @@ nonisolated extension SFTPChmodTool {
typealias F = AgentToolDetailFormatting
return F.objectItems(input) { object in
var items: [AgentToolDetail.Item] = []
F.appendField(&items, "Path", object["path"]?.stringValue,
locale: input.locale, monospaced: true)
F.appendField(&items, "Permissions", object["mode"]?.stringValue,
locale: input.locale, monospaced: true)
F.appendField(
&items, "Path", object["path"]?.stringValue,
locale: input.locale, monospaced: true)
F.appendField(
&items, "Permissions", object["mode"]?.stringValue,
locale: input.locale, monospaced: true)
return items
}
}
@@ -361,13 +405,18 @@ nonisolated extension SFTPDeleteTool {
typealias F = AgentToolDetailFormatting
return F.objectItems(input) { object in
var items: [AgentToolDetail.Item] = []
F.appendField(&items, "Path", object["path"]?.stringValue,
locale: input.locale, monospaced: true)
F.appendField(&items, "Kind", input.arguments["kind"]?.stringValue.map {
F.kindLabel($0, locale: input.locale)
}, locale: input.locale)
items.append(F.field("Result", F.localized("Deleted", locale: input.locale),
locale: input.locale))
F.appendField(
&items, "Path", object["path"]?.stringValue,
locale: input.locale, monospaced: true)
F.appendField(
&items, "Kind",
input.arguments["kind"]?.stringValue.map {
F.kindLabel($0, locale: input.locale)
}, locale: input.locale)
items.append(
F.field(
"Result", F.localized("Deleted", locale: input.locale),
locale: input.locale))
return items
}
}
@@ -380,7 +429,8 @@ nonisolated enum SFTPToolPresentation {
typealias F = AgentToolDetailFormatting
guard let object = value.objectValue else { return nil }
let path = object["path"]?.stringValue ?? ""
let name = F.nonempty(object["name"]?.stringValue)
let name =
F.nonempty(object["name"]?.stringValue)
?? F.nonempty(path.split(separator: "/").last.map(String.init))
?? F.localized("Unnamed item", locale: locale)
let kind = object["kind"]?.stringValue ?? ""
@@ -402,8 +452,9 @@ nonisolated enum SFTPToolPresentation {
typealias F = AgentToolDetailFormatting
return F.objectItems(input) { object in
var items: [AgentToolDetail.Item] = []
F.appendField(&items, "Path", object["path"]?.stringValue,
locale: input.locale, monospaced: true)
F.appendField(
&items, "Path", object["path"]?.stringValue,
locale: input.locale, monospaced: true)
items.append(F.field("Result", result, locale: input.locale))
return items
}
@@ -452,7 +503,9 @@ nonisolated extension AgentToolSupport {
if component == ".." {
guard !components.isEmpty else { throw AgentToolError.invalidPath }
components.removeLast()
} else { components.append(component) }
} else {
components.append(component)
}
}
return "/" + components.joined(separator: "/")
}
@@ -7,33 +7,42 @@ nonisolated struct ListHostsTool: AgentToolDefinition, AgentToolSchemaBuilding {
)
let hosts: AgentHostResolver
var descriptor: AgentToolDescriptor { Self.descriptor(
"list_hosts", "List Vault and temporary Termind SSH targets without credentials.",
properties: [:], required: [], target: .host, safety: .locallyReadOnly,
concurrency: .parallel,
presentation: .init(
symbol: "server.rack", activity: .semanticLabel(.hosts), output: .json,
actionKind: .list
var descriptor: AgentToolDescriptor {
Self.descriptor(
"list_hosts", "List Vault and temporary Termind SSH targets without credentials.",
properties: [:], required: [], target: .host, safety: .locallyReadOnly,
concurrency: .parallel,
presentation: .init(
symbol: "server.rack", activity: .semanticLabel(.hosts), output: .json,
actionKind: .list
)
)
) }
}
func execute(_ invocation: AgentToolInvocation, context: AgentToolExecutionContext) async throws
-> AgentToolResult {
-> AgentToolResult
{
let listing = await hosts.listing()
let rows = listing.hosts.map { target in AgentJSONValue.object([
"id": .string(target.id.uuidString), "display_name": .string(target.displayName),
"address": .string(target.address), "tags": .array(target.tags.map(AgentJSONValue.string)),
"connection_state": .string(target.connectionState), "temporary": .bool(target.isTemporary),
"group_id": target.groupID.map { .string($0.uuidString) } ?? .null,
"group_path": target.groupPath.map(AgentJSONValue.string) ?? .null,
]) }
func groupJSON(_ group: AgentHostGroupTarget) -> AgentJSONValue { .object([
"id": .string(group.id.uuidString), "name": .string(group.name),
"path": .string(group.path), "children": .array(group.children.map(groupJSON)),
]) }
return Self.result(invocation, .object([
"groups": .array(listing.groups.map(groupJSON)), "hosts": .array(rows),
]))
let rows = listing.hosts.map { target in
AgentJSONValue.object([
"id": .string(target.id.uuidString), "display_name": .string(target.displayName),
"address": .string(target.address), "tags": .array(target.tags.map(AgentJSONValue.string)),
"connection_state": .string(target.connectionState), "temporary": .bool(target.isTemporary),
"group_id": target.groupID.map { .string($0.uuidString) } ?? .null,
"group_path": target.groupPath.map(AgentJSONValue.string) ?? .null,
])
}
func groupJSON(_ group: AgentHostGroupTarget) -> AgentJSONValue {
.object([
"id": .string(group.id.uuidString), "name": .string(group.name),
"path": .string(group.path), "children": .array(group.children.map(groupJSON)),
])
}
return Self.result(
invocation,
.object([
"groups": .array(listing.groups.map(groupJSON)), "hosts": .array(rows),
]))
}
}
@@ -43,20 +52,25 @@ nonisolated struct SSHWaitTool: AgentToolDefinition, AgentToolSchemaBuilding {
)
let background: AgentBackgroundCommands
var descriptor: AgentToolDescriptor { Self.descriptor(
"ssh_wait", "Wait for a command ssh_exec reported as still running, and return its output.",
properties: ["handle": Self.string(max: 64),
"timeout_seconds": Self.integer(min: 1, max: 900)],
required: ["handle"], target: .host, safety: .locallyReadOnly,
concurrency: .parallel,
presentation: .init(
symbol: "clock.arrow.circlepath", activity: .semanticLabel(.remoteCommand),
output: .streams, actionKind: .wait
var descriptor: AgentToolDescriptor {
Self.descriptor(
"ssh_wait", "Wait for a command ssh_exec reported as still running, and return its output.",
properties: [
"handle": Self.string(max: 64),
"timeout_seconds": Self.integer(min: 1, max: 900),
],
required: ["handle"], target: .host, safety: .locallyReadOnly,
concurrency: .parallel,
presentation: .init(
symbol: "clock.arrow.circlepath", activity: .semanticLabel(.remoteCommand),
output: .streams, actionKind: .wait
)
)
) }
}
func execute(_ invocation: AgentToolInvocation, context: AgentToolExecutionContext) async throws
-> AgentToolResult {
-> AgentToolResult
{
let arguments = try Arguments(invocation)
guard let handle = UUID(uuidString: try arguments.string("handle")) else {
throw AgentToolError.invalidArguments("handle is not a valid handle.")
@@ -66,15 +80,21 @@ nonisolated struct SSHWaitTool: AgentToolDefinition, AgentToolSchemaBuilding {
reporting: AgentToolSupport.reporting(invocation, context.report),
orWhen: context.whenUserInterjects
)
guard let exitCode = outcome.exitCode else { return Self.result(invocation, .object([
"status": .string("running"), "handle": .string(handle.uuidString),
"stdout": .string(outcome.standardOut), "stderr": .string(outcome.standardError),
])) }
return Self.result(invocation, .object([
"status": .string("exited"), "stdout": .string(outcome.standardOut),
"stderr": .string(outcome.standardError), "exit_code": .number(Double(exitCode)),
"signal": outcome.signal.map(AgentJSONValue.string) ?? .null,
]))
guard let exitCode = outcome.exitCode else {
return Self.result(
invocation,
.object([
"status": .string("running"), "handle": .string(handle.uuidString),
"stdout": .string(outcome.standardOut), "stderr": .string(outcome.standardError),
]))
}
return Self.result(
invocation,
.object([
"status": .string("exited"), "stdout": .string(outcome.standardOut),
"stderr": .string(outcome.standardError), "exit_code": .number(Double(exitCode)),
"signal": outcome.signal.map(AgentJSONValue.string) ?? .null,
]))
}
}
@@ -82,30 +102,37 @@ nonisolated struct TerminalSuggestTool: AgentToolDefinition, AgentToolSchemaBuil
static let presenter = AgentToolDetailPresenter(
id: "builtin.terminal_suggest", present: present
)
var descriptor: AgentToolDescriptor { Self.descriptor(
"terminal_suggest", "Create a command card. Only the user can insert or run it in a PTY.",
properties: ["command": Self.string(max: 32_768),
"explanation": Self.string(max: 4_096)],
required: ["command"], target: .user, safety: .locallyReadOnly,
concurrency: .parallel,
presentation: .init(
symbol: "terminal", activity: .semanticArgument(key: "command", fallback: .command),
groupsWithAdjacentTools: false, actionKind: .suggest
var descriptor: AgentToolDescriptor {
Self.descriptor(
"terminal_suggest", "Create a command card. Only the user can insert or run it in a PTY.",
properties: [
"command": Self.string(max: 32_768),
"explanation": Self.string(max: 4_096),
],
required: ["command"], target: .user, safety: .locallyReadOnly,
concurrency: .parallel,
presentation: .init(
symbol: "terminal", activity: .semanticArgument(key: "command", fallback: .command),
groupsWithAdjacentTools: false, actionKind: .suggest
)
)
) }
}
func execute(_ invocation: AgentToolInvocation, context: AgentToolExecutionContext) async throws
-> AgentToolResult {
-> AgentToolResult
{
let arguments = try Arguments(invocation)
let command = try arguments.string("command")
guard !command.contains("\n"), !command.contains("\r"), !command.contains("\0") else {
throw AgentToolError.invalidArguments("A terminal suggestion must be one command line.")
}
return Self.result(invocation, .object([
"command": .string(command),
"explanation": .string(arguments.optionalString("explanation") ?? ""),
"risk": .string(CommandRiskClassifier.assess(command).risk.label),
"requires_user_action": .bool(true),
]))
return Self.result(
invocation,
.object([
"command": .string(command),
"explanation": .string(arguments.optionalString("explanation") ?? ""),
"risk": .string(CommandRiskClassifier.assess(command).risk.label),
"requires_user_action": .bool(true),
]))
}
}
@@ -119,12 +146,15 @@ nonisolated extension TerminalSuggestTool {
static func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
typealias F = AgentToolDetailFormatting
let object = input.result.objectValue ?? [:]
let command = input.arguments["command"]?.stringValue
let command =
input.arguments["command"]?.stringValue
?? object["command"]?.stringValue ?? ""
var items: [AgentToolDetail.Item] = [F.text("Command", command, locale: input.locale)]
if let explanation = F.nonempty(
input.arguments["explanation"]?.stringValue ?? object["explanation"]?.stringValue
) { items.append(.message(explanation, .secondary)) }
) {
items.append(.message(explanation, .secondary))
}
let assessment = CommandRiskClassifier.assess(command)
items.append(F.field("Risk", assessment.risk.label, locale: input.locale))
items.append(contentsOf: assessment.reasons.map { .message($0, .warning) })
@@ -155,10 +185,12 @@ nonisolated extension AgentToolSupport {
_ report: @escaping @Sendable (AgentToolResult) -> Void
) -> @Sendable (String, String) -> Void {
{ out, error in
var progress = result(invocation, .object([
"status": .string("running"),
"stdout": .string(out), "stderr": .string(error),
]))
var progress = result(
invocation,
.object([
"status": .string("running"),
"stdout": .string(out), "stderr": .string(error),
]))
progress.metadata["presentation_phase"] = .string("command")
report(progress)
}
@@ -167,14 +199,16 @@ nonisolated extension AgentToolSupport {
fileprivate static func exitedResult(
_ invocation: AgentToolInvocation, _ output: SSHExecResult
) -> AgentToolResult {
result(invocation, .object([
"status": .string("exited"),
"stdout": .string(output.stdout), "stderr": .string(output.stderr),
"exit_code": .number(Double(output.exitCode)),
"signal": output.signal.map(AgentJSONValue.string) ?? .null,
"stdout_truncated": .bool(output.stdoutTruncated),
"stderr_truncated": .bool(output.stderrTruncated),
]), truncated: output.stdoutTruncated || output.stderrTruncated)
result(
invocation,
.object([
"status": .string("exited"),
"stdout": .string(output.stdout), "stderr": .string(output.stderr),
"exit_code": .number(Double(output.exitCode)),
"signal": output.signal.map(AgentJSONValue.string) ?? .null,
"stdout_truncated": .bool(output.stdoutTruncated),
"stderr_truncated": .bool(output.stderrTruncated),
]), truncated: output.stdoutTruncated || output.stderrTruncated)
}
/// Picks up a command `ssh_exec` left running.
@@ -191,7 +225,8 @@ nonisolated struct SSHExecTool: AgentToolDefinition, AgentToolSchemaBuilding {
let pool: AgentConnectionPool
let background: AgentBackgroundCommands
var descriptor: AgentToolDescriptor { Self.descriptor(
var descriptor: AgentToolDescriptor {
Self.descriptor(
"ssh_exec", "Execute one command over a dedicated non-PTY SSH exec channel.",
properties: [
"host_id": Self.string(), "command": Self.string(max: 32_768),
@@ -228,151 +263,157 @@ nonisolated struct SSHExecTool: AgentToolDefinition, AgentToolSchemaBuilding {
"interrupted": "Stopped waiting for “{activity}” on {target}",
]
)
) }
)
}
func preflight(_ invocation: AgentToolInvocation) async throws -> AgentToolPreflight {
var checked = invocation
let arguments = try Arguments(invocation)
checked.targetHostID = try arguments.uuid("host_id")
let command = try arguments.string("command")
if arguments.optionalString("stdin") != nil && arguments.optionalString("secret_handle") != nil {
throw AgentToolError.invalidArguments("stdin and secret_handle are mutually exclusive.")
}
let assessment = CommandRiskClassifier.assess(command)
let safe = assessment.risk == .safe
return AgentToolPreflight(
invocation: checked,
safety: safe ? .locallyReadOnly : .requiresAuthorization,
reasons: assessment.reasons,
// Whether a command may run beside another is a property of
// the command, not of `ssh_exec`, and it is decided by the
// same locally-proven evidence that just decided `safety`.
// A command the classifier cannot vouch for makes its whole
// turn serial, so nothing observes the host while it is
// being changed.
//
// The ceiling on what this buys is worth stating: commands
// aimed at one host queue behind each other inside
// `SSHExecSession` regardless, because a libssh2 session
// cannot be driven from two places at once. The win is
// several hosts at once, and host work overlapping `fetch`,
// `web_search`, and MCP.
concurrency: safe ? .parallel : .sequential
)
var checked = invocation
let arguments = try Arguments(invocation)
checked.targetHostID = try arguments.uuid("host_id")
let command = try arguments.string("command")
if arguments.optionalString("stdin") != nil && arguments.optionalString("secret_handle") != nil {
throw AgentToolError.invalidArguments("stdin and secret_handle are mutually exclusive.")
}
let assessment = CommandRiskClassifier.assess(command)
let safe = assessment.risk == .safe
return AgentToolPreflight(
invocation: checked,
safety: safe ? .locallyReadOnly : .requiresAuthorization,
reasons: assessment.reasons,
// Whether a command may run beside another is a property of
// the command, not of `ssh_exec`, and it is decided by the
// same locally-proven evidence that just decided `safety`.
// A command the classifier cannot vouch for makes its whole
// turn serial, so nothing observes the host while it is
// being changed.
//
// The ceiling on what this buys is worth stating: commands
// aimed at one host queue behind each other inside
// `SSHExecSession` regardless, because a libssh2 session
// cannot be driven from two places at once. The win is
// several hosts at once, and host work overlapping `fetch`,
// `web_search`, and MCP.
concurrency: safe ? .parallel : .sequential
)
}
func execute(
_ invocation: AgentToolInvocation, context: AgentToolExecutionContext
) async throws -> AgentToolResult {
let arguments = try Arguments(invocation)
let hostID = try arguments.uuid("host_id")
let command = try arguments.string("command")
var input = arguments.optionalString("stdin").map { Data($0.utf8) }
if let rawHandle = arguments.optionalString("secret_handle") {
guard let handleID = UUID(uuidString: rawHandle) else {
throw AgentToolError.invalidArguments(
"secret_handle must be a handle issued by request_user_secret in this "
+ "run. Omit secret_handle and stdin_purpose when the command needs "
+ "no secret input; otherwise call request_user_secret again."
)
}
let purpose = try arguments.string("stdin_purpose")
let secret: String
do {
secret = try await context.secretBroker.consume(
SecretHandle(id: handleID),
matching: SecretBinding(
runID: context.runID, toolName: "ssh_exec", hostID: hostID,
purpose: purpose
)
)
} catch SecretBrokerError.notFound {
throw AgentToolError.invalidArguments(
"secret_handle is unavailable or has already been used. Call "
+ "request_user_secret again before retrying ssh_exec."
)
} catch SecretBrokerError.bindingMismatch {
throw AgentToolError.invalidArguments(
"secret_handle was issued for a different run, host, tool, or purpose. "
+ "Call request_user_secret again with matching values."
)
}
input = Data(secret.utf8)
}
// Live output, so a command that takes a minute is a card
// filling in rather than a spinner. Reported, never returned:
// the model is owed one result per call and gets it below.
let stream = AgentCommandOutputStream(
report: AgentToolSupport.reporting(invocation, context.report)
let arguments = try Arguments(invocation)
let hostID = try arguments.uuid("host_id")
let command = try arguments.string("command")
var input = arguments.optionalString("stdin").map { Data($0.utf8) }
if let rawHandle = arguments.optionalString("secret_handle") {
guard let handleID = UUID(uuidString: rawHandle) else {
throw AgentToolError.invalidArguments(
"secret_handle must be a handle issued by request_user_secret in this "
+ "run. Omit secret_handle and stdin_purpose when the command needs "
+ "no secret input; otherwise call request_user_secret again."
)
let cwd = arguments.optionalString("cwd")
// A `let` copy, because the task below outlives this call and a
// captured `var` would be shared mutable state across it.
let stdin = input
let deadline = Duration.seconds(
arguments.optionalInt("timeout_seconds")
?? AgentToolSupport.defaultReportingDeadline
)
// Connecting is inside the deadline too. A hang is a hang
// whether it is DNS, a jump host or a handshake, and a turn that
// ends on time is what lets the user be heard at all.
let handover = AgentCommandHandover()
let running = Task { () async throws -> SSHExecResult in
var connection = try await authentication.execConnection(
hostID: hostID, invocationID: invocation.id,
interaction: context.sshInteraction, pool: pool
}
let purpose = try arguments.string("stdin_purpose")
let secret: String
do {
secret = try await context.secretBroker.consume(
SecretHandle(id: handleID),
matching: SecretBinding(
runID: context.runID, toolName: "ssh_exec", hostID: hostID,
purpose: purpose
)
var retriedDeadSession = false
while true {
let session = connection.client
// The call may already have given up while this was
// connecting. Starting anyway would leave a command
// running, printing and finishing with nobody holding
// a handle to it.
guard retriedDeadSession || handover.beginRunning(on: session) else {
throw AgentCommandNotStarted()
}
context.report(AgentToolResult(
callID: invocation.call.id, content: "",
metadata: ["presentation_phase": .string("command")]
))
do {
return try await session.execute(
command, workingDirectory: cwd, stdin: stdin,
timeout: AgentToolSupport.maximumCommandLifetime,
outputLimit: 256 * 1_024,
onOutput: stream.handler
)
} catch SSHExecSessionError.channelUnavailable
where !retriedDeadSession
{
// Safe because no command channel opened. The
// authentication actor returned with the connection
// keeps one-use credentials for this retry, while its
// shared registry lease already ended.
retriedDeadSession = true
context.report(AgentToolResult(
callID: invocation.call.id, content: "",
metadata: ["presentation_phase": .string("running")]
))
await pool.disconnect(hostID: hostID, kind: .exec)
connection.client = try await authentication.openExec(
hostID: hostID, authentication: connection.authentication,
pool: pool
)
}
}
)
} catch SecretBrokerError.notFound {
throw AgentToolError.invalidArguments(
"secret_handle is unavailable or has already been used. Call "
+ "request_user_secret again before retrying ssh_exec."
)
} catch SecretBrokerError.bindingMismatch {
throw AgentToolError.invalidArguments(
"secret_handle was issued for a different run, host, tool, or purpose. "
+ "Call request_user_secret again with matching values."
)
}
input = Data(secret.utf8)
}
// Live output, so a command that takes a minute is a card
// filling in rather than a spinner. Reported, never returned:
// the model is owed one result per call and gets it below.
let stream = AgentCommandOutputStream(
report: AgentToolSupport.reporting(invocation, context.report)
)
let cwd = arguments.optionalString("cwd")
// A `let` copy, because the task below outlives this call and a
// captured `var` would be shared mutable state across it.
let stdin = input
let deadline = Duration.seconds(
arguments.optionalInt("timeout_seconds")
?? AgentToolSupport.defaultReportingDeadline
)
// Connecting is inside the deadline too. A hang is a hang
// whether it is DNS, a jump host or a handshake, and a turn that
// ends on time is what lets the user be heard at all.
let handover = AgentCommandHandover()
let running = Task { () async throws -> SSHExecResult in
var connection = try await authentication.execConnection(
hostID: hostID, invocationID: invocation.id,
interaction: context.sshInteraction, pool: pool
)
var retriedDeadSession = false
while true {
let session = connection.client
// The call may already have given up while this was
// connecting. Starting anyway would leave a command
// running, printing and finishing with nobody holding
// a handle to it.
guard retriedDeadSession || handover.beginRunning(on: session) else {
throw AgentCommandNotStarted()
}
context.report(
AgentToolResult(
callID: invocation.call.id, content: "",
metadata: ["presentation_phase": .string("command")]
))
do {
return try await session.execute(
command, workingDirectory: cwd, stdin: stdin,
timeout: AgentToolSupport.maximumCommandLifetime,
outputLimit: 256 * 1_024,
onOutput: stream.handler
)
} catch SSHExecSessionError.channelUnavailable
where !retriedDeadSession
{
// Safe because no command channel opened. The
// authentication actor returned with the connection
// keeps one-use credentials for this retry, while its
// shared registry lease already ended.
retriedDeadSession = true
context.report(
AgentToolResult(
callID: invocation.call.id, content: "",
metadata: ["presentation_phase": .string("running")]
))
await pool.disconnect(hostID: hostID, kind: .exec)
connection.client = try await authentication.openExec(
hostID: hostID, authentication: connection.authentication,
pool: pool
)
}
}
}
guard await AgentBackgroundCommands.settled(running, within: deadline) else {
guard let session = handover.giveUp() else {
// Still connecting, so there is nothing to hand over —
// no session, no output, no handle worth giving. The
// connection is left to finish into the pool rather than
// torn down, so the retry this invites is the fast one.
return Self.result(invocation, .object([
"status": .string("connecting"),
"note": .string(String(
guard await AgentBackgroundCommands.settled(running, within: deadline) else {
guard let session = handover.giveUp() else {
// Still connecting, so there is nothing to hand over —
// no session, no output, no handle worth giving. The
// connection is left to finish into the pool rather than
// torn down, so the retry this invites is the fast one.
return Self.result(
invocation,
.object([
"status": .string("connecting"),
"note": .string(
String(
localized: """
The host has not finished answering after \
\(deadline.components.seconds)s, so the command has not \
@@ -380,33 +421,36 @@ nonisolated struct SSHExecTool: AgentToolDefinition, AgentToolSchemaBuilding {
running this again shortly is likely to be quick.
"""
)),
]), truncated: false)
}
// It outlived its turn. The session goes with it — the
// channel is still being pumped, so it can be neither shared
// nor swept — and the next command for this host opens a
// fresh one.
await pool.detachExec(hostID: hostID, session: session)
let handle = await background.adopt(
hostID: hostID, command: command, stream: stream,
task: running, session: session
)
let captured = stream.captured
return Self.result(invocation, .object([
"status": .string("running"),
"handle": .string(handle.uuidString),
"stdout": .string(captured.standardOut),
"stderr": .string(captured.standardError),
"note": .string(String(
]), truncated: false)
}
// It outlived its turn. The session goes with it — the
// channel is still being pumped, so it can be neither shared
// nor swept — and the next command for this host opens a
// fresh one.
await pool.detachExec(hostID: hostID, session: session)
let handle = await background.adopt(
hostID: hostID, command: command, stream: stream,
task: running, session: session
)
let captured = stream.captured
return Self.result(
invocation,
.object([
"status": .string("running"),
"handle": .string(handle.uuidString),
"stdout": .string(captured.standardOut),
"stderr": .string(captured.standardError),
"note": .string(
String(
localized: """
Still running after \(deadline.components.seconds)s. The output \
above is what it has printed so far. Call ssh_wait with this \
handle for the rest; leaving it unasked-about abandons it.
"""
)),
]))
}
return AgentToolSupport.exitedResult(invocation, try await running.value)
]))
}
return AgentToolSupport.exitedResult(invocation, try await running.value)
}
}
@@ -422,15 +466,17 @@ nonisolated enum SSHCommandPresentation {
return F.objectItems(input) { object in
var items: [AgentToolDetail.Item] = []
if let status = object["status"]?.stringValue {
items.append(F.field(
"Status", F.statusLabel(status, locale: input.locale), locale: input.locale
))
items.append(
F.field(
"Status", F.statusLabel(status, locale: input.locale), locale: input.locale
))
}
if let code = object["exit_code"]?.integerValue {
items.append(F.field("Exit code", String(code), locale: input.locale, monospaced: true))
}
F.appendField(&items, "Signal", object["signal"]?.stringValue,
locale: input.locale, monospaced: true)
F.appendField(
&items, "Signal", object["signal"]?.stringValue,
locale: input.locale, monospaced: true)
let stdout = object["stdout"]?.stringValue ?? ""
let stderr = object["stderr"]?.stringValue ?? ""
if !stdout.isEmpty { items.append(F.text("Standard output", stdout, locale: input.locale)) }
@@ -460,79 +506,85 @@ nonisolated extension ListHostsTool {
) -> [AgentToolDetail.Item] {
typealias F = AgentToolDetailFormatting
return F.objectItems(input) { object in
let hostValues = object["hosts"]?.arrayValue ?? []
let groupValues = object["groups"]?.arrayValue ?? []
var groups: [HostGroupDetailProjection] = []
let hostValues = object["hosts"]?.arrayValue ?? []
let groupValues = object["groups"]?.arrayValue ?? []
var groups: [HostGroupDetailProjection] = []
func walk(_ rows: [AgentJSONValue], prefix: String = "") {
for row in rows {
guard let fields = row.objectValue else { continue }
let name = F.nonempty(fields["name"]?.stringValue)
?? F.localized("Unnamed group", locale: input.locale)
let computed = prefix.isEmpty ? name : "\(prefix) / \(name)"
let title = F.nonempty(fields["path"]?.stringValue) ?? computed
if let id = fields["id"]?.stringValue {
groups.append(.init(id: id, title: title))
func walk(_ rows: [AgentJSONValue], prefix: String = "") {
for row in rows {
guard let fields = row.objectValue else { continue }
let name =
F.nonempty(fields["name"]?.stringValue)
?? F.localized("Unnamed group", locale: input.locale)
let computed = prefix.isEmpty ? name : "\(prefix) / \(name)"
let title = F.nonempty(fields["path"]?.stringValue) ?? computed
if let id = fields["id"]?.stringValue {
groups.append(.init(id: id, title: title))
}
walk(fields["children"]?.arrayValue ?? [], prefix: computed)
}
walk(fields["children"]?.arrayValue ?? [], prefix: computed)
}
}
walk(groupValues)
walk(groupValues)
var grouped: [String: [AgentToolDetail.ListRow]] = [:]
var ungrouped: [AgentToolDetail.ListRow] = []
var temporary: [AgentToolDetail.ListRow] = []
var connected = 0
for hostValue in hostValues {
guard let host = hostValue.objectValue else { continue }
if host["connection_state"]?.stringValue == "connected" { connected += 1 }
let row = hostDetailRow(host, locale: input.locale)
if host["temporary"]?.boolValue == true {
temporary.append(row)
} else if let groupID = host["group_id"]?.stringValue,
groups.contains(where: { $0.id == groupID })
{
grouped[groupID, default: []].append(row)
} else {
ungrouped.append(row)
var grouped: [String: [AgentToolDetail.ListRow]] = [:]
var ungrouped: [AgentToolDetail.ListRow] = []
var temporary: [AgentToolDetail.ListRow] = []
var connected = 0
for hostValue in hostValues {
guard let host = hostValue.objectValue else { continue }
if host["connection_state"]?.stringValue == "connected" { connected += 1 }
let row = hostDetailRow(host, locale: input.locale)
if host["temporary"]?.boolValue == true {
temporary.append(row)
} else if let groupID = host["group_id"]?.stringValue,
groups.contains(where: { $0.id == groupID })
{
grouped[groupID, default: []].append(row)
} else {
ungrouped.append(row)
}
}
}
guard !hostValues.isEmpty || !groups.isEmpty else {
return [.message(F.localized("No hosts available", locale: input.locale), .secondary)]
}
func hostCount(_ count: Int) -> String {
String(localized: "\(count) hosts", locale: input.locale)
}
var summary = [hostCount(hostValues.count)]
if connected > 0 {
summary.append(String(localized: "\(connected) connected", locale: input.locale))
}
if !temporary.isEmpty {
summary.append(String(localized: "\(temporary.count) temporary", locale: input.locale))
}
var sections = groups.map { group in
let rows = grouped[group.id] ?? []
return AgentToolDetail.ListSection(
title: "\(group.title) · \(hostCount(rows.count))", rows: rows
)
}
if !groups.isEmpty || !ungrouped.isEmpty {
sections.append(.init(
title: "\(F.localized("Ungrouped", locale: input.locale)) · \(hostCount(ungrouped.count))",
rows: ungrouped
))
}
if !temporary.isEmpty {
sections.append(.init(
title: "\(F.localized("Temporary", locale: input.locale)) · \(hostCount(temporary.count))",
rows: temporary
))
}
return [.groupedList(.init(
summary: summary.joined(separator: " · "), sections: sections,
isScrollable: hostValues.count > 12
))]
guard !hostValues.isEmpty || !groups.isEmpty else {
return [.message(F.localized("No hosts available", locale: input.locale), .secondary)]
}
func hostCount(_ count: Int) -> String {
String(localized: "\(count) hosts", locale: input.locale)
}
var summary = [hostCount(hostValues.count)]
if connected > 0 {
summary.append(String(localized: "\(connected) connected", locale: input.locale))
}
if !temporary.isEmpty {
summary.append(String(localized: "\(temporary.count) temporary", locale: input.locale))
}
var sections = groups.map { group in
let rows = grouped[group.id] ?? []
return AgentToolDetail.ListSection(
title: "\(group.title) · \(hostCount(rows.count))", rows: rows
)
}
if !groups.isEmpty || !ungrouped.isEmpty {
sections.append(
.init(
title: "\(F.localized("Ungrouped", locale: input.locale)) · \(hostCount(ungrouped.count))",
rows: ungrouped
))
}
if !temporary.isEmpty {
sections.append(
.init(
title: "\(F.localized("Temporary", locale: input.locale)) · \(hostCount(temporary.count))",
rows: temporary
))
}
return [
.groupedList(
.init(
summary: summary.joined(separator: " · "), sections: sections,
isScrollable: hostValues.count > 12
))
]
}
}
@@ -541,7 +593,8 @@ nonisolated extension ListHostsTool {
) -> AgentToolDetail.ListRow {
typealias F = AgentToolDetailFormatting
let address = F.nonempty(host["address"]?.stringValue)
let name = F.nonempty(host["display_name"]?.stringValue) ?? address
let name =
F.nonempty(host["display_name"]?.stringValue) ?? address
?? F.localized("Unnamed host", locale: locale)
let state = connectionDetailState(host["connection_state"]?.stringValue ?? "", locale: locale)
let subtitle = [address ?? F.localized("Address unavailable", locale: locale), state.label]
@@ -562,11 +615,12 @@ nonisolated extension ListHostsTool {
case "reconnecting": (F.localized("Reconnecting", locale: locale), AgentToolDetail.Tint.warning)
case "disconnected": (F.localized("Not connected", locale: locale), AgentToolDetail.Tint.secondary)
case "failed": (F.localized("Failed", locale: locale), AgentToolDetail.Tint.failure)
default: (
F.nonempty(F.humanized(raw))?.capitalized
?? F.localized("Unknown", locale: locale),
AgentToolDetail.Tint.secondary
)
default:
(
F.nonempty(F.humanized(raw))?.capitalized
?? F.localized("Unknown", locale: locale),
AgentToolDetail.Tint.secondary
)
}
}
@@ -29,22 +29,22 @@ nonisolated struct AgentHostListing: Hashable, Sendable {
/// never serialized into a tool result.
@MainActor
final class AgentHostResolver: Sendable {
typealias HostKeyDiscovering = @Sendable (
_ hostID: Host.ID, _ host: String, _ port: Int,
_ proxy: SSHTransportProxy?, _ jumps: [SSHRouteHopConfiguration]
) async throws -> SSHHostKeyObservation
typealias HostKeyDiscovering =
@Sendable (
_ hostID: Host.ID, _ host: String, _ port: Int,
_ proxy: SSHTransportProxy?, _ jumps: [SSHRouteHopConfiguration]
) async throws -> SSHHostKeyObservation
private let store: HostLibraryModel
private let discoverHostKey: HostKeyDiscovering?
private var temporaryTargets: @MainActor @Sendable () -> [AgentHostTarget]
private var temporaryConfiguration:
@MainActor @Sendable (UUID) throws -> SSHConnectionConfiguration?
private var temporaryConfiguration: @MainActor @Sendable (UUID) throws -> SSHConnectionConfiguration?
init(
store: HostLibraryModel,
temporaryTargets: @escaping @MainActor @Sendable () -> [AgentHostTarget] = { [] },
temporaryConfiguration: @escaping
@MainActor @Sendable (UUID) throws -> SSHConnectionConfiguration? = { _ in nil },
temporaryConfiguration: @escaping @MainActor @Sendable (UUID) throws -> SSHConnectionConfiguration? = { _ in nil
},
discoverHostKey: HostKeyDiscovering? = nil
) {
self.store = store
@@ -55,8 +55,7 @@ final class AgentHostResolver: Sendable {
func configureTemporaryTargets(
targets: @escaping @MainActor @Sendable () -> [AgentHostTarget],
configuration: @escaping
@MainActor @Sendable (UUID) throws -> SSHConnectionConfiguration?
configuration: @escaping @MainActor @Sendable (UUID) throws -> SSHConnectionConfiguration?
) {
temporaryTargets = targets
temporaryConfiguration = configuration
@@ -74,12 +73,13 @@ final class AgentHostResolver: Sendable {
}
return AgentHostListing(
groups: groupTree(paths: paths),
hosts: saved + temporaryTargets().map { target in
var target = target
target.groupID = nil
target.groupPath = nil
return target
}
hosts: saved
+ temporaryTargets().map { target in
var target = target
target.groupID = nil
target.groupPath = nil
return target
}
)
}
@@ -123,10 +123,11 @@ final class AgentHostResolver: Sendable {
children: children
)
}
let roots = ordered(store.groups.filter { group in
guard let parentID = group.parentID else { return true }
return all[parentID] == nil
})
let roots = ordered(
store.groups.filter { group in
guard let parentID = group.parentID else { return true }
return all[parentID] == nil
})
var result = roots.map { build($0, ancestors: []) }
for group in ordered(store.groups) where !emitted.contains(group.id) {
result.append(build(group, ancestors: []))
@@ -188,7 +189,8 @@ final class AgentHostResolver: Sendable {
for: node, submissions: submissions, forcedHostID: forcedHostID
) {
let existing = submissions[node.id]
let identity = existing?.identityID.flatMap(store.identity)
let identity =
existing?.identityID.flatMap(store.identity)
?? store.identity(for: node)
let request = AgentSSHAuthenticationRequest(
invocationID: invocationID, hostID: node.id,
@@ -302,9 +304,11 @@ final class AgentHostResolver: Sendable {
configuration.hostID, configuration.host, configuration.port,
configuration.proxy, configuration.jumps
)
displayName = temporaryTargets().first { $0.id == configuration.hostID }?.displayName
displayName =
temporaryTargets().first { $0.id == configuration.hostID }?.displayName
?? configuration.host
address = configuration.port == 22
address =
configuration.port == 22
? configuration.host : "\(configuration.host):\(configuration.port)"
trustedCount = configuration.expectedHostKeys.count
configuration.expectedHostKeys.insert(observation.identity)
@@ -796,7 +800,10 @@ actor AgentConnectionPool {
}
}
private struct Key: Hashable { var hostID: UUID; var kind: Kind }
private struct Key: Hashable {
var hostID: UUID
var kind: Kind
}
private struct Entry {
var client: Client
@@ -864,9 +871,10 @@ actor AgentConnectionPool {
func exec(
hostID: UUID, configuration override: ConfigurationResolving? = nil
) async throws -> any SSHExecSessionProtocol {
guard case .exec(let session) = try await resolve(
hostID: hostID, kind: .exec, configuration: override
)
guard
case .exec(let session) = try await resolve(
hostID: hostID, kind: .exec, configuration: override
)
else { throw AgentToolError.invalidHost }
return session
}
@@ -874,9 +882,10 @@ actor AgentConnectionPool {
func sftp(
hostID: UUID, configuration override: ConfigurationResolving? = nil
) async throws -> any SFTPClientProtocol {
guard case .sftp(let client) = try await resolve(
hostID: hostID, kind: .sftp, configuration: override
)
guard
case .sftp(let client) = try await resolve(
hostID: hostID, kind: .sftp, configuration: override
)
else { throw AgentToolError.invalidHost }
return client
}
@@ -198,12 +198,13 @@ final class AppCommandRegistry {
),
]
if canShowAssistant {
commands.append(AppCommandDescriptor(
id: "action.show-assistant", title: String(localized: "Show Assistant in Sidebar"),
subtitle: String(localized: "Action"), symbol: "sidebar.left",
section: .actions,
keywords: ["sidebar", "assistant", "context", "details"], target: .showAssistant
))
commands.append(
AppCommandDescriptor(
id: "action.show-assistant", title: String(localized: "Show Assistant in Sidebar"),
subtitle: String(localized: "Action"), symbol: "sidebar.left",
section: .actions,
keywords: ["sidebar", "assistant", "context", "details"], target: .showAssistant
))
}
return commands
}
@@ -331,8 +331,8 @@ final class ConnectionJourney {
}
}
private extension ConnectionPurpose {
var logName: String {
extension ConnectionPurpose {
fileprivate var logName: String {
switch self {
case .sshSession: "SSH session"
case .sftpSession: "SFTP session"
@@ -23,11 +23,12 @@ final class RemoteSessionConnectionController {
func trustHostKey(
_ observation: SSHHostKeyObservation, using store: any RemoteSessionHostStore
) {
store.trust(KnownHostEntry(
hostname: observation.hostname, port: observation.port,
algorithm: observation.algorithm, fingerprint: observation.fingerprint,
addedAt: Date()
))
store.trust(
KnownHostEntry(
hostname: observation.hostname, port: observation.port,
algorithm: observation.algorithm, fingerprint: observation.fingerprint,
addedAt: Date()
))
if let pendingHostKeyHostID { temporaryHostKeys[pendingHostKeyHostID] = nil }
clearHostKeyPrompt()
journey.resume()
@@ -85,10 +85,13 @@ nonisolated enum TermindSSHError: LocalizedError, Sendable {
var errorDescription: String? {
switch self {
case .socket(let message), .proxy(let message), .route(let message),
.library(let message), .authenticationFailed(let message): message
.library(let message), .authenticationFailed(let message):
message
case .routeHost(_, _, _, _, let message): message
case .hostKeyChanged(let expected, let received):
String(localized: "The host key changed. Expected one of \(expected.joined(separator: ", ")), received \(received).")
String(
localized:
"The host key changed. Expected one of \(expected.joined(separator: ", ")), received \(received).")
case .missingCredential: String(localized: "The selected identity has no usable password or private key.")
case .keyPassphraseRequired: String(localized: "Enter the Passphrase for the selected key.")
case .missingUsername: String(localized: "The selected host has no login username.")
@@ -137,7 +140,8 @@ nonisolated enum TermindSSHError: LocalizedError, Sendable {
case .socket, .library, .disconnected: true
case .proxy, .route, .routeHost, .authenticationFailed, .hostKeyChanged,
.missingCredential, .keyPassphraseRequired, .missingUsername,
.untrustedHost, .mismatchedHostKey: false
.untrustedHost, .mismatchedHostKey:
false
}
}
}
@@ -129,9 +129,11 @@ final class SessionReconnectController {
retryTask = nil
}
}
nonisolated
private nonisolated extension Duration {
var timeInterval: TimeInterval {
extension Duration
{
fileprivate var timeInterval: TimeInterval {
let components = self.components
return TimeInterval(components.seconds)
+ TimeInterval(components.attoseconds) / 1_000_000_000_000_000_000
@@ -56,7 +56,8 @@ final class OutboxRunner {
try store.markSucceeded(job.id)
} catch {
let attempt = job.attemptCount + 1
let retryAt = attempt >= maximumAttempts
let retryAt =
attempt >= maximumAttempts
? nil
: Date().addingTimeInterval(pow(2, Double(attempt)))
try store.markFailed(
@@ -63,7 +63,8 @@ final class AIConfigurationModel {
self.mcpServers = mcpServers
self.roles = roles
self.lastSelectedModel = lastSelectedModel
self.lastSelectedPermissionMode = permissionDefaults
self.lastSelectedPermissionMode =
permissionDefaults
.flatMap { $0.string(forKey: SettingsKey.agentPermissionMode) }
.flatMap(AgentPermissionMode.init(rawValue:))
?? lastSelectedPermissionMode
@@ -185,9 +186,10 @@ final class AIConfigurationModel {
}
func hasMCPNamespaceConflict(_ namespaceID: String, excluding id: MCPServer.ID) -> Bool {
!namespaceID.isEmpty && mcpServers.contains {
$0.id != id && $0.effectiveNamespaceID == namespaceID
}
!namespaceID.isEmpty
&& mcpServers.contains {
$0.id != id && $0.effectiveNamespaceID == namespaceID
}
}
/// Every tool the model may be offered: enabled tools on enabled servers.
@@ -40,17 +40,21 @@ final class AssistantConversationStore {
}
private func forkRoot(for title: String, sourceID: UUID) -> String {
guard let range = title.range(
of: #" \([0-9]+\)$"#, options: .regularExpression
) else { return title }
guard
let range = title.range(
of: #" \([0-9]+\)$"#, options: .regularExpression
)
else { return title }
let candidate = String(title[..<range.lowerBound])
let belongsToExistingSequence = conversations.contains { conversation in
guard conversation.id != sourceID else { return false }
let other = conversation.derivedTitle
if other == candidate { return true }
guard let suffix = other.range(
of: #" \([0-9]+\)$"#, options: .regularExpression
) else { return false }
guard
let suffix = other.range(
of: #" \([0-9]+\)$"#, options: .regularExpression
)
else { return false }
return other[..<suffix.lowerBound] == candidate
}
return belongsToExistingSequence ? candidate : title
@@ -108,22 +112,30 @@ final class AssistantConversationStore {
// Empty is "never summarized", not a name of nothing — `derivedTitle`
// has to be able to tell those apart to fall back.
conversation.title = value.title.isEmpty ? nil : value.title
conversation.customTitle = value.metadata.customTitle.isEmpty
conversation.customTitle =
value.metadata.customTitle.isEmpty
? nil : value.metadata.customTitle
conversation.isPinned = value.metadata.isPinned
conversation.archivedAt = value.metadata.archivedAt
conversation.toolCards = value.toolCards.map { card in
let state: AgentToolCard.State
if card.result?.isInterrupted == true { state = .interrupted }
else if card.result?.isError == true { state = .failed }
else if card.result != nil || card.state == "finished" { state = .completed }
if card.result?.isInterrupted == true {
state = .interrupted
} else if card.result?.isError == true {
state = .failed
} else if card.result != nil || card.state == "finished" {
state = .completed
}
// Not `.running`, which is what this used to restore to. Every
// unfinished run is marked interrupted at launch, so a card
// that was mid-flight when the process died is not running
// now and never will be — it came back as a spinner that
// nothing would ever stop.
else if card.state == "started" { state = .interrupted }
else { state = .proposed }
else if card.state == "started" {
state = .interrupted
} else {
state = .proposed
}
return AgentToolCard(
invocation: card.invocation, descriptor: card.descriptor,
state: state, result: card.result, review: card.review
@@ -187,20 +199,23 @@ final class AssistantLibraryModel {
var pinned = pinnedConversations.filter { $0.id != id }
var chats = activeConversations.filter { $0.id != id }
if toPinned {
let index = nextID.flatMap { next in pinned.firstIndex { $0.id == next } }
let index =
nextID.flatMap { next in pinned.firstIndex { $0.id == next } }
?? pinned.endIndex
pinned.insert(source, at: index)
} else {
let index = nextID.flatMap { next in chats.firstIndex { $0.id == next } }
let index =
nextID.flatMap { next in chats.firstIndex { $0.id == next } }
?? chats.endIndex
chats.insert(source, at: index)
}
return pinned.enumerated().map {
StoredAgentConversationOrder(id: $0.element.id, isPinned: true, sortIndex: Double($0.offset))
} + chats.enumerated().map {
StoredAgentConversationOrder(id: $0.element.id, isPinned: false, sortIndex: Double($0.offset))
}
+ chats.enumerated().map {
StoredAgentConversationOrder(id: $0.element.id, isPinned: false, sortIndex: Double($0.offset))
}
}
func applyOrder(_ updates: [StoredAgentConversationOrder]) {
@@ -323,10 +338,11 @@ final class AssistantLibraryModel {
/// search field is not a way to read them.
func search(_ query: String, scope: SearchScope) -> [ConversationModel] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
let candidates: [ConversationModel] = switch scope {
case .active: pinnedConversations + activeConversations
case .archived: archivedConversations
}
let candidates: [ConversationModel] =
switch scope {
case .active: pinnedConversations + activeConversations
case .archived: archivedConversations
}
guard !trimmed.isEmpty else { return candidates }
return candidates.filter { $0.matches(trimmed) }
}
@@ -14,7 +14,8 @@ final class ConnectionHistoryModel {
entries: [ConnectionHistoryEntry] = [],
repository: (any ConnectionHistoryRepository)? = nil
) {
self.repository = repository
self.repository =
repository
?? InMemoryConnectionHistoryRepository(entries: entries)
}
@@ -66,8 +67,7 @@ final class ConnectionHistoryModel {
cachedEntries = nil
storageError = nil
revision &+= 1
}
catch { storageError = error.localizedDescription }
} catch { storageError = error.localizedDescription }
}
func clear(for hostID: Host.ID) {
@@ -76,8 +76,7 @@ final class ConnectionHistoryModel {
cachedEntries = nil
storageError = nil
revision &+= 1
}
catch { storageError = error.localizedDescription }
} catch { storageError = error.localizedDescription }
}
func clearAll() {
@@ -86,7 +85,6 @@ final class ConnectionHistoryModel {
cachedEntries = []
storageError = nil
revision &+= 1
}
catch { storageError = error.localizedDescription }
} catch { storageError = error.localizedDescription }
}
}
@@ -282,9 +282,10 @@ final class HostLibraryModel {
return String(localized: "No username")
}
let hasAvailablePassword = passwordAvailability(for: identity) == .available
let hasUsableKey = identity.keyID.flatMap { key($0) }.map {
keyAvailability(for: $0) == .available
} == true
let hasUsableKey =
identity.keyID.flatMap { key($0) }.map {
keyAvailability(for: $0) == .available
} == true
guard hasAvailablePassword || hasUsableKey else {
return String(localized: "No saved credential")
}
@@ -899,7 +900,10 @@ final class HostLibraryModel {
if previousStorage != updated.passwordStorage, !previousReference.isEmpty {
guard let oldPassword else {
throw TermindSSHError.library(
String(localized: "The saved password is unavailable on this Mac. Enter it again before changing storage.")
String(
localized:
"The saved password is unavailable on this Mac. Enter it again before changing storage."
)
)
}
try keychain.setString(
@@ -964,7 +968,8 @@ final class HostLibraryModel {
// The newly committed destination stays authoritative. Retaining
// the source is safer than rolling a successful save backward.
storageError = String(
localized: "The password was saved, but its previous Keychain copy could not be removed: \(error.localizedDescription)"
localized:
"The password was saved, but its previous Keychain copy could not be removed: \(error.localizedDescription)"
)
}
}
@@ -1002,7 +1007,8 @@ final class HostLibraryModel {
do {
password =
identity.credentialRef.isEmpty
? nil : try keychain.string(
? nil
: try keychain.string(
for: identity.credentialRef, storage: identity.passwordStorage
)
try keychain.removePassword(
@@ -1053,9 +1059,10 @@ final class HostLibraryModel {
_ = try SSHUserCertificate.parse(key.certificate, matching: key.publicKey)
}
let keySnapshot = keys
let changesLocation = previousKey.map {
$0.storage != key.storage || $0.credentialRef != key.credentialRef
} ?? false
let changesLocation =
previousKey.map {
$0.storage != key.storage || $0.credentialRef != key.credentialRef
} ?? false
if changesLocation,
previousKey?.storage == .secureEnclave || key.storage == .secureEnclave
{
@@ -1063,7 +1070,8 @@ final class HostLibraryModel {
String(localized: "Secure Enclave keys cannot be moved to another storage location.")
)
}
let previousCredential = try (credential != nil || changesLocation)
let previousCredential =
try (credential != nil || changesLocation)
? previousKey.flatMap { try keyCredential(for: $0) }
: nil
let destinationCredential = credential ?? (changesLocation ? previousCredential : nil)
@@ -1113,7 +1121,8 @@ final class HostLibraryModel {
)
} catch {
storageError = String(
localized: "The key was saved, but its previous Keychain copy could not be removed: \(error.localizedDescription)"
localized:
"The key was saved, but its previous Keychain copy could not be removed: \(error.localizedDescription)"
)
}
}
@@ -1519,11 +1528,12 @@ final class HostLibraryModel {
@discardableResult
private func persistCore() -> Bool {
do {
try vault.save(VaultSnapshot(
hosts: hosts, groups: groups, identities: identities,
keys: keys, knownHosts: knownHosts, snippets: snippets,
snippetPackages: snippetPackages, portForwardings: portForwardings
))
try vault.save(
VaultSnapshot(
hosts: hosts, groups: groups, identities: identities,
keys: keys, knownHosts: knownHosts, snippets: snippets,
snippetPackages: snippetPackages, portForwardings: portForwardings
))
storageError = nil
return true
} catch {
@@ -47,12 +47,18 @@ final class SFTPHistoryModel {
}
func delete(_ id: SFTPHistoryEntry.ID) {
do { try repository.delete(id); storageError = nil; revision &+= 1 }
catch { storageError = error.localizedDescription }
do {
try repository.delete(id)
storageError = nil
revision &+= 1
} catch { storageError = error.localizedDescription }
}
func clear(for hostID: Host.ID) {
do { try repository.clear(for: hostID); storageError = nil; revision &+= 1 }
catch { storageError = error.localizedDescription }
do {
try repository.clear(for: hostID)
storageError = nil
revision &+= 1
} catch { storageError = error.localizedDescription }
}
}
@@ -23,11 +23,13 @@ final class ToolAuditModel {
repository: (any ToolAuditRepository)? = nil,
hostName: @escaping (Host.ID) -> String? = { _ in nil }
) {
self.repository = repository ?? InMemoryToolAuditRepository(
entries: Array(
entries.sorted { $0.startedAt > $1.startedAt }.prefix(Self.globalLimit)
self.repository =
repository
?? InMemoryToolAuditRepository(
entries: Array(
entries.sorted { $0.startedAt > $1.startedAt }.prefix(Self.globalLimit)
)
)
)
self.hostName = hostName
recoverInterruptedCalls()
}
@@ -97,10 +97,11 @@ final class MCPServerTools {
groups: [.userInteraction],
secretConsumerTools: [TermindAgentPolicy.sshExecToolName]
),
additional: TermindAgentTools.tools(in: TermindAgentToolEnvironment(
hosts: hosts, connections: connections, authentication: authentication,
background: background
))
additional: TermindAgentTools.tools(
in: TermindAgentToolEnvironment(
hosts: hosts, connections: connections, authentication: authentication,
background: background
))
).filtering {
exportedNames.contains($0.name) && !disabledTools.contains($0.name)
}
@@ -23,8 +23,7 @@ protocol HostRepository: AnyObject {
final class InMemoryHostRepository: HostRepository {
private var value: HostLibrarySnapshot
private var revision: Int64 = 0
private var continuations: [UUID: AsyncStream<RepositoryChange<HostLibrarySnapshot>>.Continuation]
= [:]
private var continuations: [UUID: AsyncStream<RepositoryChange<HostLibrarySnapshot>>.Continuation] = [:]
init(snapshot: HostLibrarySnapshot = .empty) { value = snapshot }
@@ -45,4 +45,3 @@ final class InMemoryToolAuditRepository: ToolAuditRepository {
}
}
}
@@ -49,7 +49,9 @@ nonisolated struct SpeechTranscriptionFailure: Error, Equatable, Sendable {
case .recognizerUnavailable:
String(localized: "Apple Speech Recognition is not currently available for your system language.")
case .audioInputUnavailable:
String(localized: "Termind could not start an audio input. Check that a microphone is connected and available.")
String(
localized: "Termind could not start an audio input. Check that a microphone is connected and available."
)
case .noSpeech:
String(localized: "No recognizable speech was captured. Try speaking closer to the microphone.")
case .recognitionFailed:
+23 -17
View File
@@ -283,7 +283,8 @@ nonisolated enum Metrics {
/// Space to the right of the navigation sidebar in Vault's complete
/// working layout: two dividers, the main surface, and an Edit/New
/// detail panel.
static let trailingContentMinWidth: CGFloat = 1
static let trailingContentMinWidth: CGFloat =
1
+ Metrics.VaultContent.min
+ 1
+ Metrics.VaultDetail.min
@@ -482,11 +483,13 @@ struct TermindSidebarRowBackground: ViewModifier {
func body(content: Content) -> some View {
content
.frame(maxWidth: .infinity, alignment: .leading)
.foregroundStyle(SidebarSelectionStyle.foreground(
isSelected: isSelected,
controlActiveState: controlActiveState,
unselected: AnyShapeStyle(.primary)
))
.foregroundStyle(
SidebarSelectionStyle.foreground(
isSelected: isSelected,
controlActiveState: controlActiveState,
unselected: AnyShapeStyle(.primary)
)
)
.fontWeight(isSelected ? .semibold : .regular)
.background { backgroundFill }
.overlay { menuHost }
@@ -568,29 +571,32 @@ extension View {
tint: Color? = nil,
isInteractive: Bool = false
) -> some View {
modifier(TermindGlassShape(
shape: RoundedRectangle(cornerRadius: cornerRadius),
tint: tint,
isInteractive: isInteractive
))
modifier(
TermindGlassShape(
shape: RoundedRectangle(cornerRadius: cornerRadius),
tint: tint,
isInteractive: isInteractive
))
}
func termindGlassCapsule(
tint: Color? = nil,
isInteractive: Bool = false
) -> some View {
modifier(TermindGlassShape(
shape: Capsule(), tint: tint, isInteractive: isInteractive
))
modifier(
TermindGlassShape(
shape: Capsule(), tint: tint, isInteractive: isInteractive
))
}
func termindGlassCircle(
tint: Color? = nil,
isInteractive: Bool = false
) -> some View {
modifier(TermindGlassShape(
shape: Circle(), tint: tint, isInteractive: isInteractive
))
modifier(
TermindGlassShape(
shape: Circle(), tint: tint, isInteractive: isInteractive
))
}
func termindGlassBar() -> some View {
+3 -3
View File
@@ -149,8 +149,8 @@ struct TagField: View {
.onTapGesture {
commit(item.row)
}
}
.frame(maxHeight: listMaxHeight)
}
.frame(maxHeight: listMaxHeight)
}
private var tagListItems: [TagSuggestionListItem] {
@@ -245,7 +245,7 @@ struct TagField: View {
}
}
nonisolated fileprivate struct TagSuggestionListItem: Identifiable, Hashable, Sendable {
nonisolated private struct TagSuggestionListItem: Identifiable, Hashable, Sendable {
let row: TagField.Row
let isHighlighted: Bool
var id: TagField.Row { row }
@@ -13,9 +13,11 @@ nonisolated struct LinuxDistributionIconCatalog: Codable, Sendable {
var entries: [LinuxDistributionIconEntry]
private static let bundled: Self? = {
guard let url = Bundle.main.url(
forResource: "LinuxDistributionIcons", withExtension: "json"
), let data = try? Data(contentsOf: url) else { return nil }
guard
let url = Bundle.main.url(
forResource: "LinuxDistributionIcons", withExtension: "json"
), let data = try? Data(contentsOf: url)
else { return nil }
return try? JSONDecoder().decode(Self.self, from: data)
}()
@@ -12,7 +12,10 @@ nonisolated struct SerialPortDescriptor: Identifiable, Hashable, Sendable {
}
nonisolated enum SerialDataBits: Int, CaseIterable, Identifiable, Sendable {
case five = 5, six = 6, seven = 7, eight = 8
case five = 5
case six = 6
case seven = 7
case eight = 8
var id: Int { rawValue }
}
@@ -29,7 +32,8 @@ nonisolated enum SerialParity: String, CaseIterable, Identifiable, Sendable {
}
nonisolated enum SerialStopBits: Int, CaseIterable, Identifiable, Sendable {
case one = 1, two = 2
case one = 1
case two = 2
var id: Int { rawValue }
}
@@ -91,15 +95,18 @@ nonisolated struct SerialConnectionConfiguration: Hashable, Sendable {
"\(baudRate) \(dataBits.rawValue)-\(parityLetter)-\(stopBits.rawValue)"
}
private var parityLetter: String {
switch parity { case .none: "N"; case .even: "E"; case .odd: "O" }
switch parity {
case .none: "N"
case .even: "E"
case .odd: "O"
}
}
func transformInteractiveInput(_ data: Data) -> Data {
guard enterKey != .carriageReturn, data.contains(0x0D) else { return data }
var result = Data()
for byte in data {
if byte == 0x0D { result.append(contentsOf: enterKey.bytes) }
else { result.append(byte) }
if byte == 0x0D { result.append(contentsOf: enterKey.bytes) } else { result.append(byte) }
}
return result
}
@@ -20,9 +20,10 @@ nonisolated struct DetectedDistribution: Codable, Hashable, Sendable {
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let proposedID = try container.decode(String.self, forKey: .id)
let proposedLikeIDs = try container.decodeIfPresent(
[String].self, forKey: .likeIDs
) ?? []
let proposedLikeIDs =
try container.decodeIfPresent(
[String].self, forKey: .likeIDs
) ?? []
guard let value = Self(id: proposedID, likeIDs: proposedLikeIDs) else {
throw DecodingError.dataCorruptedError(
forKey: .id, in: container,
+6 -5
View File
@@ -61,11 +61,12 @@ nonisolated struct SessionContext: Hashable, Sendable {
chips.append(ContextChip(kind: .host, label: hostName, content: hostName))
}
if let workingDirectory, showsWorkingDirectoryChip {
chips.append(ContextChip(
kind: .directory,
label: workingDirectoryDisplayLabel ?? workingDirectory,
content: workingDirectory
))
chips.append(
ContextChip(
kind: .directory,
label: workingDirectoryDisplayLabel ?? workingDirectory,
content: workingDirectory
))
}
return chips
}
+100 -87
View File
@@ -179,10 +179,12 @@ nonisolated enum AgentToolPresentation {
// MCP output is remote-defined and intentionally keeps the established
// JSON presentation. Built-ins below never fall back to JSON syntax.
if card.descriptor.target == .mcp {
guard let output = detailOutput(
from: result, presentation: card.descriptor.presentation,
review: card.review
) else { return nil }
guard
let output = detailOutput(
from: result, presentation: card.descriptor.presentation,
review: card.review
)
else { return nil }
return AgentToolDetail(items: [
.text(.init(title: nil, text: output, style: .monospaced))
])
@@ -190,10 +192,12 @@ nonisolated enum AgentToolPresentation {
let raw = result.untrustedPayload ?? result.content
guard let value = try? AgentJSONValue.decode(Data(raw.utf8)) else {
guard let output = detailOutput(
from: result, presentation: card.descriptor.presentation,
review: card.review
) else { return nil }
guard
let output = detailOutput(
from: result, presentation: card.descriptor.presentation,
review: card.review
)
else { return nil }
let text = output.isEmpty ? String(localized: "Completed without output") : output
return AgentToolDetail(items: [
.message(text, result.isError ? .failure : .secondary)
@@ -213,11 +217,13 @@ nonisolated enum AgentToolPresentation {
items: AgentToolDetailFormatting.genericItems(value, locale: locale)
)
}
return AgentToolDetail(items: presenter.present(.init(
result: value,
arguments: card.invocation.call.arguments.objectValue ?? [:],
locale: locale
)))
return AgentToolDetail(
items: presenter.present(
.init(
result: value,
arguments: card.invocation.call.arguments.objectValue ?? [:],
locale: locale
)))
}
/// A bounded rendering projection. The full transformed output stays beside
@@ -351,9 +357,11 @@ nonisolated enum AgentToolPresentation {
for card: AgentToolCard, locale: Locale
) -> RenderedOperation {
guard let presentation = card.descriptor.presentation else {
if isChinese(locale), let recovered = recoveredBuiltInOperation(
for: card, locale: locale
) {
if isChinese(locale),
let recovered = recoveredBuiltInOperation(
for: card, locale: locale
)
{
return recovered
}
return RenderedOperation(
@@ -484,7 +492,8 @@ nonisolated enum AgentToolPresentation {
return RenderedOperation(text: oneLine, isArgumentValue: true)
}
}
let text = card.descriptor.name == "web_search"
let text =
card.descriptor.name == "web_search"
? "" : activityName(metadata.activity, locale: locale)
return RenderedOperation(text: text, isArgumentValue: false)
}
@@ -494,7 +503,8 @@ nonisolated enum AgentToolPresentation {
) -> String {
guard let first = cards.first else { return "" }
guard let presentation = first.descriptor.presentation else {
let activity = cards.count == 1
let activity =
cards.count == 1
? operation(for: first, locale: locale)
: RenderedOperation(
text: toolCallCount(cards.count, locale: locale), isArgumentValue: false
@@ -719,41 +729,43 @@ nonisolated enum AgentToolPresentation {
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)
}
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))
}
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)"
}
@@ -852,44 +864,45 @@ nonisolated enum AgentToolPresentation {
form: ActionForm, locale: Locale
) -> String {
if isChinese(locale) { return localizedActionVerb(kind, locale: locale) }
let forms: ActionForms = switch kind {
case .ask: .init(infinitive: "ask", progressive: "Asking", past: "Asked")
case .compare: .init(infinitive: "compare", progressive: "Comparing", past: "Compared")
case .copy: .init(infinitive: "copy", progressive: "Copying", past: "Copied")
case .create: .init(infinitive: "create", progressive: "Creating", past: "Created")
case .delete: .init(infinitive: "delete", progressive: "Deleting", past: "Deleted")
case .edit: .init(infinitive: "edit", progressive: "Editing", past: "Edited")
case .fetch: .init(infinitive: "fetch", progressive: "Fetching", past: "Fetched")
case .inspect: .init(infinitive: "inspect", progressive: "Inspecting", past: "Inspected")
case .list: .init(infinitive: "list", progressive: "Listing", past: "Listed")
case .move: .init(infinitive: "move", progressive: "Moving", past: "Moved")
case .present: .init(infinitive: "present", progressive: "Writing", past: "Presented")
case .pull: .init(infinitive: "pull", progressive: "Pulling", past: "Pulled")
case .push: .init(infinitive: "push", progressive: "Pushing", past: "Pushed")
case .read: .init(infinitive: "read", progressive: "Reading", past: "Read")
case .request: .init(infinitive: "request", progressive: "Requesting", past: "Requested")
case .run: .init(infinitive: "run", progressive: "Running", past: "Ran")
case .search: .init(infinitive: "search", progressive: "Searching", past: "Searched")
case .suggest: .init(infinitive: "suggest", progressive: "Suggesting", past: "Suggested")
case .update: .init(infinitive: "update", progressive: "Updating", past: "Updated")
case .wait: .init(infinitive: "wait for", progressive: "Waiting for", past: "Waited for")
case .write: .init(infinitive: "write", progressive: "Writing", past: "Wrote")
case .changePermissions:
.init(
infinitive: "change permissions for",
progressive: "Changing permissions for",
past: "Changed permissions for"
)
case .use: .init(infinitive: "use", progressive: "Using", past: "Used")
// Open vocabulary. The raw value is the verb an app chose, so using it
// uninflected reads better than a generic "act on".
default:
.init(
infinitive: kind.rawValue,
progressive: "\(kind.rawValue.capitalized)ing",
past: "\(kind.rawValue.capitalized)ed"
)
}
let forms: ActionForms =
switch kind {
case .ask: .init(infinitive: "ask", progressive: "Asking", past: "Asked")
case .compare: .init(infinitive: "compare", progressive: "Comparing", past: "Compared")
case .copy: .init(infinitive: "copy", progressive: "Copying", past: "Copied")
case .create: .init(infinitive: "create", progressive: "Creating", past: "Created")
case .delete: .init(infinitive: "delete", progressive: "Deleting", past: "Deleted")
case .edit: .init(infinitive: "edit", progressive: "Editing", past: "Edited")
case .fetch: .init(infinitive: "fetch", progressive: "Fetching", past: "Fetched")
case .inspect: .init(infinitive: "inspect", progressive: "Inspecting", past: "Inspected")
case .list: .init(infinitive: "list", progressive: "Listing", past: "Listed")
case .move: .init(infinitive: "move", progressive: "Moving", past: "Moved")
case .present: .init(infinitive: "present", progressive: "Writing", past: "Presented")
case .pull: .init(infinitive: "pull", progressive: "Pulling", past: "Pulled")
case .push: .init(infinitive: "push", progressive: "Pushing", past: "Pushed")
case .read: .init(infinitive: "read", progressive: "Reading", past: "Read")
case .request: .init(infinitive: "request", progressive: "Requesting", past: "Requested")
case .run: .init(infinitive: "run", progressive: "Running", past: "Ran")
case .search: .init(infinitive: "search", progressive: "Searching", past: "Searched")
case .suggest: .init(infinitive: "suggest", progressive: "Suggesting", past: "Suggested")
case .update: .init(infinitive: "update", progressive: "Updating", past: "Updated")
case .wait: .init(infinitive: "wait for", progressive: "Waiting for", past: "Waited for")
case .write: .init(infinitive: "write", progressive: "Writing", past: "Wrote")
case .changePermissions:
.init(
infinitive: "change permissions for",
progressive: "Changing permissions for",
past: "Changed permissions for"
)
case .use: .init(infinitive: "use", progressive: "Using", past: "Used")
// Open vocabulary. The raw value is the verb an app chose, so using it
// uninflected reads better than a generic "act on".
default:
.init(
infinitive: kind.rawValue,
progressive: "\(kind.rawValue.capitalized)ing",
past: "\(kind.rawValue.capitalized)ed"
)
}
return forms[form]
}
@@ -135,8 +135,9 @@ nonisolated enum ContextAttachmentImporter {
}
var pages: [String] = []
for index in 0..<document.pageCount {
guard let value = document.page(at: index)?.string?
.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty
guard
let value = document.page(at: index)?.string?
.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty
else { continue }
pages.append("--- Page \(index + 1) ---\n\(value)")
}
@@ -240,18 +241,24 @@ nonisolated enum ContextAttachmentImporter {
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: dimension,
]
guard let image = CGImageSourceCreateThumbnailAtIndex(
source, 0, options as CFDictionary
) else { throw ImportError.unreadableImage }
guard
let image = CGImageSourceCreateThumbnailAtIndex(
source, 0, options as CFDictionary
)
else { throw ImportError.unreadableImage }
let alpha = image.alphaInfo
let hasAlpha = alpha == .first || alpha == .last
let hasAlpha =
alpha == .first || alpha == .last
|| alpha == .premultipliedFirst || alpha == .premultipliedLast
let type = hasAlpha ? UTType.png : UTType.jpeg
let output = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(
output, type.identifier as CFString, 1, nil
) else { throw ImportError.unreadableImage }
let properties: [CFString: Any] = hasAlpha
guard
let destination = CGImageDestinationCreateWithData(
output, type.identifier as CFString, 1, nil
)
else { throw ImportError.unreadableImage }
let properties: [CFString: Any] =
hasAlpha
? [:] : [kCGImageDestinationLossyCompressionQuality: 0.84]
CGImageDestinationAddImage(destination, image, properties as CFDictionary)
guard CGImageDestinationFinalize(destination) else {
@@ -324,9 +331,15 @@ nonisolated private final class ParagraphTextParser: NSObject, XMLParserDelegate
return output.replacingOccurrences(of: #"\n{3,}"#, with: "\n\n", options: .regularExpression)
}
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?, attributes attributeDict: [String: String] = [:]) {
func parser(
_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?,
attributes attributeDict: [String: String] = [:]
) {
let name = elementName.split(separator: ":").last.map(String.init) ?? elementName
if name == "t" { capturesText = true; captured = "" }
if name == "t" {
capturesText = true
captured = ""
}
if name == "tab" { output += "\t" }
}
@@ -336,7 +349,10 @@ nonisolated private final class ParagraphTextParser: NSObject, XMLParserDelegate
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName: String?) {
let name = elementName.split(separator: ":").last.map(String.init) ?? elementName
if name == "t" { output += captured; capturesText = false }
if name == "t" {
output += captured
capturesText = false
}
if cellElements.contains(name) { output += "\t" }
if paragraphElements.contains(name) { output += "\n" }
}
@@ -356,10 +372,19 @@ nonisolated private final class SharedStringsParser: NSObject, XMLParserDelegate
return strings
}
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?, attributes attributeDict: [String: String] = [:]) {
func parser(
_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?,
attributes attributeDict: [String: String] = [:]
) {
let name = elementName.split(separator: ":").last.map(String.init) ?? elementName
if name == "si" { inItem = true; value = "" }
if name == "t", inItem { inText = true; text = "" }
if name == "si" {
inItem = true
value = ""
}
if name == "t", inItem {
inText = true
text = ""
}
}
func parser(_ parser: XMLParser, foundCharacters string: String) {
@@ -368,8 +393,14 @@ nonisolated private final class SharedStringsParser: NSObject, XMLParserDelegate
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName: String?) {
let name = elementName.split(separator: ":").last.map(String.init) ?? elementName
if name == "t", inText { value += text; inText = false }
if name == "si" { strings.append(value); inItem = false }
if name == "t", inText {
value += text
inText = false
}
if name == "si" {
strings.append(value)
inItem = false
}
}
}
@@ -391,7 +422,10 @@ nonisolated private final class WorksheetTextParser: NSObject, XMLParserDelegate
return rows.joined(separator: "\n")
}
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?, attributes attributeDict: [String: String] = [:]) {
func parser(
_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?,
attributes attributeDict: [String: String] = [:]
) {
let name = elementName.split(separator: ":").last.map(String.init) ?? elementName
if name == "row" { cells = [] }
if name == "c" {
@@ -400,7 +434,10 @@ nonisolated private final class WorksheetTextParser: NSObject, XMLParserDelegate
let column = Self.columnIndex(cellReference)
while cells.count < column { cells.append("") }
}
if name == "v" || name == "t" { captureElement = name; captured = "" }
if name == "v" || name == "t" {
captureElement = name
captured = ""
}
}
func parser(_ parser: XMLParser, foundCharacters string: String) {
+127 -92
View File
@@ -128,33 +128,40 @@ nonisolated struct ContextChip: Identifiable, Hashable, Sendable {
var cursor = suffix.startIndex
while let opening = suffix.range(of: prefix, range: cursor..<suffix.endIndex) {
let labelStart = opening.upperBound
guard let labelEnd = suffix.range(
of: "\">", range: labelStart..<suffix.endIndex
) else { break }
guard
let labelEnd = suffix.range(
of: "\">", range: labelStart..<suffix.endIndex
)
else { break }
let contentStart = labelEnd.upperBound
let closing = "</\(kind.blockName)>"
guard let contentEnd = suffix.range(
of: closing, range: contentStart..<suffix.endIndex
) else { break }
guard
let contentEnd = suffix.range(
of: closing, range: contentStart..<suffix.endIndex
)
else { break }
let label = String(suffix[labelStart..<labelEnd.lowerBound])
let storedIndex = unusedAttachments.firstIndex {
$0.label.replacingOccurrences(of: "\"", with: "'") == label
&& $0.kind == kind.agentAttachmentKind
}
let attachment: AgentContextAttachment? = storedIndex.map {
unusedAttachments.remove(at: $0)
} ?? kind.fallbackAttachment(
label: label,
content: String(suffix[contentStart..<contentEnd.lowerBound])
)
matches.append((
opening.lowerBound,
ContextChipReference(
id: attachment?.id ?? UUID(), kind: kind,
label: attachment?.label ?? label,
attachment: attachment
let attachment: AgentContextAttachment? =
storedIndex.map {
unusedAttachments.remove(at: $0)
}
?? kind.fallbackAttachment(
label: label,
content: String(suffix[contentStart..<contentEnd.lowerBound])
)
))
matches.append(
(
opening.lowerBound,
ContextChipReference(
id: attachment?.id ?? UUID(), kind: kind,
label: attachment?.label ?? label,
attachment: attachment
)
))
cursor = contentEnd.upperBound
}
}
@@ -185,8 +192,8 @@ nonisolated struct ContextChipReference: Identifiable, Hashable, Sendable {
var attachment: AgentContextAttachment? = nil
}
private extension ContextChip.Kind {
nonisolated var agentAttachmentKind: AgentContextAttachment.Kind? {
extension ContextChip.Kind {
fileprivate nonisolated var agentAttachmentKind: AgentContextAttachment.Kind? {
switch self {
case .file: .file
case .output: .output
@@ -198,7 +205,7 @@ private extension ContextChip.Kind {
}
}
nonisolated func fallbackAttachment(
fileprivate nonisolated func fallbackAttachment(
label: String, content: String
) -> AgentContextAttachment? {
guard let kind = agentAttachmentKind else { return nil }
@@ -212,7 +219,7 @@ private extension ContextChip.Kind {
)
}
nonisolated func fallbackFilename(for label: String) -> String {
fileprivate nonisolated func fallbackFilename(for label: String) -> String {
if self == .file {
let stem = (label as NSString).deletingPathExtension
return "\(stem.isEmpty ? label : stem) extracted.txt"
@@ -657,8 +664,7 @@ final class ConversationModel: Identifiable {
private var inputWaiters: [UUID: CheckedContinuation<AgentUserInputResponse, Never>] = [:]
private var userInputTimeoutTask: Task<Void, Never>?
private var sshAuthenticationWaiters: [UUID: CheckedContinuation<AgentSSHAuthenticationResponse, Never>] = [:]
private var sshHostKeyWaiters:
[UUID: CheckedContinuation<AgentSSHHostKeyResponse, Never>] = [:]
private var sshHostKeyWaiters: [UUID: CheckedContinuation<AgentSSHHostKeyResponse, Never>] = [:]
/// Whether the agent may only look.
///
@@ -897,7 +903,8 @@ final class ConversationModel: Identifiable {
else { return nil }
let original = transcript[target]
let visible = original.authoredText ?? ContextChip.visiblePrompt(in: original.text)
let suffix = original.text.hasPrefix(visible)
let suffix =
original.text.hasPrefix(visible)
? String(original.text.dropFirst(visible.count)) : ""
return AgentTranscriptMessage(
role: .user, text: authoredText + suffix, authoredText: authoredText,
@@ -920,14 +927,18 @@ final class ConversationModel: Identifiable {
contextEstimateTracker.upsert(replacement)
if let visibleIndex = messages.firstIndex(where: { $0.id == id }) {
messages.replaceSubrange(visibleIndex..., with: [ChatMessage(
id: replacement.id, role: .user,
text: replacement.authoredText ?? ContextChip.visiblePrompt(in: replacement.text),
contextChips: ContextChip.visibleReferences(
in: replacement.text, images: replacement.images,
contextAttachments: replacement.contextAttachments
), images: replacement.images, createdAt: replacement.createdAt
)])
messages.replaceSubrange(
visibleIndex...,
with: [
ChatMessage(
id: replacement.id, role: .user,
text: replacement.authoredText ?? ContextChip.visiblePrompt(in: replacement.text),
contextChips: ContextChip.visibleReferences(
in: replacement.text, images: replacement.images,
contextAttachments: replacement.contextAttachments
), images: replacement.images, createdAt: replacement.createdAt
)
])
}
toolCards.removeAll {
removedRunIDs.contains($0.invocation.runID)
@@ -1023,10 +1034,10 @@ final class ConversationModel: Identifiable {
let runIDMap = Dictionary(uniqueKeysWithValues: originalRunIDs.map { ($0, UUID()) })
let clonedMessageRunIDs: [UUID: UUID] = Dictionary(
uniqueKeysWithValues: source.compactMap { message -> (UUID, UUID)? in
guard let oldRunID = messageRunIDs[message.id],
let newRunID = runIDMap[oldRunID], let newMessageID = messageIDMap[message.id]
else { return nil }
return (newMessageID, newRunID)
guard let oldRunID = messageRunIDs[message.id],
let newRunID = runIDMap[oldRunID], let newMessageID = messageIDMap[message.id]
else { return nil }
return (newMessageID, newRunID)
}
)
let clonedRuns = originalRunIDs.compactMap { oldRunID -> AgentRunSummary? in
@@ -1084,9 +1095,10 @@ final class ConversationModel: Identifiable {
grouping: toolCards.filter { $0.terminalSuggestion == nil },
by: { $0.invocation.runID }
)
let toolProcessMessageIDs = Set(transcript.compactMap { message -> UUID? in
message.role == .assistant && !message.toolCalls.isEmpty ? message.id : nil
})
let toolProcessMessageIDs = Set(
transcript.compactMap { message -> UUID? in
message.role == .assistant && !message.toolCalls.isEmpty ? message.id : nil
})
var processRunIDs: [UUID: UUID] = [:]
for message in transcript where message.role == .assistant && !message.toolCalls.isEmpty {
if let runID = messageRunIDs[message.id] {
@@ -1128,21 +1140,25 @@ final class ConversationModel: Identifiable {
guard owner == runID else { return nil }
return visibleByID[messageID]
}
let workHasNonReasoningOutput = !cards.isEmpty
let workHasNonReasoningOutput =
!cards.isEmpty
|| processMessages.contains { !$0.text.isEmpty }
let hasVisibleReasoning = processMessages.contains { message in
message.id == activeReasoningMessageID && !message.reasoning.isEmpty
}
let visibleProcess = processMessages.filter { message in
let livePlaceholder = message.isStreaming && message.text.isEmpty
let livePlaceholder =
message.isStreaming && message.text.isEmpty
&& message.reasoning.isEmpty
let toolProcess = toolProcessMessageIDs.contains(message.id)
let toolProcess =
toolProcessMessageIDs.contains(message.id)
&& (!message.text.isEmpty
|| (message.isStreaming && message.reasoning.isEmpty))
// Working belongs to the Work as a whole. Reasoning does not
// consume this slot: a later empty turn may still be replaced
// by its next reasoning block. Any non-reasoning output does.
return (livePlaceholder
return
(livePlaceholder
&& !hasVisibleReasoning
&& !workHasNonReasoningOutput)
|| toolProcess
@@ -1153,7 +1169,8 @@ final class ConversationModel: Identifiable {
let hasUnfinishedGroupableTool = groupableCards.contains {
!$0.state.isTerminal
}
let latestToolReasoning = processMessages
let latestToolReasoning =
processMessages
.sorted { $0.createdAt < $1.createdAt }
.reversed()
.compactMap { message -> AgentToolReasoning? in
@@ -1169,7 +1186,8 @@ final class ConversationModel: Identifiable {
// Once there is a tool surface to carry it, reasoning becomes a
// transient title instead of a separate structural row. Before the
// atomic batch is published it remains visible on its own.
let reasoningItems = !hasUnfinishedGroupableTool
let reasoningItems =
!hasUnfinishedGroupableTool
? processMessages.flatMap { message -> [AgentWorkItem] in
guard message.id == activeReasoningMessageID else { return [] }
return message.reasoning.map(AgentWorkItem.reasoning)
@@ -1178,7 +1196,8 @@ final class ConversationModel: Identifiable {
let activeReasoningBlockID = processMessages.first(where: {
$0.id == activeReasoningMessageID && $0.isStreaming && $0.text.isEmpty
})?.reasoning.last?.id
var items = reasoningItems + visibleProcess.map(AgentWorkItem.message)
var items =
reasoningItems + visibleProcess.map(AgentWorkItem.message)
+ cards.map(AgentWorkItem.tool)
items.sort {
if $0.orderedAt != $1.orderedAt { return $0.orderedAt < $1.orderedAt }
@@ -1203,7 +1222,8 @@ final class ConversationModel: Identifiable {
// The work row follows the clock of the first thing it actually
// presents. A stopped direct answer has no work item, so its stop
// event is the row's place in the timeline.
let orderedAt = items.first?.orderedAt
let orderedAt =
items.first?.orderedAt
?? stored?.finishedAt
?? stored?.startedAt
?? .now
@@ -1215,14 +1235,17 @@ final class ConversationModel: Identifiable {
&& processRunIDs[$0.id] == nil
&& $0.createdAt < orderedAt
})?.createdAt
let fallbackState: AgentRunState = cards.allSatisfy {
$0.state == .completed
} ? .completed : .interrupted
var run = stored ?? AgentRunSummary(
id: runID, state: fallbackState,
startedAt: items.first?.orderedAt ?? orderedAt,
finishedAt: items.last?.orderedAt
)
let fallbackState: AgentRunState =
cards.allSatisfy {
$0.state == .completed
} ? .completed : .interrupted
var run =
stored
?? AgentRunSummary(
id: runID, state: fallbackState,
startedAt: items.first?.orderedAt ?? orderedAt,
finishedAt: items.last?.orderedAt
)
if activeRunID == runID { run.state = runState }
return AgentWorkGroup(
run: run, items: items, orderedAt: orderedAt,
@@ -1256,11 +1279,13 @@ final class ConversationModel: Identifiable {
if card.descriptor.presentation?.groupsWithAdjacentTools == false {
grouped.append(.tool(card))
} else if !insertedBatch {
grouped.append(.tools(AgentToolBatch(
cards: batched,
runID: runID,
reasoning: reasoning
)))
grouped.append(
.tools(
AgentToolBatch(
cards: batched,
runID: runID,
reasoning: reasoning
)))
insertedBatch = true
}
} else {
@@ -1274,12 +1299,13 @@ final class ConversationModel: Identifiable {
var timeline: [TimelineItem] {
var items: [TimelineItem] = []
let groups = workGroups
let groupedProcessIDs: Set<UUID> = Set(groups.flatMap { group -> [UUID] in
group.items.compactMap { item -> UUID? in
guard case .message(let message) = item else { return nil }
return message.id
}
})
let groupedProcessIDs: Set<UUID> = Set(
groups.flatMap { group -> [UUID] in
group.items.compactMap { item -> UUID? in
guard case .message(let message) = item else { return nil }
return message.id
}
})
var emittedGroups = Set<UUID>()
for message in messages {
for group in groups where group.anchorMessageID == message.id {
@@ -1291,15 +1317,17 @@ final class ConversationModel: Identifiable {
// bubble reads as the model having said nothing. While it is still
// streaming it stays only until this turn receives reasoning; that
// live text then owns the activity treatment until turnFinished.
guard message.role != .assistant || !message.text.isEmpty
|| (message.isStreaming && message.reasoning.isEmpty)
guard
message.role != .assistant || !message.text.isEmpty
|| (message.isStreaming && message.reasoning.isEmpty)
else { continue }
items.append(.message(message))
}
items += groups.filter { !emittedGroups.contains($0.id) }.map(TimelineItem.work)
let commands = toolCards.compactMap(\.terminalSuggestion)
guard !commands.isEmpty || !plans.isEmpty || !compactions.isEmpty
|| compactionActivity != nil
guard
!commands.isEmpty || !plans.isEmpty || !compactions.isEmpty
|| compactionActivity != nil
else {
return items
}
@@ -1307,7 +1335,8 @@ final class ConversationModel: Identifiable {
// run and a compaction happens between runs, so there are never many of
// either, and two more cursors inside that loop would be two more things
// to keep right for no gain.
var extra = commands.map(TimelineItem.command)
var extra =
commands.map(TimelineItem.command)
+ plans.map(TimelineItem.plan)
+ compactions.map(TimelineItem.compaction)
if let compactionActivity { extra.append(.compactionActivity(compactionActivity)) }
@@ -1364,7 +1393,8 @@ final class ConversationModel: Identifiable {
inCharacters: AgentContextEstimator.characterUnits(in: $1.text)
)
}
let boundary = summarized.reversed().first(where: { !$0.isCompaction })?.id
let boundary =
summarized.reversed().first(where: { !$0.isCompaction })?.id
?? compactionRecords.last?.compactedThroughMessageID
return AgentCompactionRecord(
compactedThroughMessageID: boundary,
@@ -1436,12 +1466,13 @@ final class ConversationModel: Identifiable {
}
func attach(_ chip: ContextChip) {
guard !composer.attachments.contains(where: {
if let image = chip.image, let existing = $0.image {
return existing.data == image.data
}
return $0.kind == chip.kind && $0.content == chip.content
})
guard
!composer.attachments.contains(where: {
if let image = chip.image, let existing = $0.image {
return existing.data == image.data
}
return $0.kind == chip.kind && $0.content == chip.content
})
else { return }
composer.attachments.append(chip)
refreshAttachedContextEstimate()
@@ -1453,7 +1484,8 @@ final class ConversationModel: Identifiable {
}
func beginAttachmentImport(label: String) -> PendingContextAttachment? {
guard composer.attachments.count + composer.pendingAttachments.count
guard
composer.attachments.count + composer.pendingAttachments.count
< ContextAttachmentImporter.maximumAttachments
else {
composer.attachmentImportError = String(
@@ -2199,15 +2231,17 @@ final class ConversationModel: Identifiable {
if let activeRunID { messageRunIDs[message.id] = activeRunID }
let pending = pendingSteering.first(where: { $0.id == message.id })
let visibleText = pending?.draft ?? ContextChip.visiblePrompt(in: message.text)
let contextChips = pending?.attachments.map {
ContextChipReference(
id: $0.id, kind: $0.kind, label: $0.label,
attachment: $0.contextAttachment
let contextChips =
pending?.attachments.map {
ContextChipReference(
id: $0.id, kind: $0.kind, label: $0.label,
attachment: $0.contextAttachment
)
}
?? ContextChip.visibleReferences(
in: message.text, images: message.images,
contextAttachments: message.contextAttachments
)
} ?? ContextChip.visibleReferences(
in: message.text, images: message.images,
contextAttachments: message.contextAttachments
)
pendingSteering.removeAll { $0.id == message.id }
messages.append(
ChatMessage(
@@ -2360,9 +2394,10 @@ final class ConversationModel: Identifiable {
guard let source = transcript.first(where: { $0.id == sourceMessageID }),
!source.toolCalls.isEmpty
else { return }
let stagedCallIDs = Set(stagedIDs.compactMap {
stagedToolCards[$0]?.invocation.call.id
})
let stagedCallIDs = Set(
stagedIDs.compactMap {
stagedToolCards[$0]?.invocation.call.id
})
guard source.toolCalls.allSatisfy({ stagedCallIDs.contains($0.id) }) else {
return
}
@@ -35,12 +35,13 @@ private struct ConversationAttachmentDropModifier: ViewModifier {
@ViewBuilder
private var decoration: some View {
if targetedKind != nil {
DropTargetOverlay(presentation: DropTargetPresentation(
title: String(localized: "Attach to Chat"),
detail: String(localized: "Files and images"),
systemImage: targetedKind == .images
? "photo.badge.plus" : "paperclip"
))
DropTargetOverlay(
presentation: DropTargetPresentation(
title: String(localized: "Attach to Chat"),
detail: String(localized: "Files and images"),
systemImage: targetedKind == .images
? "photo.badge.plus" : "paperclip"
))
}
}
+29 -22
View File
@@ -146,10 +146,11 @@ private struct ChipView: View {
styledContent(removable: false)
}
}
.help(Text(
remove == nil
? String(localized: "Always included") : String(localized: "Remove attachment")
))
.help(
Text(
remove == nil
? String(localized: "Always included") : String(localized: "Remove attachment")
))
}
private func styledContent(removable: Bool) -> some View {
@@ -158,9 +159,9 @@ private struct ChipView: View {
systemName: removable && (isHovering || isFocused)
? "xmark" : chip.kind.symbol
)
.font(.caption2)
.frame(width: 12, height: 12)
.contentTransition(.symbolEffect(.replace))
.font(.caption2)
.frame(width: 12, height: 12)
.contentTransition(.symbolEffect(.replace))
Text(chip.label).font(.caption).lineLimit(1)
}
.padding(.horizontal, Metrics.Space.s)
@@ -214,9 +215,10 @@ struct FlowLayout: Layout {
let width = proposal.width ?? naturalWidth
let rows = arrange(subviews: subviews, in: width)
let height = rows.last.map { $0.y + $0.height } ?? 0
let usedWidth = rows.map { row in
row.items.last.map { $0.x + $0.size.width } ?? 0
}.max() ?? 0
let usedWidth =
rows.map { row in
row.items.last.map { $0.x + $0.size.width } ?? 0
}.max() ?? 0
return CGSize(width: fillsWidth ? width : usedWidth, height: height)
}
@@ -524,7 +526,7 @@ struct ComposerView: View {
sessionContext: sessionContext, conversation: conversation,
cancelPending: cancelAttachmentImport
)
.transition(.opacity.combined(with: .move(edge: .bottom)))
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
draftEditor($composer.draft)
@@ -1075,9 +1077,11 @@ struct ComposerView: View {
}
}
private var slashModelChoices: [(
argument: String, provider: ModelProvider, model: AIModel
)] {
private var slashModelChoices:
[(
argument: String, provider: ModelProvider, model: AIModel
)]
{
modelProviders.flatMap { provider in
provider.assistantModels.map { model in
(
@@ -1376,9 +1380,10 @@ struct ComposerView: View {
private func handleContextPaste() -> Bool {
let pasteboard = NSPasteboard.general
let urls = pasteboard.readObjects(
forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]
) as? [URL] ?? []
let urls =
pasteboard.readObjects(
forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]
) as? [URL] ?? []
if !urls.isEmpty {
conversation.importAttachmentFiles(urls)
return true
@@ -1392,8 +1397,9 @@ struct ComposerView: View {
guard let text = pasteboard.string(forType: .string),
ContextAttachmentImporter.shouldAttachPastedText(text)
else { return false }
do { conversation.attach(try ContextAttachmentImporter.pastedText(text)) }
catch { conversation.composer.attachmentImportError = error.localizedDescription }
do { conversation.attach(try ContextAttachmentImporter.pastedText(text)) } catch {
conversation.composer.attachmentImportError = error.localizedDescription
}
return true
}
@@ -1717,9 +1723,10 @@ struct ComposerView: View {
private var modelOptionItems: [AssistantModelOptionListItem] {
guard !modelProviders.isEmpty else { return [.empty] }
return modelProviders.flatMap { provider in
[.provider(provider)] + provider.assistantModels.map { model in
.model(provider: provider, model: model, selected: isSelected(model, on: provider))
}
[.provider(provider)]
+ provider.assistantModels.map { model in
.model(provider: provider, model: model, selected: isSelected(model, on: provider))
}
}
}
+47 -35
View File
@@ -1848,7 +1848,8 @@ private final class NativeFlowView: NSView {
let available = max(1, bounds.width)
var y: CGFloat = 0
for row in rows(for: available) {
let rowWidth = row.reduce(CGFloat.zero) { $0 + $1.size.width }
let rowWidth =
row.reduce(CGFloat.zero) { $0 + $1.size.width }
+ spacing * CGFloat(max(0, row.count - 1))
var x: CGFloat =
horizontalAlignment == .trailing ? max(0, available - rowWidth) : 0
@@ -1873,9 +1874,10 @@ private final class NativeFlowView: NSView {
override var intrinsicContentSize: NSSize {
let available = max(1, bounds.width > 0 ? bounds.width : superview?.bounds.width ?? 1)
let rows = rows(for: available)
let height = rows.reduce(CGFloat.zero) { total, row in
total + (row.map(\.size.height).max() ?? 0)
}
let height =
rows.reduce(CGFloat.zero) { total, row in
total + (row.map(\.size.height).max() ?? 0)
}
+ spacing * CGFloat(max(0, rows.count - 1))
return NSSize(width: NSView.noIntrinsicMetric, height: height)
}
@@ -1956,7 +1958,8 @@ private final class NativeDisclosureHeader: NSControl {
"Transcript.ScanningTitle"
)
}
let showsTransientTitle = transientMarkdownTitle != nil
let showsTransientTitle =
transientMarkdownTitle != nil
&& transientUntil.map { $0 > .now } == true
if showsTransientTitle, let transientMarkdownTitle {
titleLabel.attributedStringValue = TermindMarkdownParser.singleLineAttributedText(
@@ -2008,7 +2011,8 @@ private final class NativeDisclosureHeader: NSControl {
row.topAnchor.constraint(equalTo: topAnchor),
row.bottomAnchor.constraint(equalTo: bottomAnchor),
])
let accessibleTitle = showsTransientTitle
let accessibleTitle =
showsTransientTitle
? transientMarkdownTitle.map(TermindMarkdownParser.plainText) ?? title
: title
setAccessibilityLabel(accessibleTitle)
@@ -2167,10 +2171,11 @@ private final class NativeChipButton: NativeButton {
override func updateLayer() {
super.updateLayer()
layer?.backgroundColor = nativeHierarchicalColor(
.quaternaryLabelColor,
opacity: 0.5
).cgColor
layer?.backgroundColor =
nativeHierarchicalColor(
.quaternaryLabelColor,
opacity: 0.5
).cgColor
}
override func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
@@ -2236,10 +2241,11 @@ private final class NativeRiskBadge: NSView {
override func updateLayer() {
super.updateLayer()
layer?.backgroundColor = TermindColor.resolvedNative(
Self.color(assessment.risk).withAlphaComponent(0.12),
for: effectiveAppearance
).cgColor
layer?.backgroundColor =
TermindColor.resolvedNative(
Self.color(assessment.risk).withAlphaComponent(0.12),
for: effectiveAppearance
).cgColor
}
override func viewDidChangeEffectiveAppearance() {
@@ -2282,10 +2288,11 @@ private final class NativeBadge: NSView {
override func updateLayer() {
super.updateLayer()
layer?.backgroundColor = TermindColor.resolvedNative(
NSColor.quaternaryLabelColor.withAlphaComponent(0.5),
for: effectiveAppearance
).cgColor
layer?.backgroundColor =
TermindColor.resolvedNative(
NSColor.quaternaryLabelColor.withAlphaComponent(0.5),
for: effectiveAppearance
).cgColor
}
override func viewDidChangeEffectiveAppearance() {
@@ -2380,10 +2387,11 @@ private final class NativeVerticalRule: NSView {
override func updateLayer() {
super.updateLayer()
layer?.backgroundColor = TermindColor.resolvedNative(
.quaternaryLabelColor,
for: effectiveAppearance
).cgColor
layer?.backgroundColor =
TermindColor.resolvedNative(
.quaternaryLabelColor,
for: effectiveAppearance
).cgColor
}
override func viewDidChangeEffectiveAppearance() {
@@ -2601,11 +2609,13 @@ private nonisolated final class NativeScanningMaskLayer: CALayer {
let bandWidth = min(max(bounds.width * 0.32, 40), 96)
let origin = -bandWidth + (bounds.width + bandWidth) * progress
let alphas: [CGFloat] = [0, 0.34, 0.82, 1, 0.82, 0.34, 0]
guard let gradient = CGGradient(
colorsSpace: CGColorSpaceCreateDeviceRGB(),
colors: alphas.map { CGColor(gray: 1, alpha: $0) } as CFArray,
locations: [0, 1 / 6, 2 / 6, 3 / 6, 4 / 6, 5 / 6, 1]
) else { return }
guard
let gradient = CGGradient(
colorsSpace: CGColorSpaceCreateDeviceRGB(),
colors: alphas.map { CGColor(gray: 1, alpha: $0) } as CFArray,
locations: [0, 1 / 6, 2 / 6, 3 / 6, 4 / 6, 5 / 6, 1]
)
else { return }
context.saveGState()
context.setBlendMode(.destinationOut)
@@ -2760,16 +2770,18 @@ private final class NativeCard: NSView {
required init?(coder: NSCoder) { fatalError() }
override func updateLayer() {
super.updateLayer()
layer?.backgroundColor = TermindColor.resolvedNative(
fillProvider(),
for: effectiveAppearance
).cgColor
if let strokeProvider {
layer?.borderWidth = 1
layer?.borderColor = TermindColor.resolvedNative(
strokeProvider(),
layer?.backgroundColor =
TermindColor.resolvedNative(
fillProvider(),
for: effectiveAppearance
).cgColor
if let strokeProvider {
layer?.borderWidth = 1
layer?.borderColor =
TermindColor.resolvedNative(
strokeProvider(),
for: effectiveAppearance
).cgColor
} else {
layer?.borderWidth = 0
layer?.borderColor = nil
+68 -58
View File
@@ -130,7 +130,8 @@ struct AssistantPanelView: View {
private var workspaceAssistantSurface: some View {
let exitsAsWhole = window.sidebarAssistantConversation != nil
let dropConversation = window.sidebarAssistantConversation
let dropConversation =
window.sidebarAssistantConversation
?? window.sidebar.assistantDraft
return VStack(spacing: 0) {
@@ -284,32 +285,32 @@ private struct AssistantConversationList: View {
var body: some View {
content
.onChange(of: visibleConversationIDs) {
guard let renamingID, !visibleConversationIDs.contains(renamingID) else { return }
cancelRename()
}
.onChange(of: selectedID) {
guard case .active = scope,
let id = selectedID,
let conversation = window.assistantLibrary.conversation(id: id)
else { return }
setExpanded(true, for: conversation.isPinned ? .pinned : .chats)
}
.alert(
"Delete Conversation?",
isPresented: Binding(
get: { deleteTarget != nil },
set: { if !$0 { deleteTarget = nil } }
), presenting: deleteTarget
) { conversation in
Button("Cancel", role: .cancel) { deleteTarget = nil }
Button("Delete", role: .destructive) {
window.deleteAssistantConversation(conversation.id)
deleteTarget = nil
.onChange(of: visibleConversationIDs) {
guard let renamingID, !visibleConversationIDs.contains(renamingID) else { return }
cancelRename()
}
.onChange(of: selectedID) {
guard case .active = scope,
let id = selectedID,
let conversation = window.assistantLibrary.conversation(id: id)
else { return }
setExpanded(true, for: conversation.isPinned ? .pinned : .chats)
}
.alert(
"Delete Conversation?",
isPresented: Binding(
get: { deleteTarget != nil },
set: { if !$0 { deleteTarget = nil } }
), presenting: deleteTarget
) { conversation in
Button("Cancel", role: .cancel) { deleteTarget = nil }
Button("Delete", role: .destructive) {
window.deleteAssistantConversation(conversation.id)
deleteTarget = nil
}
} message: { _ in
Text("This permanently removes the conversation and its scratch files.")
}
} message: { _ in
Text("This permanently removes the conversation and its scratch files.")
}
}
private var visibleConversationIDs: Set<ConversationModel.ID> {
@@ -357,13 +358,15 @@ private struct AssistantConversationList: View {
// that asked for it tears down the Button mid-dispatch.
animatesStructuralChanges: false,
draggedID: { isSearching ? nil : Self.draggedConversationID(in: window) },
onMove: isSearching ? nil : { id, target in
setExpanded(true, for: target.section)
window.moveAssistantConversation(
id, toPinned: target.section == .pinned, before: target.beforeID
)
window.dragging = nil
},
onMove: isSearching
? nil
: { id, target in
setExpanded(true, for: target.section)
window.moveAssistantConversation(
id, toPinned: target.section == .pinned, before: target.beforeID
)
window.dragging = nil
},
blankAreaMenu: { Self.blankAreaMenu(in: window, locale: locale) },
isInteractive: isInteractive
) { item in
@@ -652,7 +655,8 @@ nonisolated enum AssistantConversationDropResolver {
) -> AssistantConversationDropTarget? {
guard items.count == rowFrames.count, !items.isEmpty else { return nil }
let index = rowFrames.firstIndex { $0.contains(CGPoint(x: $0.midX, y: pointerY)) }
let index =
rowFrames.firstIndex { $0.contains(CGPoint(x: $0.midX, y: pointerY)) }
?? rowFrames.firstIndex { pointerY < $0.midY }
?? (items.count - 1)
@@ -922,10 +926,11 @@ final class AssistantConversationListContainer: NSView {
}
private func updateAppearanceLayers() {
insertionLine.layer?.backgroundColor = TermindColor.resolvedNative(
TermindColor.nativeAccent,
for: effectiveAppearance
).cgColor
insertionLine.layer?.backgroundColor =
TermindColor.resolvedNative(
TermindColor.nativeAccent,
for: effectiveAppearance
).cgColor
}
/// A row's own menu is installed by SwiftUI on its hosting view, which
@@ -976,9 +981,11 @@ final class AssistantConversationListContainer: NSView {
let point = listView.convert(sender.draggingLocation, from: nil)
let items = listView.content
let frames = items.indices.map { listView.rectForRow(at: $0) }
guard let target = AssistantConversationDropResolver.target(
items: items, rowFrames: frames, pointerY: point.y
) else {
guard
let target = AssistantConversationDropResolver.target(
items: items, rowFrames: frames, pointerY: point.y
)
else {
clearTarget()
return false
}
@@ -1172,8 +1179,8 @@ private struct CompactAssistantHeader: ViewModifier {
}
}
private extension View {
func compactAssistantHeader() -> some View {
extension View {
fileprivate func compactAssistantHeader() -> some View {
modifier(CompactAssistantHeader())
}
}
@@ -1282,11 +1289,12 @@ struct AssistantConversationRow: View {
private var rowContent: some View {
HStack(spacing: Metrics.Space.s) {
Image(systemName: conversation.isArchived ? "archivebox" : "sparkles")
.foregroundStyle(SidebarSelectionStyle.foreground(
isSelected: isSelected,
controlActiveState: controlActiveState,
unselected: AnyShapeStyle(.secondary)
))
.foregroundStyle(
SidebarSelectionStyle.foreground(
isSelected: isSelected,
controlActiveState: controlActiveState,
unselected: AnyShapeStyle(.secondary)
))
if isRenaming {
SidebarInlineRenameField(
text: $renameTitle,
@@ -1312,17 +1320,19 @@ struct AssistantConversationRow: View {
// The row's own foreground, so on a selected card in a
// key window this reads white with the title beside it
// rather than as an accent-coloured glyph on accent.
.foregroundStyle(SidebarSelectionStyle.foreground(
isSelected: isSelected,
controlActiveState: controlActiveState,
unselected: AnyShapeStyle(.secondary)
))
.foregroundStyle(
SidebarSelectionStyle.foreground(
isSelected: isSelected,
controlActiveState: controlActiveState,
unselected: AnyShapeStyle(.secondary)
))
}
.buttonStyle(.plain)
.help(Text(
conversation.isArchived
? localized("Unarchive") : localized("Archive")
))
.help(
Text(
conversation.isArchived
? localized("Unarchive") : localized("Archive")
))
}
}
}
@@ -1354,7 +1364,7 @@ struct AssistantView: View {
conversation: conversation,
onNewChat: { window.openNewAssistantChat() }
)
.background(TermindColor.windowSurface)
.background(TermindColor.windowSurface)
} else {
ConversationView(
conversation: window.assistantLibrary.draftConversation,
@@ -59,12 +59,9 @@ final class ExternalAuthCoordinator {
private(set) var pending: [Pending] = []
private(set) var presentationRevision: UInt64 = 0
private var sshWaiters:
[UUID: CheckedContinuation<AgentSSHAuthenticationResponse, Never>] = [:]
private var secretWaiters:
[UUID: CheckedContinuation<AgentUserInputResponse, Never>] = [:]
private var hostKeyWaiters:
[UUID: CheckedContinuation<AgentSSHHostKeyResponse, Never>] = [:]
private var sshWaiters: [UUID: CheckedContinuation<AgentSSHAuthenticationResponse, Never>] = [:]
private var secretWaiters: [UUID: CheckedContinuation<AgentUserInputResponse, Never>] = [:]
private var hostKeyWaiters: [UUID: CheckedContinuation<AgentSSHHostKeyResponse, Never>] = [:]
private var secretTimeoutTask: Task<Void, Never>?
var current: Pending? { pending.first }
@@ -162,9 +159,10 @@ final class ExternalAuthCoordinator {
private func append(_ request: Request, clientName: String, runID: UUID) {
let wasEmpty = pending.isEmpty
pending.append(Pending(
runID: runID, clientName: String(clientName.prefix(160)), request: request
))
pending.append(
Pending(
runID: runID, clientName: String(clientName.prefix(160)), request: request
))
if wasEmpty { presentCurrent() }
}
@@ -364,9 +364,10 @@ struct HostChooserView: View {
}
.formStyle(.grouped)
.onChange(of: serialConfiguration.devicePath) {
serialConfiguration.deviceName = serialPorts.first {
$0.path == serialConfiguration.devicePath
}?.name
serialConfiguration.deviceName =
serialPorts.first {
$0.path == serialConfiguration.devicePath
}?.name
serialError = nil
}
.onChange(of: serialBaudRate) {
@@ -396,9 +397,9 @@ struct HostChooserView: View {
"\(chooser.selected.count) selected", locale: locale
)
)
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
Button(selectAllLabel) {
toggleAllResults()
@@ -623,26 +624,26 @@ struct HostChooserRow: View {
isSelected: isChecked ?? isSelected
) {
HStack(spacing: Metrics.Space.m) {
// Two channels on purpose, and they mean different things: the box
// says what you have chosen, the row's background says where the
// arrow keys are. Conflating them would make the cursor look like a
// pick you had not made.
if let isChecked {
SelectionMark(mode: .multiple, isSelected: isChecked)
}
// Two channels on purpose, and they mean different things: the box
// says what you have chosen, the row's background says where the
// arrow keys are. Conflating them would make the cursor look like a
// pick you had not made.
if let isChecked {
SelectionMark(mode: .multiple, isSelected: isChecked)
}
StatusDot(status: app.hostLibrary.status(of: host.id))
StatusDot(status: app.hostLibrary.status(of: host.id))
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
Text(host.name)
.lineLimit(1)
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
Text(host.name)
.lineLimit(1)
Text(app.hostLibrary.loginAddress(for: host))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Text(app.hostLibrary.loginAddress(for: host))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
} trailing: {
+2 -1
View File
@@ -171,7 +171,8 @@ enum HostMenuEntries {
NativeMenuEntry(title: String(localized: "Edit")) {
window.openHostEditor(host: host)
},
NativeMenuEntry(title: String(localized: "Copy SSH Command"),
NativeMenuEntry(
title: String(localized: "Copy SSH Command"),
isEnabled: app.hostLibrary.sshCommand(for: host) != nil
) {
guard let command = app.hostLibrary.sshCommand(for: host) else { return }
+3 -2
View File
@@ -315,7 +315,8 @@ struct HostsPageView: ManagedCardPage {
if nested == 0 {
return String(localized: "This group contains \(hostCount).")
}
let groupCount = nested == 1
let groupCount =
nested == 1
? String(localized: "1 nested group")
: String(localized: "\(nested) nested groups")
return String(localized: "This group contains \(hostCount) and \(groupCount). Nested groups will be deleted.")
@@ -436,7 +437,7 @@ struct HostsPagePresentation: ManagedCardPresentationModifier {
private var confirmationTitle: Text {
switch state.confirmation {
case .deleteGroup(let group): Text("Delete “\(group.name)”?" )
case .deleteGroup(let group): Text("Delete “\(group.name)”?")
case nil: Text("")
}
}
+1 -3
View File
@@ -102,9 +102,7 @@ struct NewGroupSheet: View {
}
private func close() {
if let requestedBack { requestedBack() }
else if let requestedClose { requestedClose() }
else { dismiss() }
if let requestedBack { requestedBack() } else if let requestedClose { requestedClose() } else { dismiss() }
}
private func load() {
@@ -220,7 +220,8 @@ struct KeychainPageView: ManagedCardPage {
private func delete(keys: [SSHKeyRef], identities: [Identity]) {
for identity in identities {
if !app.credentials.delete(identityID: identity.id) {
state.errorMessage = app.credentials.storageError
state.errorMessage =
app.credentials.storageError
?? String(localized: "The identity could not be deleted.")
}
}
@@ -429,9 +430,10 @@ private struct SoftwareKeyEditor: View {
existing: SSHKeyRef? = nil, title: String,
symbol: String, close: @escaping () -> Void,
loadPrivateKey: (() throws -> String)? = nil,
save: @escaping (
String, TagColor, KeyStorage, String, String?, String, SSHKeyMaterial
) async throws -> SSHKeyRef
save:
@escaping (
String, TagColor, KeyStorage, String, String?, String, SSHKeyMaterial
) async throws -> SSHKeyRef
) {
self.existing = existing
self.title = title
@@ -943,7 +945,8 @@ private struct GenerateKeySheet: View {
}
.keyboardShortcut(.defaultAction)
.disabled(
isWorking || (storage != .secureEnclave && (!passwordsMatch || (!passphrase.isEmpty && rounds <= 0)))
isWorking
|| (storage != .secureEnclave && (!passwordsMatch || (!passphrase.isEmpty && rounds <= 0)))
|| (storage == .secureEnclave && !SecureEnclaveKey.isAvailable)
)
}
@@ -240,9 +240,7 @@ struct NewIdentitySheet: View {
}
private func close() {
if let requestedBack { requestedBack() }
else if let requestedClose { requestedClose() }
else { dismiss() }
if let requestedBack { requestedBack() } else if let requestedClose { requestedClose() } else { dismiss() }
}
private var isValid: Bool {
@@ -318,10 +318,11 @@ nonisolated struct QuickOpenResults: Equatable, Sendable {
func listItems(selectedID: AppCommandDescriptor.ID?) -> [QuickOpenListItem] {
sections.flatMap { section in
[.section(section)] + commands.compactMap { command in
guard command.section == section else { return nil }
return .command(command, isSelected: command.id == selectedID)
}
[.section(section)]
+ commands.compactMap { command in
guard command.section == section else { return nil }
return .command(command, isSelected: command.id == selectedID)
}
}
}
@@ -330,7 +331,8 @@ nonisolated struct QuickOpenResults: Equatable, Sendable {
by offset: Int
) -> AppCommandDescriptor.ID? {
guard !commands.isEmpty else { return nil }
let current = selectedID.flatMap { id in commands.firstIndex { $0.id == id } }
let current =
selectedID.flatMap { id in commands.firstIndex { $0.id == id } }
?? (offset > 0 ? -1 : commands.count)
let next = min(max(current + offset, 0), commands.count - 1)
return commands[next].id
+14 -12
View File
@@ -1,6 +1,6 @@
import ListViewKit
import SwiftUI
import UniformTypeIdentifiers
import ListViewKit
#if canImport(AppKit)
import AppKit
@@ -160,9 +160,7 @@ struct SFTPBrowserView: View {
case compact
init(width: CGFloat) {
if width >= 760 { self = .full }
else if width >= 520 { self = .medium }
else { self = .compact }
if width >= 760 { self = .full } else if width >= 520 { self = .medium } else { self = .compact }
}
}
@@ -480,14 +478,16 @@ struct SFTPBrowserView: View {
let components = RemotePath.displayedComponents(
of: sftpSession.path, home: sftpSession.homeResolution
)
let tailCount = switch layout {
case .full: 3
case .medium: 2
case .compact: 1
}
let tailCount =
switch layout {
case .full: 3
case .medium: 2
case .compact: 1
}
let collapses = components.count > tailCount + 1
let visible = collapses ? Array(components.suffix(tailCount)) : Array(components.dropFirst())
let hidden = collapses
let hidden =
collapses
? Array(components.dropFirst().dropLast(tailCount))
: []
@@ -648,7 +648,8 @@ struct SFTPBrowserView: View {
}
private func fileListItems(layout: Layout) -> [SFTPFileListItem] {
let emphasized = isPaneFocused && !pathFocused
let emphasized =
isPaneFocused && !pathFocused
&& controlActiveState == .key
let layoutKey: Int
switch layout {
@@ -990,7 +991,8 @@ struct SFTPBrowserView: View {
let files = sftpSession.filteredFiles
guard !files.isEmpty else { return .ignored }
let currentID = selectionCursor ?? sftpSession.selectedFiles.first?.id
let currentIndex = currentID.flatMap { id in files.firstIndex { $0.id == id } }
let currentIndex =
currentID.flatMap { id in files.firstIndex { $0.id == id } }
?? (offset > 0 ? -1 : files.count)
let targetIndex = min(max(currentIndex + offset, 0), files.count - 1)
let target = files[targetIndex]
+26 -15
View File
@@ -389,7 +389,8 @@ final class SFTPSessionModel: Identifiable {
return
}
status = reconnecting ? .reconnecting : .connecting
hint = reconnecting
hint =
reconnecting
? String(localized: "Reconnecting") : String(localized: "Connecting")
connectionTask = Task { [weak self] in
guard let self else { return }
@@ -490,7 +491,8 @@ final class SFTPSessionModel: Identifiable {
authenticationHost = host
}
let submitted = self.suppliedAuthentications[authenticationHost.id]
let retryNeedsPassphrase = submitted?.passphrase == nil
let retryNeedsPassphrase =
submitted?.passphrase == nil
&& (submitted?.keyID ?? store.identity(for: authenticationHost)?.keyID)
.flatMap { store.key($0) }?.requiresPassphraseEntry == true
let returnsToIdentity = submitted?.passphrase != nil
@@ -1175,7 +1177,9 @@ final class SFTPSessionModel: Identifiable {
}
}
for var job in batch.jobs {
if remoteResolution.skipped.contains(where: { job.remotePath == $0 || job.remotePath.hasPrefix($0 + "/") })
if remoteResolution.skipped.contains(where: {
job.remotePath == $0 || job.remotePath.hasPrefix($0 + "/")
})
|| skippedLocalPrefixes.contains(where: {
job.localURL.path == $0.path || job.localURL.path.hasPrefix($0.path + "/")
})
@@ -1198,11 +1202,13 @@ final class SFTPSessionModel: Identifiable {
}
if job.hasConflict {
if job.direction == .upload {
guard let resolved = try await resolveRemoteConflict(
path: job.remotePath,
conflictingTargetIsDirectory: job.conflictingTargetIsDirectory,
policy: policy, client: client
) else { continue }
guard
let resolved = try await resolveRemoteConflict(
path: job.remotePath,
conflictingTargetIsDirectory: job.conflictingTargetIsDirectory,
policy: policy, client: client
)
else { continue }
job.remotePath = resolved.path
job.hasConflict = resolved.overwrite
} else {
@@ -1268,11 +1274,13 @@ final class SFTPSessionModel: Identifiable {
job.hasConflict = false
}
if job.hasConflict {
guard let resolved = try await resolveRemoteConflict(
path: job.destinationPath,
conflictingTargetIsDirectory: job.conflictingTargetIsDirectory,
policy: policy, client: client
) else { continue }
guard
let resolved = try await resolveRemoteConflict(
path: job.destinationPath,
conflictingTargetIsDirectory: job.conflictingTargetIsDirectory,
policy: policy, client: client
)
else { continue }
job.destinationPath = resolved.path
job.hasConflict = resolved.overwrite
}
@@ -1354,7 +1362,8 @@ final class SFTPSessionModel: Identifiable {
let submission = suppliedAuthentications[host.id]
let selectedKeyID = submission?.keyID ?? identity?.keyID
let username = submission?.username ?? identity?.username ?? ""
let needsPassphrase = requiresPassphrase
let needsPassphrase =
requiresPassphrase
?? (!username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& selectedKeyID.flatMap { store.key($0) }?.requiresPassphraseEntry == true)
pendingAuthentication = SSHAuthenticationPrompt(
@@ -1483,7 +1492,9 @@ final class SFTPSessionModel: Identifiable {
for original in directories {
if result.skipped.contains(where: {
original == $0 || original.hasPrefix($0 + "/")
}) { continue }
}) {
continue
}
var destination = original
for mapping in result.mappings
where destination == mapping.0 || destination.hasPrefix(mapping.0 + "/") {
@@ -103,9 +103,12 @@ struct MCPServerEditorPage: View {
.font(.caption)
.foregroundStyle(.orange)
} else if namespaceID.isEmpty {
Label("Name must produce an ID containing ASCII letters or numbers.", systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
Label(
"Name must produce an ID containing ASCII letters or numbers.",
systemImage: "exclamationmark.triangle.fill"
)
.font(.caption)
.foregroundStyle(.orange)
} else if hasNamespaceConflict {
Label("Another MCP server already uses this ID.", systemImage: "exclamationmark.triangle.fill")
.font(.caption)
@@ -297,7 +300,9 @@ struct MCPServerEditorPage: View {
private func binding<Value>(_ keyPath: WritableKeyPath<MCPServer, Value>) -> Binding<Value> {
Binding(
get: { app.aiConfiguration.mcpServer(serverID)?[keyPath: keyPath] ?? MCPServer(name: "")[keyPath: keyPath] },
get: {
app.aiConfiguration.mcpServer(serverID)?[keyPath: keyPath] ?? MCPServer(name: "")[keyPath: keyPath]
},
set: { newValue in
guard var server = app.aiConfiguration.mcpServer(serverID) else { return }
server[keyPath: keyPath] = newValue
@@ -336,7 +341,8 @@ private struct ToolRow: View {
private var qualifiedToolName: String {
guard let server = app.aiConfiguration.mcpServer(serverID) else { return tool.id }
let leaf = MCPToolNaming.functionNames(for: server.tools)[tool.id]
let leaf =
MCPToolNaming.functionNames(for: server.tools)[tool.id]
?? MCPToolNaming.functionName(from: tool.id)
return MCPToolNaming.qualifiedName(
namespace: server.effectiveNamespaceID, function: leaf
@@ -384,8 +390,8 @@ private struct ToolRow: View {
}
}
private extension MCPTool.AccessPolicy {
var displayName: LocalizedStringKey {
extension MCPTool.AccessPolicy {
fileprivate var displayName: LocalizedStringKey {
switch self {
case .disabled: "Disabled"
case .alwaysAsk: "Always Ask"
@@ -13,10 +13,12 @@ struct MCPServerSettings: View {
var body: some View {
Section("Server") {
Toggle("Enable", isOn: Binding(
get: { server.isEnabled && app.proAccess.canUse(.mcpServer) },
set: { server.setEnabled($0) }
))
Toggle(
"Enable",
isOn: Binding(
get: { server.isEnabled && app.proAccess.canUse(.mcpServer) },
set: { server.setEnabled($0) }
))
LabeledContent("Status") {
HStack(spacing: Metrics.Space.s) {
@@ -28,10 +30,13 @@ struct MCPServerSettings: View {
}
LabeledContent("Connected clients", value: "\(server.connectedClients)")
LabeledContent("Port") {
TextField("Port", value: Binding(
get: { server.port },
set: { server.setPort($0) }
), format: .number.grouping(.never))
TextField(
"Port",
value: Binding(
get: { server.port },
set: { server.setPort($0) }
), format: .number.grouping(.never)
)
.frame(width: 100)
.multilineTextAlignment(.trailing)
}
@@ -68,15 +73,20 @@ struct MCPServerSettings: View {
Section("Exposed Tools") {
ForEach(MCPServerTools.exportedNames.sorted(), id: \.self) { name in
Toggle(name, isOn: Binding(
get: { !server.disabledTools.contains(name) },
set: { server.setToolEnabled($0, name: name) }
))
Toggle(
name,
isOn: Binding(
get: { !server.disabledTools.contains(name) },
set: { server.setToolEnabled($0, name: name) }
)
)
.font(.system(.body, design: .monospaced))
}
Text("Tool changes are enforced immediately. Existing clients see the new list when they reconnect or list tools again.")
.font(.caption)
.foregroundStyle(.secondary)
Text(
"Tool changes are enforced immediately. Existing clients see the new list when they reconnect or list tools again."
)
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Call History") {
@@ -139,7 +149,9 @@ private struct ExternalToolCallRow: View {
HStack {
Text(row.clientName)
Text(row.startedAt, style: .relative)
if let duration = row.duration { Text(duration, format: .number.precision(.fractionLength(2))) + Text("s") }
if let duration = row.duration {
Text(duration, format: .number.precision(.fractionLength(2))) + Text("s")
}
}
.font(.caption2).foregroundStyle(.tertiary)
}
@@ -196,7 +196,9 @@ struct ModelListEditor: View {
/// Roles whose model is among the ticked ones.
private var brokenRoles: [AIRole] {
AIRole.allCases.filter { role in
guard let ref = app.aiConfiguration.assignment(for: role), ref.modelProviderID == modelProviderID else { return false }
guard let ref = app.aiConfiguration.assignment(for: role), ref.modelProviderID == modelProviderID else {
return false
}
return state.checked.contains(ref.modelID)
}
}
@@ -507,7 +509,8 @@ struct ModelListEditor: View {
}
private func reasoningLevelRow(_ effort: ReasoningEffort, model: AIModel) -> some View {
let mapping = model.reasoningProfile?.mapping(for: effort)
let mapping =
model.reasoningProfile?.mapping(for: effort)
?? ReasoningLevelMapping(isSupported: false)
let supportedCount = model.reasoningProfile?.levels.values.filter(\.isSupported).count ?? 0
@@ -603,7 +606,8 @@ struct ModelListEditor: View {
) -> Binding<Value> where Value: Hashable {
Binding(
get: {
app.aiConfiguration.modelProvider(modelProviderID)?.model(model.id)?[keyPath: keyPath] ?? model[keyPath: keyPath]
app.aiConfiguration.modelProvider(modelProviderID)?.model(model.id)?[keyPath: keyPath]
?? model[keyPath: keyPath]
},
set: { newValue in
guard var updated = app.aiConfiguration.modelProvider(modelProviderID)?.model(model.id) else { return }
@@ -751,8 +755,8 @@ struct ModelListEditor: View {
}
}
private extension AIModel {
var withoutReasoningOverride: AIModel {
extension AIModel {
fileprivate var withoutReasoningOverride: AIModel {
var copy = self
copy.reasoningProfile = nil
return copy
@@ -825,7 +829,7 @@ private struct CandidatePicker: View {
let ids = filtered.map(\.id)
chooser.toggleAll(visible: ids, enabled: Set(selectable.map(\.id)))
}
.disabled(selectable.isEmpty)
.disabled(selectable.isEmpty)
Button("Select None") { chooser.selected.subtract(filtered.map(\.id)) }
.disabled(chooser.selected.isEmpty)
Spacer(minLength: 0)
@@ -844,7 +848,7 @@ private struct CandidatePicker: View {
isChosen: item.isChosen
) {
chooser.activate(item.id)
}
}
}
}
@@ -867,8 +871,8 @@ private struct CandidatePicker: View {
Button(
chooser.selected.isEmpty ? "Add" : "Add \(chooser.selected.count)"
) { add(chooser.selected) }
.keyboardShortcut(.defaultAction)
.disabled(chooser.selected.isEmpty)
.keyboardShortcut(.defaultAction)
.disabled(chooser.selected.isEmpty)
}
.padding(Metrics.Space.l)
}
@@ -350,7 +350,10 @@ struct ModelProviderEditorPage: View {
/// the provider exists.
private func binding<Value>(_ keyPath: WritableKeyPath<ModelProvider, Value>) -> Binding<Value> {
Binding(
get: { app.aiConfiguration.modelProvider(modelProviderID)?[keyPath: keyPath] ?? ModelProvider(name: "")[keyPath: keyPath] },
get: {
app.aiConfiguration.modelProvider(modelProviderID)?[keyPath: keyPath]
?? ModelProvider(name: "")[keyPath: keyPath]
},
set: { newValue in
guard var provider = app.aiConfiguration.modelProvider(modelProviderID) else { return }
provider[keyPath: keyPath] = newValue
@@ -91,13 +91,13 @@ struct SettingsPageView: View {
var body: some View {
detail.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(TermindColor.windowSurface)
.onChange(of: window.settingsRoute) { oldRoute, newRoute in
cleanUpDraft(from: oldRoute, movingTo: newRoute)
}
.onDisappear {
cleanUpDraft(from: window.settingsRoute, movingTo: .index)
}
.background(TermindColor.windowSurface)
.onChange(of: window.settingsRoute) { oldRoute, newRoute in
cleanUpDraft(from: oldRoute, movingTo: newRoute)
}
.onDisappear {
cleanUpDraft(from: window.settingsRoute, movingTo: .index)
}
}
@ViewBuilder
@@ -264,7 +264,9 @@ private struct SettingsIndexPage: View {
}
private func categoryButton(_ category: SettingsCategory) -> some View {
Button { select(category) } label: {
Button {
select(category)
} label: {
SettingsCategoryRow(category: category)
}
.buttonStyle(.plain)
@@ -292,9 +294,11 @@ private struct SettingsCategoryRow: View {
enum SettingsDraftPolicy {
static func shouldDiscard(_ provider: ModelProvider) -> Bool {
let hasDefaultName = provider.name.trimmingCharacters(in: .whitespacesAndNewlines)
let hasDefaultName =
provider.name.trimmingCharacters(in: .whitespacesAndNewlines)
== String(localized: "New Provider")
let hasConfiguration = !provider.baseURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
let hasConfiguration =
!provider.baseURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| !provider.apiPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| provider.hasCredential || !provider.models.isEmpty
return hasDefaultName && !hasConfiguration
@@ -426,8 +430,10 @@ private struct ProSettings: View {
}
Section("Termind Pro") {
Text("Unlock multiple-pane workspaces, the AI Assistant, terminal drop uploads, MCP, and private iCloud Vault sync with one permanent purchase.")
.foregroundStyle(.secondary)
Text(
"Unlock multiple-pane workspaces, the AI Assistant, terminal drop uploads, MCP, and private iCloud Vault sync with one permanent purchase."
)
.foregroundStyle(.secondary)
if app.proAccess.status.canPurchase {
Button(purchaseEntryTitle) { app.proAccess.presentPaywall() }
.buttonStyle(.borderedProminent)
@@ -500,8 +506,10 @@ private struct SidebarSettings: View {
private struct SFTPSettings: View {
@Environment(AppModel.self) private var app
@AppStorage(SettingsKey.sftpGlobalTransferLimit) private var globalLimit = SettingsKey.defaultSFTPGlobalTransferLimit
@AppStorage(SettingsKey.sftpPerHostTransferLimit) private var perHostLimit = SettingsKey.defaultSFTPPerHostTransferLimit
@AppStorage(SettingsKey.sftpGlobalTransferLimit) private var globalLimit = SettingsKey
.defaultSFTPGlobalTransferLimit
@AppStorage(SettingsKey.sftpPerHostTransferLimit) private var perHostLimit = SettingsKey
.defaultSFTPPerHostTransferLimit
var body: some View {
Section("SFTP") {
@@ -519,8 +527,10 @@ private struct SFTPSettings: View {
Text(perHostLimit, format: .number).monospacedDigit()
}
}
Text("Changes apply to queued transfers. Active transfers continue unchanged. Unsaved credentials limit a session to one transfer at a time.")
.font(.caption).foregroundStyle(.secondary)
Text(
"Changes apply to queued transfers. Active transfers continue unchanged. Unsaved credentials limit a session to one transfer at a time."
)
.font(.caption).foregroundStyle(.secondary)
}
.onAppear { applyLimits() }
.onChange(of: globalLimit) { applyLimits() }
@@ -529,9 +539,15 @@ private struct SFTPSettings: View {
private func applyLimits() {
let normalizedGlobal = min(max(globalLimit, 1), 8)
if globalLimit != normalizedGlobal { globalLimit = normalizedGlobal; return }
if globalLimit != normalizedGlobal {
globalLimit = normalizedGlobal
return
}
let normalizedPerHost = min(max(perHostLimit, 1), normalizedGlobal)
if perHostLimit != normalizedPerHost { perHostLimit = normalizedPerHost; return }
if perHostLimit != normalizedPerHost {
perHostLimit = normalizedPerHost
return
}
app.sftpTransfers.updateLimits(global: globalLimit, perHost: perHostLimit)
}
}
@@ -615,8 +631,12 @@ private struct TerminalSettings: View {
}
}
.onAppear {
if TermindTerminalThemeCatalog.darkTheme(named: darkTheme) == nil { darkTheme = SettingsKey.defaultTerminalThemeDark }
if TermindTerminalThemeCatalog.lightTheme(named: lightTheme) == nil { lightTheme = SettingsKey.defaultTerminalThemeLight }
if TermindTerminalThemeCatalog.darkTheme(named: darkTheme) == nil {
darkTheme = SettingsKey.defaultTerminalThemeDark
}
if TermindTerminalThemeCatalog.lightTheme(named: lightTheme) == nil {
lightTheme = SettingsKey.defaultTerminalThemeLight
}
}
.onChange(of: shellIntegration) { _, isEnabled in
shellIntegrationChanged(isEnabled)
@@ -636,8 +656,10 @@ private struct SyncSettings: View {
LabeledContent("Status") { statusLabel }
if case .accountChangeConfirmationRequired = status {
Text("This Mac is signed in to a different iCloud account. Your local Vault will not be uploaded until you confirm the merge.")
.font(.caption).foregroundStyle(.secondary)
Text(
"This Mac is signed in to a different iCloud account. Your local Vault will not be uploaded until you confirm the merge."
)
.font(.caption).foregroundStyle(.secondary)
Button("Merge with This iCloud Account") {
app.vaultSync?.confirmAccountSwitchMerge()
}
@@ -645,8 +667,10 @@ private struct SyncSettings: View {
Button("Retry") { app.vaultSync?.retry() }
}
Text("Hosts, identities, public key metadata, Known Hosts, snippets, and port forwards sync through your private iCloud database. Passwords and private keys remain protected separately by Keychain. Credentials marked “This Mac” will be missing on other Macs.")
.font(.caption).foregroundStyle(.secondary)
Text(
"Hosts, identities, public key metadata, Known Hosts, snippets, and port forwards sync through your private iCloud database. Passwords and private keys remain protected separately by Keychain. Credentials marked “This Mac” will be missing on other Macs."
)
.font(.caption).foregroundStyle(.secondary)
}
}
@@ -38,7 +38,9 @@ struct TerminalCustomConfigurationEditorPage: View {
.font(.caption)
.foregroundStyle(.orange)
Spacer()
Button { self.importError = nil } label: {
Button {
self.importError = nil
} label: {
Image(systemName: "xmark")
}
.buttonStyle(.plain)
@@ -555,18 +555,18 @@ private struct SnippetsSidebarView: View {
SSHSessionSnippetRow(
snippet: snippet, isSelected: selectedSnippetID == snippet.id
)
.termindSidebarRow(isSelected: selectedSnippetID == snippet.id) {
[
NativeMenuEntry(title: localized("Copy")) {
copyToPasteboard(snippet.body)
},
NativeMenuEntry(title: localized("Execute")) {
requestExecution(snippet)
},
]
}
.onTapGesture(count: 2) { requestExecution(snippet) }
.onTapGesture { selectedSnippetID = snippet.id }
.termindSidebarRow(isSelected: selectedSnippetID == snippet.id) {
[
NativeMenuEntry(title: localized("Copy")) {
copyToPasteboard(snippet.body)
},
NativeMenuEntry(title: localized("Execute")) {
requestExecution(snippet)
},
]
}
.onTapGesture(count: 2) { requestExecution(snippet) }
.onTapGesture { selectedSnippetID = snippet.id }
}
private var displayedSnippets: [Snippet] {
@@ -598,9 +598,10 @@ private struct SnippetsSidebarView: View {
private func requestExecution(_ snippet: Snippet) {
if snippet.targetHostIDs.isEmpty {
window.hostChooser = .snippet(SnippetHostChooserRequest(
snippetIDs: [snippet.id], strategy: .fillMissingTargets
))
window.hostChooser = .snippet(
SnippetHostChooserRequest(
snippetIDs: [snippet.id], strategy: .fillMissingTargets
))
return
}
if CommandRiskClassifier.assess(snippet.body).requiresSecondConfirmation {
@@ -642,11 +643,13 @@ private struct SidebarCard<Trailing: View>: View {
HStack(spacing: Metrics.Space.s) {
if let symbol {
Image(systemName: symbol)
.foregroundStyle(SidebarSelectionStyle.foreground(
isSelected: isSelected,
controlActiveState: controlActiveState,
unselected: symbolTint ?? AnyShapeStyle(.secondary)
))
.foregroundStyle(
SidebarSelectionStyle.foreground(
isSelected: isSelected,
controlActiveState: controlActiveState,
unselected: symbolTint ?? AnyShapeStyle(.secondary)
)
)
.frame(width: Metrics.Sidebar.rowIcon)
}
@@ -657,11 +660,13 @@ private struct SidebarCard<Trailing: View>: View {
if let subtitle {
Text(subtitle)
.font(subtitleIsMonospaced ? .caption.monospaced() : .caption)
.foregroundStyle(SidebarSelectionStyle.foreground(
isSelected: isSelected,
controlActiveState: controlActiveState,
unselected: AnyShapeStyle(.secondary)
))
.foregroundStyle(
SidebarSelectionStyle.foreground(
isSelected: isSelected,
controlActiveState: controlActiveState,
unselected: AnyShapeStyle(.secondary)
)
)
.lineLimit(1)
.truncationMode(truncatesSubtitleInMiddle ? .middle : .tail)
}
@@ -899,7 +904,9 @@ private struct SSHCommandHistorySidebar: View {
}
private func insert(_ entry: CommandHistoryEntry) {
guard let workspace = window.selectedWorkspace, let sshSession = workspace.focusedPane.sshSession else { return }
guard let workspace = window.selectedWorkspace, let sshSession = workspace.focusedPane.sshSession else {
return
}
workspace.deliver(entry.command, from: sshSession)
}
@@ -912,7 +919,9 @@ private struct SSHCommandHistorySidebar: View {
}
private func executeNow(_ entry: CommandHistoryEntry) {
guard let workspace = window.selectedWorkspace, let sshSession = workspace.focusedPane.sshSession else { return }
guard let workspace = window.selectedWorkspace, let sshSession = workspace.focusedPane.sshSession else {
return
}
workspace.deliver(entry.command, from: sshSession, submit: true)
destructiveEntry = nil
}
@@ -819,7 +819,7 @@ private struct SnippetTargetPickerSheet: View {
isChecked: item.isChecked
)
.onTapGesture { chooser.activate(item.id) }
}
}
.padding(.horizontal, Metrics.Space.l)
.padding(.bottom, Metrics.Space.s)
}
+11 -7
View File
@@ -229,10 +229,11 @@ struct StatusBarView: View {
Text(status.label)
}
}
.help(Text(
host.map { app.hostLibrary.loginAddress(for: $0) }
?? serialSession?.subtitle ?? status.label
))
.help(
Text(
host.map { app.hostLibrary.loginAddress(for: $0) }
?? serialSession?.subtitle ?? status.label
))
}
private var host: Host? { pane?.hostID.flatMap { app.hostLibrary.host($0) } }
@@ -611,9 +612,12 @@ private struct TransferCenterNativeList: NSViewRepresentable {
return Metrics.TransferCenter.estimatedRowHeight
}
measurement.rootView = root
return ceil(measurement.sizeThatFits(in: CGSize(
width: width, height: .greatestFiniteMagnitude
)).height)
return ceil(
measurement.sizeThatFits(
in: CGSize(
width: width, height: .greatestFiniteMagnitude
)
).height)
}
private func configure(_ row: HostingListRow, item: TransferCenterListItem) {
@@ -243,7 +243,8 @@
// search-field → terminal → search-field focus flash.
let window = terminalView.window
let editor = window?.firstResponder as? NSTextView
let textField = editor?.isFieldEditor == true
let textField =
editor?.isFieldEditor == true
? editor?.delegate as? NSTextField
: nil
let selection = editor?.selectedRange
@@ -673,7 +673,8 @@ final class SSHSessionModel: Identifiable {
{
let identity = store.identity(for: jump)
let submitted = suppliedAuthentications[jump.id]
let retryNeedsPassphrase = submitted?.passphrase == nil
let retryNeedsPassphrase =
submitted?.passphrase == nil
&& (submitted?.keyID ?? identity?.keyID)
.flatMap { store.key($0) }?.requiresPassphraseEntry == true
connectionAttemptID = nil
@@ -768,9 +769,11 @@ final class SSHSessionModel: Identifiable {
}
let authenticationIdentity = store.identity(for: authenticationHost)
let submitted = suppliedAuthentications[authenticationHost.id]
let authenticationKeyID = submitted?.keyID
let authenticationKeyID =
submitted?.keyID
?? authenticationIdentity?.keyID ?? selectedKeyID
let retryNeedsPassphrase = submitted?.passphrase == nil
let retryNeedsPassphrase =
submitted?.passphrase == nil
&& authenticationKeyID.flatMap { store.key($0) }?
.requiresPassphraseEntry == true
let returnsToIdentity = submitted?.passphrase != nil
@@ -104,7 +104,10 @@ final class SerialSessionModel: Identifiable {
}
)
try Task.checkCancellation()
guard let self else { await opened.close(); return }
guard let self else {
await opened.close()
return
}
self.connection = opened
self.status = .connected
self.connectionJourney.succeed()
@@ -146,8 +149,9 @@ final class SerialSessionModel: Identifiable {
terminalBackend.receive(echo)
}
Task {
do { try await connection.write(output) }
catch { await MainActor.run { self.fail(error.localizedDescription) } }
do { try await connection.write(output) } catch {
await MainActor.run { self.fail(error.localizedDescription) }
}
}
}
@@ -44,64 +44,64 @@ struct TerminalPlaceholderView: View {
terminalSurface
.overlay(alignment: .bottom) { uploadDecoration }
.overlay { connectionJourney }
.animation(Motion.panel, value: uploadNotice)
.background(terminalAppearance.background)
.onAppear {
// The mounted view is the final source of truth after any drag,
// merge, detach, or SwiftUI tree rebuild. Reassert the relationship
// here so a stale layout callback cannot leave live keystrokes on a
// sshSession whose router was cleared while it moved.
sshSession.inputRouter = workspace
sshSession.terminalState.isSurfaceVisible = isVisible
terminalFocused = requestsFocus
sshSession.terminalState.applyAppearance(terminalAppearance.preferences)
if sshSession.status == .connecting { sshSession.connect(using: app.hostLibrary) }
if requestsFocus { sshSession.requestTerminalFocus() }
}
.onChange(of: isVisible) { _, visible in
sshSession.terminalState.isSurfaceVisible = visible
}
.onChange(of: requestsFocus) { _, focused in
terminalFocused = focused
if focused { sshSession.requestTerminalFocus() }
}
.onChange(of: externalFileDrop?.id) {
guard let externalFileDrop else { return }
_ = receiveDroppedURLs(externalFileDrop.urls)
}
.onDisappear {
sshSession.terminalState.isSurfaceVisible = false
terminalFocused = false
uploadNoticeTask?.cancel()
uploadPreparationTask?.cancel()
cancelPendingUpload()
}
.onChange(of: terminalAppearance.preferences) { _, preferences in
sshSession.terminalState.applyAppearance(preferences)
}
.confirmationDialog(
uploadConflictTitle,
isPresented: $uploadConflictPresented,
titleVisibility: .visible
) {
Button("Replace") { enqueuePendingUpload(.replace) }
Button("Skip Existing") { enqueuePendingUpload(.skip) }
Button("Keep Both") { enqueuePendingUpload(.keepBoth) }
Button("Cancel", role: .cancel) { cancelPendingUpload() }
} message: {
Text("Use this choice for all conflicts in this batch.")
}
.alert(
"Upload Failed",
isPresented: Binding(
get: { uploadError != nil },
set: { if !$0 { uploadError = nil } }
)
) {
Button("OK") { uploadError = nil }
} message: {
Text(uploadError ?? "")
}
.animation(Motion.panel, value: uploadNotice)
.background(terminalAppearance.background)
.onAppear {
// The mounted view is the final source of truth after any drag,
// merge, detach, or SwiftUI tree rebuild. Reassert the relationship
// here so a stale layout callback cannot leave live keystrokes on a
// sshSession whose router was cleared while it moved.
sshSession.inputRouter = workspace
sshSession.terminalState.isSurfaceVisible = isVisible
terminalFocused = requestsFocus
sshSession.terminalState.applyAppearance(terminalAppearance.preferences)
if sshSession.status == .connecting { sshSession.connect(using: app.hostLibrary) }
if requestsFocus { sshSession.requestTerminalFocus() }
}
.onChange(of: isVisible) { _, visible in
sshSession.terminalState.isSurfaceVisible = visible
}
.onChange(of: requestsFocus) { _, focused in
terminalFocused = focused
if focused { sshSession.requestTerminalFocus() }
}
.onChange(of: externalFileDrop?.id) {
guard let externalFileDrop else { return }
_ = receiveDroppedURLs(externalFileDrop.urls)
}
.onDisappear {
sshSession.terminalState.isSurfaceVisible = false
terminalFocused = false
uploadNoticeTask?.cancel()
uploadPreparationTask?.cancel()
cancelPendingUpload()
}
.onChange(of: terminalAppearance.preferences) { _, preferences in
sshSession.terminalState.applyAppearance(preferences)
}
.confirmationDialog(
uploadConflictTitle,
isPresented: $uploadConflictPresented,
titleVisibility: .visible
) {
Button("Replace") { enqueuePendingUpload(.replace) }
Button("Skip Existing") { enqueuePendingUpload(.skip) }
Button("Keep Both") { enqueuePendingUpload(.keepBoth) }
Button("Cancel", role: .cancel) { cancelPendingUpload() }
} message: {
Text("Use this choice for all conflicts in this batch.")
}
.alert(
"Upload Failed",
isPresented: Binding(
get: { uploadError != nil },
set: { if !$0 { uploadError = nil } }
)
) {
Button("OK") { uploadError = nil }
} message: {
Text(uploadError ?? "")
}
}
@ViewBuilder
@@ -312,11 +312,12 @@ struct TerminalPlaceholderView: View {
// The log rides on the chip rather than in the draft. Pasting it into
// the editable text made the pill a label for something the user could
// silently delete half of, and left the draft too long to read.
conversation.attach(ContextChip(
kind: .output,
label: String(localized: "Connection error"),
content: String(log.suffix(4_000))
))
conversation.attach(
ContextChip(
kind: .output,
label: String(localized: "Connection error"),
content: String(log.suffix(4_000))
))
conversation.composer.draft = String(
localized: "SSH connection failed: \(message). What should I check?"
)
+1
View File
@@ -1,4 +1,5 @@
import SwiftUI
/// The first pinned destination's main content. Its navigator is mounted by the
/// window-level split view and remains alive while top-level destinations change.
struct VaultView: View {
+14 -9
View File
@@ -474,8 +474,9 @@ final class WindowModel {
var sessionPanes: [PaneRef] {
workspaces.flatMap { workspace in
workspace.panes.compactMap { pane in
guard pane.kind == .sshSession || pane.kind == .sftpSession
|| pane.kind == .serialSession
guard
pane.kind == .sshSession || pane.kind == .sftpSession
|| pane.kind == .serialSession
else { return nil }
return PaneRef(workspace: workspace, pane: pane)
}
@@ -600,12 +601,14 @@ final class WindowModel {
}
func toggleAssistantPin(_ id: ConversationModel.ID) {
if let assistantCommands { assistantCommands.togglePin(id) }
else if let conversation = assistantLibrary.conversation(id: id),
if let assistantCommands {
assistantCommands.togglePin(id)
} else if let conversation = assistantLibrary.conversation(id: id),
!conversation.isArchived, !conversation.isStreaming, !conversation.isCompacting
{
let toPinned = !conversation.isPinned
let nextID = (toPinned
let nextID =
(toPinned
? assistantLibrary.pinnedConversations
: assistantLibrary.activeConversations).first?.id
moveAssistantConversation(id, toPinned: toPinned, before: nextID)
@@ -625,8 +628,9 @@ final class WindowModel {
}
}
func renameAssistantConversation(_ id: ConversationModel.ID, to title: String) {
if let assistantCommands { assistantCommands.rename(id, title) }
else {
if let assistantCommands {
assistantCommands.rename(id, title)
} else {
let value = title.trimmingCharacters(in: .whitespacesAndNewlines)
if !value.isEmpty {
assistantLibrary.conversation(id: id)?.customTitle = String(value.prefix(80))
@@ -634,8 +638,9 @@ final class WindowModel {
}
}
func setAssistantArchived(_ id: ConversationModel.ID, archived: Bool) {
if let assistantCommands { assistantCommands.archive(id, archived) }
else if let conversation = assistantLibrary.conversation(id: id),
if let assistantCommands {
assistantCommands.archive(id, archived)
} else if let conversation = assistantLibrary.conversation(id: id),
!conversation.isStreaming, !conversation.isCompacting
{
conversation.archivedAt = archived ? .now : nil
+18 -13
View File
@@ -270,9 +270,10 @@ final class Workspace: Identifiable {
commandGenerationSequence &+= 1
let generation = commandGenerationSequence
let targetIDs = targets.map(\.id)
let revisions = Dictionary(uniqueKeysWithValues: targets.map {
($0.id, $0.commandGeneratorRevision)
})
let revisions = Dictionary(
uniqueKeysWithValues: targets.map {
($0.id, $0.commandGeneratorRevision)
})
let focusedID = focusedPaneID
let wasBroadcasting = broadcastsInput && canBroadcast
commandGeneratorState = .readingDraft
@@ -284,11 +285,13 @@ final class Workspace: Identifiable {
guard !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw WorkspaceCommandGenerationError.emptyDraft
}
guard self?.commandGenerationSnapshotIsCurrent(
generation: generation, focusedID: focusedID,
wasBroadcasting: wasBroadcasting, targetIDs: targetIDs,
revisions: revisions
) == true else { throw CancellationError() }
guard
self?.commandGenerationSnapshotIsCurrent(
generation: generation, focusedID: focusedID,
wasBroadcasting: wasBroadcasting, targetIDs: targetIDs,
revisions: revisions
) == true
else { throw CancellationError() }
self?.commandGeneratorState = .generating
let context = CommandGeneratorContext(
@@ -300,11 +303,13 @@ final class Workspace: Identifiable {
try await controller.generate(context)
}
try Task.checkCancellation()
guard self?.commandGenerationSnapshotIsCurrent(
generation: generation, focusedID: focusedID,
wasBroadcasting: wasBroadcasting, targetIDs: targetIDs,
revisions: revisions
) == true else { throw CancellationError() }
guard
self?.commandGenerationSnapshotIsCurrent(
generation: generation, focusedID: focusedID,
wasBroadcasting: wasBroadcasting, targetIDs: targetIDs,
revisions: revisions
) == true
else { throw CancellationError() }
for target in targets {
target.replaceCurrentDraft(with: command)
@@ -21,7 +21,7 @@ struct WorkspaceContentView: View {
isActive: isActive,
omittedPaneID: omittedPaneID
)
.id(workspace.id)
.id(workspace.id)
if workspace.commandGeneratorState != .idle {
CommandGeneratorOverlay(state: workspace.commandGeneratorState)
@@ -554,7 +554,8 @@ private struct PaneView: View {
tone: .unavailable
)
}
let hostName = app.hostLibrary.host(sftpSession.hostID)?.name
let hostName =
app.hostLibrary.host(sftpSession.hostID)?.name
?? String(localized: "SFTP")
return DropTargetPresentation(
title: nativeDropKind == .sftpItems
@@ -142,7 +142,9 @@ struct WorkspaceNavigatorView: View {
navigatorSectionHeader("Workspace", isExpanded: $workspaceExpanded)
.onGeometryChange(for: CGRect.self) {
$0.frame(in: .named(Self.coordinateSpace))
} action: { workspaceHeaderFrame = $0 }
} action: {
workspaceHeaderFrame = $0
}
if workspaceExpanded {
if window.workspaces.isEmpty {
@@ -160,7 +162,9 @@ struct WorkspaceNavigatorView: View {
)
.onGeometryChange(for: CGRect.self) {
$0.frame(in: .named(Self.coordinateSpace))
} action: { workspaceFrames[workspace.id] = $0 }
} action: {
workspaceFrames[workspace.id] = $0
}
.overlay(alignment: .top) {
if workspaceDropTarget?.visibleBoundaryIndex == index {
workspaceInsertionLine
@@ -189,7 +193,9 @@ struct WorkspaceNavigatorView: View {
}
.onGeometryChange(for: CGRect.self) {
$0.frame(in: .named(Self.coordinateSpace))
} action: { batchStartupFrame = $0 }
} action: {
batchStartupFrame = $0
}
}
}
}
@@ -201,7 +207,8 @@ struct WorkspaceNavigatorView: View {
private var workspaceDropBounds: CGRect {
guard workspaceExpanded else { return workspaceHeaderFrame }
let frames = [workspaceHeaderFrame, batchStartupFrame]
let frames =
[workspaceHeaderFrame, batchStartupFrame]
+ window.workspaces.compactMap { workspaceFrames[$0.id] }
return frames.filter { !$0.isEmpty }.reduce(.null) { $0.union($1) }
}
@@ -240,7 +247,7 @@ struct WorkspaceNavigatorView: View {
return true
}
)
.frame(width: proxy.size.width, height: proxy.size.height)
.frame(width: proxy.size.width, height: proxy.size.height)
}
.allowsHitTesting(true)
}
@@ -381,11 +388,13 @@ private struct WorkspaceNavigatorRow: View {
private var row: some View {
HStack(spacing: Metrics.Space.s) {
Image(systemName: workspace.symbol)
.foregroundStyle(SidebarSelectionStyle.foreground(
isSelected: isSelected,
controlActiveState: controlActiveState,
unselected: AnyShapeStyle(.secondary)
))
.foregroundStyle(
SidebarSelectionStyle.foreground(
isSelected: isSelected,
controlActiveState: controlActiveState,
unselected: AnyShapeStyle(.secondary)
)
)
.frame(width: Metrics.Sidebar.rowIcon)
if isRenaming {
@@ -63,8 +63,7 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen
func setEnabled(_ enabled: Bool) {
UserDefaults.standard.set(enabled, forKey: SettingsKey.vaultSyncEnabled)
if enabled { start() }
else { pause() }
if enabled { start() } else { pause() }
}
func pause() {
@@ -80,8 +79,12 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen
func retry() {
guard isEnabled else { return }
terminalError = nil
if engine == nil { isStarted = false; start() }
else { run { [weak self] in try await self?.synchronizeNow() } }
if engine == nil {
isStarted = false
start()
} else {
run { [weak self] in try await self?.synchronizeNow() }
}
}
func noteLocalChanges(_ mutations: VaultMutationSet) {
@@ -113,9 +116,7 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen
guard operation == nil else { return }
operation = Task { [weak self] in
defer { self?.operation = nil }
do { try await body() }
catch is CancellationError {}
catch { self?.status = Self.status(for: error) }
do { try await body() } catch is CancellationError {} catch { self?.status = Self.status(for: error) }
}
}
@@ -190,9 +191,10 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen
private func addPendingChanges(_ keys: Set<VaultRecordKey>) {
guard let engine else { return }
engine.state.add(pendingRecordZoneChanges: keys.map {
.saveRecord(CKRecord.ID(recordName: $0.recordName, zoneID: zoneID))
})
engine.state.add(
pendingRecordZoneChanges: keys.map {
.saveRecord(CKRecord.ID(recordName: $0.recordName, zoneID: zoneID))
})
}
private func updateUpToDateStatus() throws {
@@ -248,8 +250,7 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen
guard let key = VaultRecordKey(recordName: recordID.recordName),
let outgoing = try? journal.outgoingEntry(for: key)
else { return nil }
do { return try Self.makeRecord(outgoing, recordID: recordID) }
catch {
do { return try Self.makeRecord(outgoing, recordID: recordID) } catch {
terminalError = error.localizedDescription
status = .failed(error.localizedDescription)
return nil
@@ -355,9 +356,11 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen
// Physical deletes are not part of the protocol; recreate the local
// envelope instead of converting a console accident into data loss.
let physicallyDeleted = Set(changes.deletions.compactMap {
VaultRecordKey(recordName: $0.recordID.recordName)
}).filter { (try? journal.envelope(for: $0)) != nil }
let physicallyDeleted = Set(
changes.deletions.compactMap {
VaultRecordKey(recordName: $0.recordID.recordName)
}
).filter { (try? journal.envelope(for: $0)) != nil }
try journal.markNeedsUpload(physicallyDeleted)
addPendingChanges(result.needsUpload.union(physicallyDeleted))
try context.save()
@@ -424,8 +427,9 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen
/// scoped to Development or Production. Rebind once per environment while
/// retaining every local envelope and its conflict-resolution version.
private func prepareForCurrentEnvironment() throws {
guard defaults.string(forKey: SettingsKey.vaultSyncEnvironment)
!= environmentIdentifier
guard
defaults.string(forKey: SettingsKey.vaultSyncEnvironment)
!= environmentIdentifier
else { return }
try journal.markAllEntriesNeedsUploadAfterEnvironmentChange()
@@ -544,6 +548,6 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen
}
}
private extension SHA256.Digest {
nonisolated var hexString: String { map { String(format: "%02x", $0) }.joined() }
extension SHA256.Digest {
fileprivate nonisolated var hexString: String { map { String(format: "%02x", $0) }.joined() }
}
@@ -159,8 +159,11 @@ extension VaultSnapshot {
private nonisolated func upsert<Value: Identifiable>(
_ value: Value, in values: inout [Value]
) where Value.ID == UUID {
if let index = values.firstIndex(where: { $0.id == value.id }) { values[index] = value }
else { values.append(value) }
if let index = values.firstIndex(where: { $0.id == value.id }) {
values[index] = value
} else {
values.append(value)
}
}
}
@@ -265,9 +268,11 @@ final class VaultSyncJournal {
result.accepted.append(envelope)
continue
}
if local.version < envelope.version { result.accepted.append(envelope) }
else if envelope.version < local.version { result.needsUpload.insert(local.key) }
else if local != envelope {
if local.version < envelope.version {
result.accepted.append(envelope)
} else if envelope.version < local.version {
result.needsUpload.insert(local.key)
} else if local != envelope {
let localData = VaultSyncCodec.encode(local)
let remoteData = VaultSyncCodec.encode(envelope)
if localData.lexicographicallyPrecedes(remoteData) {
@@ -313,9 +318,10 @@ final class VaultSyncJournal {
row.reason = reason
row.receivedAt = .now
} else {
context.insert(PersistentVaultSyncQuarantine(
recordName: recordName, payloadData: payloadData, reason: reason
))
context.insert(
PersistentVaultSyncQuarantine(
recordName: recordName, payloadData: payloadData, reason: reason
))
}
}
@@ -374,8 +380,9 @@ final class VaultSyncJournal {
func markAllLiveRecordsNeedsUploadForNewAccount() throws {
for row in try context.fetch(FetchDescriptor<PersistentVaultSyncEntry>()) {
guard let envelope = row.envelope else { continue }
if envelope.isDeleted { context.delete(row) }
else {
if envelope.isDeleted {
context.delete(row)
} else {
row.needsUpload = true
row.systemFieldsData = nil
}
@@ -91,20 +91,22 @@ final class PersistentAgentMessage {
)
authoredText = message.authoredText.map(AgentPersistenceRedactor.visibleText)
imagesData = (try? JSONEncoder().encode(message.images)) ?? Data()
reasoningData = AgentPersistenceRedactor.encoded(message.reasoning.map { block in
AgentReasoningBlock(
id: block.id,
text: AgentPersistenceRedactor.visibleText(block.text),
createdAt: block.createdAt
)
})
toolCallsData = AgentPersistenceRedactor.encoded(message.toolCalls.map { call in
AgentToolCall(
id: call.id, name: call.name,
arguments: AgentPersistenceRedactor.redactedJSON(call.arguments),
providerItemID: call.providerItemID
)
})
reasoningData = AgentPersistenceRedactor.encoded(
message.reasoning.map { block in
AgentReasoningBlock(
id: block.id,
text: AgentPersistenceRedactor.visibleText(block.text),
createdAt: block.createdAt
)
})
toolCallsData = AgentPersistenceRedactor.encoded(
message.toolCalls.map { call in
AgentToolCall(
id: call.id, name: call.name,
arguments: AgentPersistenceRedactor.redactedJSON(call.arguments),
providerItemID: call.providerItemID
)
})
toolCallID = message.toolCallID
toolName = message.toolName
isError = message.isError
@@ -276,8 +278,11 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
}
let runID = snapshot.id
let runDescriptor = FetchDescriptor<PersistentAgentRun>(predicate: #Predicate { $0.id == runID })
if let row = try context.fetch(runDescriptor).first { row.update(snapshot) }
else { context.insert(PersistentAgentRun(snapshot)) }
if let row = try context.fetch(runDescriptor).first {
row.update(snapshot)
} else {
context.insert(PersistentAgentRun(snapshot))
}
let conversationID = snapshot.conversationID
let conversationDescriptor = FetchDescriptor<PersistentAgentConversation>(
@@ -290,9 +295,10 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
if let conversation = try context.fetch(conversationDescriptor).first {
conversation.updatedAt = .now
} else {
context.insert(PersistentAgentConversation(
id: conversationID, title: "", createdAt: snapshot.startedAt
))
context.insert(
PersistentAgentConversation(
id: conversationID, title: "", createdAt: snapshot.startedAt
))
}
// Matched on message id, not on this run's id. A conversation's earlier
@@ -301,17 +307,18 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
// constraint and rewriting rows that were already correct.
let durableMessages = snapshot.messages.filter { !$0.isCompaction }
let ids = Set(durableMessages.map(\.id))
let stored = try context.fetch(FetchDescriptor<PersistentAgentMessage>(
predicate: #Predicate { ids.contains($0.id) }
))
let stored = try context.fetch(
FetchDescriptor<PersistentAgentMessage>(
predicate: #Predicate { ids.contains($0.id) }
))
let storedIDs = Set(stored.map(\.id))
var inserted = false
for (sequence, message) in durableMessages.enumerated()
where !storedIDs.contains(message.id)
{
context.insert(PersistentAgentMessage(
message, runID: runID, sequence: sequence
))
where !storedIDs.contains(message.id) {
context.insert(
PersistentAgentMessage(
message, runID: runID, sequence: sequence
))
insertContextAttachments(for: message)
inserted = true
}
@@ -326,26 +333,30 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
guard !deletedConversationIDs.contains(conversationID) else {
throw AgentConversationPersistenceError.conversationNotFound
}
let conversations = try context.fetch(FetchDescriptor<PersistentAgentConversation>(
predicate: #Predicate { $0.id == conversationID }
))
let conversations = try context.fetch(
FetchDescriptor<PersistentAgentConversation>(
predicate: #Predicate { $0.id == conversationID }
))
guard conversations.first != nil else {
throw AgentConversationPersistenceError.conversationNotFound
}
if let boundary = record.compactedThroughMessageID {
let runIDs = Set(try context.fetch(FetchDescriptor<PersistentAgentRun>())
.filter { $0.conversationID == conversationID }.map(\.id))
let rows = try context.fetch(FetchDescriptor<PersistentAgentMessage>(
predicate: #Predicate { $0.id == boundary }
))
let runIDs = Set(
try context.fetch(FetchDescriptor<PersistentAgentRun>())
.filter { $0.conversationID == conversationID }.map(\.id))
let rows = try context.fetch(
FetchDescriptor<PersistentAgentMessage>(
predicate: #Predicate { $0.id == boundary }
))
guard rows.contains(where: { runIDs.contains($0.runID) }) else {
throw AgentConversationPersistenceError.messageNotFound
}
}
let recordID = record.id
let existing = try context.fetch(FetchDescriptor<PersistentAgentCompaction>(
predicate: #Predicate { $0.id == recordID }
))
let existing = try context.fetch(
FetchDescriptor<PersistentAgentCompaction>(
predicate: #Predicate { $0.id == recordID }
))
guard existing.isEmpty else { return }
context.insert(PersistentAgentCompaction(record, conversationID: conversationID))
try context.savePendingChanges()
@@ -363,18 +374,21 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
try upsertInvocation(invocation, state: "finished")
context.insert(PersistentAgentToolResult(result, invocation: invocation))
case .reviewStarted(let request):
context.insert(PersistentAgentReviewRecord(
runID: runID, invocationID: request.invocation.id, decision: nil
))
context.insert(
PersistentAgentReviewRecord(
runID: runID, invocationID: request.invocation.id, decision: nil
))
case .reviewFinished(let request, let decision):
context.insert(PersistentAgentReviewRecord(
runID: runID, invocationID: request.invocation.id, decision: decision
))
context.insert(
PersistentAgentReviewRecord(
runID: runID, invocationID: request.invocation.id, decision: decision
))
case .reviewFailed(let request, let reason, let timedOut):
context.insert(PersistentAgentReviewRecord(
runID: runID, invocationID: request.invocation.id,
failure: reason, timedOut: timedOut
))
context.insert(
PersistentAgentReviewRecord(
runID: runID, invocationID: request.invocation.id,
failure: reason, timedOut: timedOut
))
default:
// Nothing to write, so nothing to save. `AgentRuntime` already
// filters on `isDurable`; this is the second half of the same
@@ -386,9 +400,10 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
@MainActor
func loadConversations() async throws -> [StoredAgentConversation] {
let conversations = try context.fetch(FetchDescriptor<PersistentAgentConversation>(
sortBy: [SortDescriptor(\.updatedAt, order: .reverse)]
))
let conversations = try context.fetch(
FetchDescriptor<PersistentAgentConversation>(
sortBy: [SortDescriptor(\.updatedAt, order: .reverse)]
))
let orderRows = try context.fetch(FetchDescriptor<PersistentAgentConversationOrder>())
let sortIndexByConversation = Dictionary(
uniqueKeysWithValues: orderRows.map { ($0.conversationID, $0.sortIndex) }
@@ -437,16 +452,19 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
}
.compactMap { row -> AgentTranscriptMessage? in
guard let role = AgentTranscriptRole(rawValue: row.role) else { return nil }
let calls = (try? JSONDecoder().decode(
[AgentToolCall].self, from: row.toolCallsData
)) ?? []
let images = (try? JSONDecoder().decode(
[AgentImageAttachment].self, from: row.imagesData
)) ?? []
let calls =
(try? JSONDecoder().decode(
[AgentToolCall].self, from: row.toolCallsData
)) ?? []
let images =
(try? JSONDecoder().decode(
[AgentImageAttachment].self, from: row.imagesData
)) ?? []
let contextAttachments = attachmentsByMessage[row.id] ?? []
let reasoning = (try? JSONDecoder().decode(
[AgentReasoningBlock].self, from: row.reasoningData
)) ?? []
let reasoning =
(try? JSONDecoder().decode(
[AgentReasoningBlock].self, from: row.reasoningData
)) ?? []
return AgentTranscriptMessage(
id: row.id, role: role, text: row.text,
authoredText: row.authoredText, images: images,
@@ -462,7 +480,8 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
uniqueKeysWithValues: messages.filter { runIDs.contains($0.runID) }
.map { ($0.id, $0.runID) }
)
let state = matchingRuns.last.flatMap { AgentRunState(rawValue: $0.state) }
let state =
matchingRuns.last.flatMap { AgentRunState(rawValue: $0.state) }
?? .completed
let cards = invocations.filter { runIDs.contains($0.runID) }
.sorted { $0.createdAt < $1.createdAt }
@@ -545,16 +564,18 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
guard updates.count == ids.count else {
throw AgentConversationPersistenceError.conversationNotFound
}
let rows = try context.fetch(FetchDescriptor<PersistentAgentConversation>(
predicate: #Predicate { ids.contains($0.id) }
))
let rows = try context.fetch(
FetchDescriptor<PersistentAgentConversation>(
predicate: #Predicate { ids.contains($0.id) }
))
guard rows.count == ids.count else {
throw AgentConversationPersistenceError.conversationNotFound
}
let updatesByID = Dictionary(uniqueKeysWithValues: updates.map { ($0.id, $0) })
let orderRows = try context.fetch(FetchDescriptor<PersistentAgentConversationOrder>(
predicate: #Predicate { ids.contains($0.conversationID) }
))
let orderRows = try context.fetch(
FetchDescriptor<PersistentAgentConversationOrder>(
predicate: #Predicate { ids.contains($0.conversationID) }
))
var orderByID = Dictionary(
uniqueKeysWithValues: orderRows.map { ($0.conversationID, $0) }
)
@@ -583,9 +604,10 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
let runs = allRuns.filter { $0.conversationID == id }
.sorted { $0.startedAt < $1.startedAt }
let allMessages = try context.fetch(FetchDescriptor<PersistentAgentMessage>())
guard let target = allMessages.first(where: { message in
message.id == messageID && runs.contains { $0.id == message.runID }
}),
guard
let target = allMessages.first(where: { message in
message.id == messageID && runs.contains { $0.id == message.runID }
}),
let targetIndex = runs.firstIndex(where: { $0.id == target.runID })
else { throw AgentConversationPersistenceError.messageNotFound }
@@ -601,8 +623,8 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
do {
try deleteGraph(runIDs: removedRunIDs, runs: removedRuns)
for row in try context.fetch(FetchDescriptor<PersistentAgentCompaction>())
where row.conversationID == id
&& row.compactedThroughMessageID.map(removedMessageIDs.contains) == true
where row.conversationID == id
&& row.compactedThroughMessageID.map(removedMessageIDs.contains) == true
{
context.delete(row)
}
@@ -613,9 +635,10 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
startedAt: replacement.createdAt, finishedAt: replacement.createdAt
)
context.insert(PersistentAgentRun(replacementRun))
context.insert(PersistentAgentMessage(
replacement, runID: replacementRun.id, sequence: 0
))
context.insert(
PersistentAgentMessage(
replacement, runID: replacementRun.id, sequence: 0
))
insertContextAttachments(for: replacement)
let descriptor = FetchDescriptor<PersistentAgentConversation>(
@@ -657,35 +680,42 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
try context.commit {
context.insert(conversation)
if let sortIndex = metadata.sortIndex {
context.insert(PersistentAgentConversationOrder(
conversationID: value.id, sortIndex: sortIndex
))
context.insert(
PersistentAgentConversationOrder(
conversationID: value.id, sortIndex: sortIndex
))
}
let summaries = value.runs.isEmpty
? [AgentRunSummary(
id: UUID(), state: value.lastRunState,
startedAt: metadata.createdAt, finishedAt: metadata.updatedAt
)]
let summaries =
value.runs.isEmpty
? [
AgentRunSummary(
id: UUID(), state: value.lastRunState,
startedAt: metadata.createdAt, finishedAt: metadata.updatedAt
)
]
: value.runs.sorted { $0.startedAt < $1.startedAt }
for summary in summaries {
context.insert(PersistentAgentRun(AgentRunSnapshot(
id: summary.id, conversationID: value.id, state: summary.state,
permissionMode: .askForApproval, messages: [],
startedAt: summary.startedAt, finishedAt: summary.finishedAt
)))
context.insert(
PersistentAgentRun(
AgentRunSnapshot(
id: summary.id, conversationID: value.id, state: summary.state,
permissionMode: .askForApproval, messages: [],
startedAt: summary.startedAt, finishedAt: summary.finishedAt
)))
}
let validRunIDs = Set(summaries.map(\.id))
let fallbackRunID = summaries[0].id
for (sequence, message) in value.messages.enumerated() {
guard !message.isCompaction else { continue }
context.insert(PersistentAgentMessage(
message,
runID: value.messageRunIDs[message.id].flatMap {
validRunIDs.contains($0) ? $0 : nil
} ?? fallbackRunID,
sequence: sequence
))
context.insert(
PersistentAgentMessage(
message,
runID: value.messageRunIDs[message.id].flatMap {
validRunIDs.contains($0) ? $0 : nil
} ?? fallbackRunID,
sequence: sequence
))
insertContextAttachments(for: message)
}
for record in value.compactions {
@@ -701,9 +731,10 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
context.insert(PersistentAgentToolResult(result, invocation: invocation))
}
if let review = card.review {
context.insert(PersistentAgentReviewRecord(
runID: invocation.runID, invocationID: invocation.id, decision: review
))
context.insert(
PersistentAgentReviewRecord(
runID: invocation.runID, invocationID: invocation.id, decision: review
))
}
}
}
@@ -724,31 +755,32 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
let orderRows = try context.fetch(FetchDescriptor<PersistentAgentConversationOrder>())
for row in orderRows where row.conversationID == id { context.delete(row) }
for row in try context.fetch(FetchDescriptor<PersistentAgentCompaction>())
where row.conversationID == id { context.delete(row) }
where row.conversationID == id { context.delete(row) }
try context.save()
}
private func deleteGraph(runIDs: Set<UUID>, runs: [PersistentAgentRun]) throws {
for row in try context.fetch(FetchDescriptor<PersistentAgentToolResult>())
where runIDs.contains(row.runID) { context.delete(row) }
where runIDs.contains(row.runID) { context.delete(row) }
for row in try context.fetch(FetchDescriptor<PersistentAgentReviewRecord>())
where runIDs.contains(row.runID) { context.delete(row) }
where runIDs.contains(row.runID) { context.delete(row) }
for row in try context.fetch(FetchDescriptor<PersistentAgentToolInvocation>())
where runIDs.contains(row.runID) { context.delete(row) }
where runIDs.contains(row.runID) { context.delete(row) }
let messages = try context.fetch(FetchDescriptor<PersistentAgentMessage>())
.filter { runIDs.contains($0.runID) }
let messageIDs = Set(messages.map(\.id))
for row in try context.fetch(FetchDescriptor<PersistentAgentContextAttachments>())
where messageIDs.contains(row.messageID) { context.delete(row) }
where messageIDs.contains(row.messageID) { context.delete(row) }
for row in messages { context.delete(row) }
for row in runs { context.delete(row) }
}
private func insertContextAttachments(for message: AgentTranscriptMessage) {
guard !message.contextAttachments.isEmpty else { return }
context.insert(PersistentAgentContextAttachments(
messageID: message.id, attachments: message.contextAttachments
))
context.insert(
PersistentAgentContextAttachments(
messageID: message.id, attachments: message.contextAttachments
))
}
/// Puts back tool results that were recorded but never made it into the
@@ -795,10 +827,11 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
// The last recorded one: a call id is unique to its invocation,
// so more than one row can only mean a rewritten result.
guard let stored = results.last(where: { $0.callID == call.id }) else { continue }
recovered.append(AgentTranscriptMessage(
role: .tool, text: stored.content, toolCallID: call.id,
toolName: names[call.id] ?? call.name, isError: stored.isError
))
recovered.append(
AgentTranscriptMessage(
role: .tool, text: stored.content, toolCallID: call.id,
toolName: names[call.id] ?? call.name, isError: stored.isError
))
}
}
return recovered
@@ -2,143 +2,143 @@ import Foundation
import SwiftData
enum TermindSchemaV7HostModels {
@Model
final class PersistentHost {
@Attribute(.unique) var id: UUID = UUID()
var name = ""
var address = ""
var port = 22
var identityID: UUID?
var jumpHostID: UUID?
var proxyProtocol: String?
var proxyHostname = ""
var proxyPort = 0
var proxyUsername = ""
var proxyIdentityID: UUID?
var sftpSessionInitialPath = ""
var groupID: UUID?
var tags: [String] = []
var colorTag = TagColor.none.rawValue
/// Legacy V1–V5 storage retained only so existing stores can migrate.
/// Recents and last-connected presentation are derived from connection
/// history; this column is never read or written by current code.
var lastUsedAt: Date?
var sortIndex = 0.0
@Model
final class PersistentHost {
@Attribute(.unique) var id: UUID = UUID()
var name = ""
var address = ""
var port = 22
var identityID: UUID?
var jumpHostID: UUID?
var proxyProtocol: String?
var proxyHostname = ""
var proxyPort = 0
var proxyUsername = ""
var proxyIdentityID: UUID?
var sftpSessionInitialPath = ""
var groupID: UUID?
var tags: [String] = []
var colorTag = TagColor.none.rawValue
/// Legacy V1–V5 storage retained only so existing stores can migrate.
/// Recents and last-connected presentation are derived from connection
/// history; this column is never read or written by current code.
var lastUsedAt: Date?
var sortIndex = 0.0
init(_ value: Host) {
id = value.id
apply(value)
}
func apply(_ value: Host) {
name = value.name
address = value.host
port = value.port
identityID = value.identityID
jumpHostID = value.jumpHostID
proxyProtocol = value.proxy?.kind.rawValue
proxyHostname = value.proxy?.hostname ?? ""
proxyPort = value.proxy?.port ?? 0
proxyUsername = value.proxy?.username ?? ""
proxyIdentityID = value.proxy?.identityID
sftpSessionInitialPath = value.sftpSessionInitialPath
groupID = value.groupID
tags = value.tags
colorTag = value.colorTag.rawValue
sortIndex = value.sortIndex
}
var value: Host {
var host = Host(
id: id, name: name, host: address, port: port,
identityID: identityID, groupID: groupID, tags: tags,
colorTag: TagColor(rawValue: colorTag) ?? .none,
sortIndex: sortIndex
)
host.jumpHostID = jumpHostID
host.sftpSessionInitialPath = sftpSessionInitialPath
if let proxyProtocol, let kind = HostProxy.ProtocolKind(rawValue: proxyProtocol) {
host.proxy = HostProxy(
kind: kind, hostname: proxyHostname, port: proxyPort,
username: proxyUsername, identityID: proxyIdentityID
)
init(_ value: Host) {
id = value.id
apply(value)
}
func apply(_ value: Host) {
name = value.name
address = value.host
port = value.port
identityID = value.identityID
jumpHostID = value.jumpHostID
proxyProtocol = value.proxy?.kind.rawValue
proxyHostname = value.proxy?.hostname ?? ""
proxyPort = value.proxy?.port ?? 0
proxyUsername = value.proxy?.username ?? ""
proxyIdentityID = value.proxy?.identityID
sftpSessionInitialPath = value.sftpSessionInitialPath
groupID = value.groupID
tags = value.tags
colorTag = value.colorTag.rawValue
sortIndex = value.sortIndex
}
var value: Host {
var host = Host(
id: id, name: name, host: address, port: port,
identityID: identityID, groupID: groupID, tags: tags,
colorTag: TagColor(rawValue: colorTag) ?? .none,
sortIndex: sortIndex
)
host.jumpHostID = jumpHostID
host.sftpSessionInitialPath = sftpSessionInitialPath
if let proxyProtocol, let kind = HostProxy.ProtocolKind(rawValue: proxyProtocol) {
host.proxy = HostProxy(
kind: kind, hostname: proxyHostname, port: proxyPort,
username: proxyUsername, identityID: proxyIdentityID
)
}
return host
}
return host
}
}
}
enum TermindSchemaV8Models {
@Model
final class PersistentHost {
@Attribute(.unique) var id: UUID = UUID()
var name = ""
var address = ""
var port = 22
var identityID: UUID?
var jumpHostID: UUID?
var proxyProtocol: String?
var proxyHostname = ""
var proxyPort = 0
var proxyUsername = ""
var proxyIdentityID: UUID?
var sftpSessionInitialPath = ""
var groupID: UUID?
var tags: [String] = []
var colorTag = TagColor.none.rawValue
var detectedDistributionID: String?
var detectedDistributionLikeIDs: [String] = []
/// Legacy storage retained for the existing V5–V6 migration contract.
var lastUsedAt: Date?
var sortIndex = 0.0
@Model
final class PersistentHost {
@Attribute(.unique) var id: UUID = UUID()
var name = ""
var address = ""
var port = 22
var identityID: UUID?
var jumpHostID: UUID?
var proxyProtocol: String?
var proxyHostname = ""
var proxyPort = 0
var proxyUsername = ""
var proxyIdentityID: UUID?
var sftpSessionInitialPath = ""
var groupID: UUID?
var tags: [String] = []
var colorTag = TagColor.none.rawValue
var detectedDistributionID: String?
var detectedDistributionLikeIDs: [String] = []
/// Legacy storage retained for the existing V5–V6 migration contract.
var lastUsedAt: Date?
var sortIndex = 0.0
init(_ value: Host) {
id = value.id
apply(value)
}
func apply(_ value: Host) {
name = value.name
address = value.host
port = value.port
identityID = value.identityID
jumpHostID = value.jumpHostID
proxyProtocol = value.proxy?.kind.rawValue
proxyHostname = value.proxy?.hostname ?? ""
proxyPort = value.proxy?.port ?? 0
proxyUsername = value.proxy?.username ?? ""
proxyIdentityID = value.proxy?.identityID
sftpSessionInitialPath = value.sftpSessionInitialPath
groupID = value.groupID
tags = value.tags
colorTag = value.colorTag.rawValue
detectedDistributionID = value.detectedDistribution?.id
detectedDistributionLikeIDs = value.detectedDistribution?.likeIDs ?? []
sortIndex = value.sortIndex
}
var value: Host {
var host = Host(
id: id, name: name, host: address, port: port,
identityID: identityID, groupID: groupID, tags: tags,
colorTag: TagColor(rawValue: colorTag) ?? .none,
detectedDistribution: detectedDistributionID.flatMap {
DetectedDistribution(id: $0, likeIDs: detectedDistributionLikeIDs)
},
sortIndex: sortIndex
)
host.jumpHostID = jumpHostID
host.sftpSessionInitialPath = sftpSessionInitialPath
if let proxyProtocol, let kind = HostProxy.ProtocolKind(rawValue: proxyProtocol) {
host.proxy = HostProxy(
kind: kind, hostname: proxyHostname, port: proxyPort,
username: proxyUsername, identityID: proxyIdentityID
)
init(_ value: Host) {
id = value.id
apply(value)
}
func apply(_ value: Host) {
name = value.name
address = value.host
port = value.port
identityID = value.identityID
jumpHostID = value.jumpHostID
proxyProtocol = value.proxy?.kind.rawValue
proxyHostname = value.proxy?.hostname ?? ""
proxyPort = value.proxy?.port ?? 0
proxyUsername = value.proxy?.username ?? ""
proxyIdentityID = value.proxy?.identityID
sftpSessionInitialPath = value.sftpSessionInitialPath
groupID = value.groupID
tags = value.tags
colorTag = value.colorTag.rawValue
detectedDistributionID = value.detectedDistribution?.id
detectedDistributionLikeIDs = value.detectedDistribution?.likeIDs ?? []
sortIndex = value.sortIndex
}
var value: Host {
var host = Host(
id: id, name: name, host: address, port: port,
identityID: identityID, groupID: groupID, tags: tags,
colorTag: TagColor(rawValue: colorTag) ?? .none,
detectedDistribution: detectedDistributionID.flatMap {
DetectedDistribution(id: $0, likeIDs: detectedDistributionLikeIDs)
},
sortIndex: sortIndex
)
host.jumpHostID = jumpHostID
host.sftpSessionInitialPath = sftpSessionInitialPath
if let proxyProtocol, let kind = HostProxy.ProtocolKind(rawValue: proxyProtocol) {
host.proxy = HostProxy(
kind: kind, hostname: proxyHostname, port: proxyPort,
username: proxyUsername, identityID: proxyIdentityID
)
}
return host
}
return host
}
}
}
typealias PersistentHost = TermindSchemaV8Models.PersistentHost
@@ -289,97 +289,97 @@ enum TermindSchemaV6Models {
}
enum TermindSchemaV7Models {
@Model
final class PersistentIdentity {
@Attribute(.unique) var id: UUID = UUID()
var name = ""
var username = ""
var credentialRef = ""
var passwordStorage = PasswordStorage.iCloud.rawValue
var keyID: UUID?
var notes = ""
var lastUsedAt: Date?
var colorTag = TagColor.none.rawValue
var sortIndex = 0.0
@Model
final class PersistentIdentity {
@Attribute(.unique) var id: UUID = UUID()
var name = ""
var username = ""
var credentialRef = ""
var passwordStorage = PasswordStorage.iCloud.rawValue
var keyID: UUID?
var notes = ""
var lastUsedAt: Date?
var colorTag = TagColor.none.rawValue
var sortIndex = 0.0
init(_ value: Identity) {
id = value.id
apply(value)
init(_ value: Identity) {
id = value.id
apply(value)
}
func apply(_ value: Identity) {
name = value.name
username = value.username
credentialRef = value.credentialRef
passwordStorage = value.passwordStorage.rawValue
keyID = value.keyID
notes = value.notes
lastUsedAt = value.lastUsedAt
colorTag = value.colorTag.rawValue
sortIndex = value.sortIndex
}
var value: Identity {
Identity(
id: id, name: name, username: username, credentialRef: credentialRef,
passwordStorage: PasswordStorage(rawValue: passwordStorage) ?? .iCloud,
keyID: keyID, notes: notes, lastUsedAt: lastUsedAt,
colorTag: TagColor(rawValue: colorTag) ?? .none, sortIndex: sortIndex
)
}
}
func apply(_ value: Identity) {
name = value.name
username = value.username
credentialRef = value.credentialRef
passwordStorage = value.passwordStorage.rawValue
keyID = value.keyID
notes = value.notes
lastUsedAt = value.lastUsedAt
colorTag = value.colorTag.rawValue
sortIndex = value.sortIndex
}
@Model
final class PersistentSSHKey {
@Attribute(.unique) var id: UUID = UUID()
var label = ""
var algorithm = SSHKeyAlgorithm.ed25519.rawValue
var publicKey = ""
var certificate = ""
var fingerprint = ""
var credentialRef = ""
var storage = KeyStorage.iCloud.rawValue
var keySize: Int?
var hasPassphrase = false
var requiresUserPresence = false
var createdAt = Date(timeIntervalSince1970: 0)
var colorTag = TagColor.none.rawValue
var sortIndex = 0.0
var value: Identity {
Identity(
id: id, name: name, username: username, credentialRef: credentialRef,
passwordStorage: PasswordStorage(rawValue: passwordStorage) ?? .iCloud,
keyID: keyID, notes: notes, lastUsedAt: lastUsedAt,
colorTag: TagColor(rawValue: colorTag) ?? .none, sortIndex: sortIndex
)
}
}
init(_ value: SSHKeyRef) {
id = value.id
apply(value)
}
@Model
final class PersistentSSHKey {
@Attribute(.unique) var id: UUID = UUID()
var label = ""
var algorithm = SSHKeyAlgorithm.ed25519.rawValue
var publicKey = ""
var certificate = ""
var fingerprint = ""
var credentialRef = ""
var storage = KeyStorage.iCloud.rawValue
var keySize: Int?
var hasPassphrase = false
var requiresUserPresence = false
var createdAt = Date(timeIntervalSince1970: 0)
var colorTag = TagColor.none.rawValue
var sortIndex = 0.0
func apply(_ value: SSHKeyRef) {
label = value.label
algorithm = value.algorithm.rawValue
publicKey = value.publicKey
certificate = value.certificate
fingerprint = value.fingerprint
credentialRef = value.credentialRef
storage = value.storage.rawValue
keySize = value.keySize
hasPassphrase = value.hasPassphrase
requiresUserPresence = value.requiresUserPresence
createdAt = value.createdAt
colorTag = value.colorTag.rawValue
sortIndex = value.sortIndex
}
init(_ value: SSHKeyRef) {
id = value.id
apply(value)
var value: SSHKeyRef {
SSHKeyRef(
id: id, label: label,
algorithm: SSHKeyAlgorithm(rawValue: algorithm) ?? .ed25519,
publicKey: publicKey, certificate: certificate, fingerprint: fingerprint,
credentialRef: credentialRef,
storage: KeyStorage(rawValue: storage) ?? .iCloud,
keySize: keySize, hasPassphrase: hasPassphrase,
requiresUserPresence: requiresUserPresence, createdAt: createdAt,
colorTag: TagColor(rawValue: colorTag) ?? .none, sortIndex: sortIndex
)
}
}
func apply(_ value: SSHKeyRef) {
label = value.label
algorithm = value.algorithm.rawValue
publicKey = value.publicKey
certificate = value.certificate
fingerprint = value.fingerprint
credentialRef = value.credentialRef
storage = value.storage.rawValue
keySize = value.keySize
hasPassphrase = value.hasPassphrase
requiresUserPresence = value.requiresUserPresence
createdAt = value.createdAt
colorTag = value.colorTag.rawValue
sortIndex = value.sortIndex
}
var value: SSHKeyRef {
SSHKeyRef(
id: id, label: label,
algorithm: SSHKeyAlgorithm(rawValue: algorithm) ?? .ed25519,
publicKey: publicKey, certificate: certificate, fingerprint: fingerprint,
credentialRef: credentialRef,
storage: KeyStorage(rawValue: storage) ?? .iCloud,
keySize: keySize, hasPassphrase: hasPassphrase,
requiresUserPresence: requiresUserPresence, createdAt: createdAt,
colorTag: TagColor(rawValue: colorTag) ?? .none, sortIndex: sortIndex
)
}
}
}
typealias PersistentIdentity = TermindSchemaV7Models.PersistentIdentity
@@ -758,14 +758,12 @@ final class HostPersistence {
var byID: [UUID: Model] = [:]
for row in rows {
let id = rowID(row)
if byID[id] == nil { byID[id] = row }
else { context.delete(row) }
if byID[id] == nil { byID[id] = row } else { context.delete(row) }
}
let wanted = Set(values.map(valueID))
for row in byID.values where !wanted.contains(rowID(row)) { context.delete(row) }
for value in values {
if let row = byID[valueID(value)] { apply(row, value) }
else { context.insert(make(value)) }
if let row = byID[valueID(value)] { apply(row, value) } else { context.insert(make(value)) }
}
}
}
@@ -30,9 +30,11 @@ final class SwiftDataConnectionHistoryRepository: ConnectionHistoryRepository {
init(context: ModelContext) { self.context = context }
func entries() throws -> [ConnectionHistoryEntry] {
try context.fetch(FetchDescriptor<PersistentConnectionHistoryEntry>(
sortBy: [SortDescriptor(\.connectedAt, order: .reverse)]
)).map(\.value)
try context.fetch(
FetchDescriptor<PersistentConnectionHistoryEntry>(
sortBy: [SortDescriptor(\.connectedAt, order: .reverse)]
)
).map(\.value)
}
func insert(_ entry: ConnectionHistoryEntry, limit: Int) throws {
@@ -4,8 +4,7 @@ import SwiftData
@MainActor
final class SwiftDataHostRepository: HostRepository {
private let context: ModelContext
private var continuations: [UUID: AsyncStream<RepositoryChange<HostLibrarySnapshot>>.Continuation]
= [:]
private var continuations: [UUID: AsyncStream<RepositoryChange<HostLibrarySnapshot>>.Continuation] = [:]
var didRecordVaultChanges: ((VaultMutationSet) -> Void)?
init(context: ModelContext) {
@@ -32,19 +32,22 @@ final class SwiftDataSFTPHistoryRepository: SFTPHistoryRepository {
init(context: ModelContext) { self.context = context }
func entries(for hostID: Host.ID) throws -> [SFTPHistoryEntry] {
try context.fetch(FetchDescriptor<PersistentSFTPHistoryEntry>(
predicate: #Predicate { $0.hostID == hostID },
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
)).map(\.value)
try context.fetch(
FetchDescriptor<PersistentSFTPHistoryEntry>(
predicate: #Predicate { $0.hostID == hostID },
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
)
).map(\.value)
}
func upsert(_ entry: SFTPHistoryEntry, limit: Int) throws {
let hostID = entry.hostID
try context.commit {
let rows = try context.fetch(FetchDescriptor<PersistentSFTPHistoryEntry>(
predicate: #Predicate { $0.hostID == hostID },
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
))
let rows = try context.fetch(
FetchDescriptor<PersistentSFTPHistoryEntry>(
predicate: #Predicate { $0.hostID == hostID },
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
))
if let row = rows.first(where: {
$0.kind == entry.kind.rawValue && $0.path == entry.path
}) {
@@ -55,9 +55,11 @@ final class SwiftDataToolAuditRepository: ToolAuditRepository {
init(context: ModelContext) { self.context = context }
func entries() throws -> [ExternalToolCall] {
try context.fetch(FetchDescriptor<PersistentExternalToolCall>(
sortBy: [SortDescriptor(\.startedAt, order: .reverse)]
)).map(\.value)
try context.fetch(
FetchDescriptor<PersistentExternalToolCall>(
sortBy: [SortDescriptor(\.startedAt, order: .reverse)]
)
).map(\.value)
}
func clear() throws {
@@ -97,9 +99,10 @@ final class SwiftDataToolAuditRepository: ToolAuditRepository {
func recoverInterruptedCalls() throws {
let running = ExternalToolCall.Status.running.rawValue
try context.commit {
let rows = try context.fetch(FetchDescriptor<PersistentExternalToolCall>(
predicate: #Predicate { $0.status == running }
))
let rows = try context.fetch(
FetchDescriptor<PersistentExternalToolCall>(
predicate: #Predicate { $0.status == running }
))
for row in rows {
row.status = ExternalToolCall.Status.error.rawValue
row.finishedAt = .now
@@ -38,7 +38,8 @@ actor MCPServerProtocol {
case ("POST", "/mcp"):
return await streamablePOST(request)
case ("GET", "/mcp"):
return HTTPResponse.text(405, "This server does not expose a standalone MCP SSE stream.", headers: ["Allow": "POST, DELETE"])
return HTTPResponse.text(
405, "This server does not expose a standalone MCP SSE stream.", headers: ["Allow": "POST, DELETE"])
case ("DELETE", "/mcp"):
return await deleteSession(request)
case ("GET", "/sse"):
@@ -68,8 +69,7 @@ actor MCPServerProtocol {
return .text(415, "MCP requests must use application/json.")
}
let message: RPCMessage
do { message = try RPCMessage(request.body) }
catch {
do { message = try RPCMessage(request.body) } catch {
return Self.jsonResponse(.error(id: .null, code: -32700, message: "Parse error"), status: 400)
}
@@ -82,7 +82,8 @@ actor MCPServerProtocol {
sessionCountDidChange()
let response = await dispatch(message, sessionID: session.id)
return Self.jsonResponse(
response ?? .error(id: message.id ?? .null, code: -32603, message: "Initialize did not produce a response"),
response
?? .error(id: message.id ?? .null, code: -32603, message: "Initialize did not produce a response"),
headers: ["Mcp-Session-Id": session.id]
)
}
@@ -152,12 +153,12 @@ actor MCPServerProtocol {
let continuation = session.legacyContinuation
else { return .text(404, "The legacy MCP session is unknown or expired.") }
let message: RPCMessage
do { message = try RPCMessage(request.body) }
catch {
continuation.yield(Self.sse(
event: "message",
data: RPCResponse.error(id: .null, code: -32700, message: "Parse error").data
))
do { message = try RPCMessage(request.body) } catch {
continuation.yield(
Self.sse(
event: "message",
data: RPCResponse.error(id: .null, code: -32700, message: "Parse error").data
))
return HTTPResponse(status: 202)
}
if message.method == "initialize" {
@@ -180,29 +181,38 @@ actor MCPServerProtocol {
switch message.method {
case "initialize":
guard !session.initialized else {
response = isNotification ? nil : .error(
id: message.id ?? .null, code: -32600,
message: "initialize may only be sent once"
)
response =
isNotification
? nil
: .error(
id: message.id ?? .null, code: -32600,
message: "initialize may only be sent once"
)
break
}
if let clientName = message.clientName { session.clientName = clientName }
let requested = message.params?["protocolVersion"]?.stringValue
session.protocolVersion = session.isLegacy
session.protocolVersion =
session.isLegacy
? MCPClient.legacyVersion
: Self.negotiatedStreamableVersion(requested)
response = isNotification ? nil : .success(id: message.id ?? .null, result: .object([
"protocolVersion": .string(session.protocolVersion),
"capabilities": .object(["tools": .object([:])]),
"serverInfo": .object([
"name": .string("termind"),
"title": .string("Termind MCP Server"),
"version": .string(Bundle.termindDisplayVersion),
]),
"instructions": .string(
"Use list_hosts to discover host_id values. SSH and SFTP calls use Termind's saved Vault credentials and may show an authentication window on this Mac. For secret command input, call request_user_secret, then pass its secret_handle to ssh_exec with stdin_purpose exactly matching the original purpose. The handle is bound to this session, host, tool, and purpose, and can be used only once."
),
]))
response =
isNotification
? nil
: .success(
id: message.id ?? .null,
result: .object([
"protocolVersion": .string(session.protocolVersion),
"capabilities": .object(["tools": .object([:])]),
"serverInfo": .object([
"name": .string("termind"),
"title": .string("Termind MCP Server"),
"version": .string(Bundle.termindDisplayVersion),
]),
"instructions": .string(
"Use list_hosts to discover host_id values. SSH and SFTP calls use Termind's saved Vault credentials and may show an authentication window on this Mac. For secret command input, call request_user_secret, then pass its secret_handle to ssh_exec with stdin_purpose exactly matching the original purpose. The handle is bound to this session, host, tool, and purpose, and can be used only once."
),
]))
case "notifications/initialized":
session.initialized = true
response = nil
@@ -210,30 +220,42 @@ actor MCPServerProtocol {
response = isNotification ? nil : .success(id: message.id ?? .null, result: .object([:]))
case "tools/list":
guard session.initialized else {
response = isNotification ? nil : .error(
id: message.id ?? .null, code: -32002, message: "Server not initialized"
)
response =
isNotification
? nil
: .error(
id: message.id ?? .null, code: -32002, message: "Server not initialized"
)
break
}
let descriptors = await tools.descriptors
response = isNotification ? nil : .success(
id: message.id ?? .null,
result: .object(["tools": .array(descriptors.map(Self.toolJSON))])
)
response =
isNotification
? nil
: .success(
id: message.id ?? .null,
result: .object(["tools": .array(descriptors.map(Self.toolJSON))])
)
case "tools/call":
guard session.initialized else {
response = isNotification ? nil : .error(
id: message.id ?? .null, code: -32002, message: "Server not initialized"
)
response =
isNotification
? nil
: .error(
id: message.id ?? .null, code: -32002, message: "Server not initialized"
)
break
}
guard let params = message.params,
let name = params["name"]?.stringValue
else {
response = isNotification ? nil : .error(
id: message.id ?? .null, code: -32602,
message: "tools/call requires an enabled tool name and object arguments"
)
response =
isNotification
? nil
: .error(
id: message.id ?? .null, code: -32602,
message: "tools/call requires an enabled tool name and object arguments"
)
break
}
let arguments = params["arguments"] ?? .object([:])
@@ -241,10 +263,13 @@ actor MCPServerProtocol {
arguments.objectValue != nil,
await tools.contains(name)
else {
response = isNotification ? nil : .error(
id: message.id ?? .null, code: -32602,
message: "tools/call requires an enabled tool name and object arguments"
)
response =
isNotification
? nil
: .error(
id: message.id ?? .null, code: -32602,
message: "tools/call requires an enabled tool name and object arguments"
)
break
}
do {
@@ -254,19 +279,28 @@ actor MCPServerProtocol {
callID: message.id?.encodedString ?? UUID().uuidString,
secretBroker: session.secretBroker
)
response = isNotification ? nil : .success(
id: message.id ?? .null, result: Self.toolResultJSON(result)
)
response =
isNotification
? nil
: .success(
id: message.id ?? .null, result: Self.toolResultJSON(result)
)
} catch {
response = isNotification ? nil : .error(
id: message.id ?? .null, code: -32602, message: error.localizedDescription
)
response =
isNotification
? nil
: .error(
id: message.id ?? .null, code: -32602, message: error.localizedDescription
)
}
default:
response = isNotification ? nil : .error(
id: message.id ?? .null, code: -32601,
message: "Method not found: \(message.method)"
)
response =
isNotification
? nil
: .error(
id: message.id ?? .null, code: -32601,
message: "Method not found: \(message.method)"
)
}
if sessions[sessionID] != nil {
session.lastUsedAt = .now
@@ -281,7 +315,8 @@ actor MCPServerProtocol {
private func makeSession(message: RPCMessage, legacy: Bool) -> MCPServerSession {
let id = UUID().uuidString
return MCPServerSession(
id: id, runID: UUID(), secretBroker: SecretBroker(), protocolVersion: legacy
id: id, runID: UUID(), secretBroker: SecretBroker(),
protocolVersion: legacy
? MCPClient.legacyVersion : MCPClient.preferredVersion,
clientName: message.clientName ?? "MCP client", isLegacy: legacy
)
@@ -351,13 +386,16 @@ actor MCPServerProtocol {
private static func toolResultJSON(_ result: AgentToolResult) -> AgentJSONValue {
let text = result.untrustedPayload ?? result.content
var value: [String: AgentJSONValue] = [
"content": .array([.object([
"type": .string("text"), "text": .string(text),
])]),
"content": .array([
.object([
"type": .string("text"), "text": .string(text),
])
]),
"isError": .bool(result.isError),
]
if let parsed = try? AgentJSONValue.decode(Data(text.utf8)) {
value["structuredContent"] = parsed.objectValue == nil
value["structuredContent"] =
parsed.objectValue == nil
? .object(["value": parsed]) : parsed
}
if result.isTruncated {
@@ -415,11 +453,12 @@ private nonisolated struct RPCResponse: Sendable {
}
static func error(id: AgentJSONValue, code: Int, message: String) -> Self {
Self(value: .object([
"jsonrpc": .string("2.0"), "id": id,
"error": .object([
"code": .number(Double(code)), "message": .string(message),
]),
]))
Self(
value: .object([
"jsonrpc": .string("2.0"), "id": id,
"error": .object([
"code": .number(Double(code)), "message": .string(message),
]),
]))
}
}
@@ -196,7 +196,8 @@ actor LoopbackHTTPServer {
do { try await Self.send(response, on: connection) } catch { return }
if case .stream = response.body { return }
let connectionHeader = request.header("connection")?.lowercased()
let keepAlive = request.version == "HTTP/1.1"
let keepAlive =
request.version == "HTTP/1.1"
? connectionHeader != "close" : connectionHeader == "keep-alive"
if !keepAlive { return }
}
@@ -215,7 +216,8 @@ actor LoopbackHTTPServer {
let count = max(a.count, b.count)
var difference = a.count ^ b.count
for index in 0..<count {
difference |= Int(index < a.count ? a[index] : 0)
difference |=
Int(index < a.count ? a[index] : 0)
^ Int(index < b.count ? b[index] : 0)
}
return difference == 0
@@ -229,7 +231,8 @@ actor LoopbackHTTPServer {
components.path.isEmpty, components.query == nil, components.fragment == nil,
let rawHost = components.host?.lowercased()
else { return false }
let host = rawHost.hasPrefix("[") && rawHost.hasSuffix("]")
let host =
rawHost.hasPrefix("[") && rawHost.hasSuffix("]")
? String(rawHost.dropFirst().dropLast()) : rawHost
if host == "localhost" || host == "::1" { return true }
let octets = host.split(separator: ".").compactMap { UInt8($0) }
@@ -285,20 +288,27 @@ actor LoopbackHTTPServer {
while remainder.count < length { remainder.append(try await receive(connection)) }
let body = Data(remainder.prefix(length))
remainder.removeFirst(length)
return (HTTPRequest(
method: String(requestLine[0]), target: String(requestLine[1]),
version: String(requestLine[2]), headers: headers, body: body
), remainder)
return (
HTTPRequest(
method: String(requestLine[0]), target: String(requestLine[1]),
version: String(requestLine[2]), headers: headers, body: body
), remainder
)
}
private static func receive(_ connection: NWConnection) async throws -> Data {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Data, any Error>) in
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1_024) {
data, _, complete, error in
if let error { continuation.resume(throwing: error) }
else if let data, !data.isEmpty { continuation.resume(returning: data) }
else if complete { continuation.resume(throwing: LoopbackHTTPServerError.endOfStream) }
else { continuation.resume(throwing: LoopbackHTTPServerError.malformed) }
if let error {
continuation.resume(throwing: error)
} else if let data, !data.isEmpty {
continuation.resume(returning: data)
} else if complete {
continuation.resume(throwing: LoopbackHTTPServerError.endOfStream)
} else {
continuation.resume(throwing: LoopbackHTTPServerError.malformed)
}
}
}
}
@@ -333,10 +343,11 @@ actor LoopbackHTTPServer {
private static func send(_ data: Data, on connection: NWConnection) async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
connection.send(content: data, completion: .contentProcessed { error in
if let error { continuation.resume(throwing: error) }
else { continuation.resume() }
})
connection.send(
content: data,
completion: .contentProcessed { error in
if let error { continuation.resume(throwing: error) } else { continuation.resume() }
})
}
}
@@ -40,4 +40,3 @@ extension SkillLibraryModel {
)
}
}
@@ -6,15 +6,17 @@ extension DispatchQueue {
) async throws -> T {
try await withCheckedThrowingContinuation { continuation in
async {
do { continuation.resume(returning: try operation()) }
catch { continuation.resume(throwing: error) }
do { continuation.resume(returning: try operation()) } catch { continuation.resume(throwing: error) }
}
}
}
func perform(_ operation: @escaping @Sendable () -> Void) async {
await withCheckedContinuation { continuation in
async { operation(); continuation.resume() }
async {
operation()
continuation.resume()
}
}
}
}
@@ -14,7 +14,8 @@ nonisolated enum PortForwardingRuntimeState: Hashable, Sendable {
var isActive: Bool {
switch self {
case .preparing, .awaitingTrust, .awaitingAuthentication, .connecting,
.starting, .running, .reconnecting: true
.starting, .running, .reconnecting:
true
case .stopped, .failed: false
}
}
@@ -87,9 +88,9 @@ final class PortForwardingController {
for id in all { states[id] = .stopped }
let active = sessions
sessions.removeAll()
connectionTasks.values.forEach { $0.cancel() }
for task in connectionTasks.values { task.cancel() }
connectionTasks.removeAll()
reconnectTasks.values.forEach { $0.cancel() }
for task in reconnectTasks.values { task.cancel() }
reconnectTasks.removeAll()
reconnectAttempts.removeAll()
connectedAt.removeAll()
@@ -117,8 +118,7 @@ final class PortForwardingController {
try await created.connect(configuration)
}
connectionTasks[host.id] = task
do { try await task.value }
catch {
do { try await task.value } catch {
sessions[host.id] = nil
connectionTasks[host.id] = nil
throw error
@@ -130,7 +130,10 @@ final class PortForwardingController {
guard desired.contains(rule.id) else { return }
states[rule.id] = .starting
try await session.start(rule)
guard desired.contains(rule.id) else { await session.stop(rule.id); return }
guard desired.contains(rule.id) else {
await session.stop(rule.id)
return
}
states[rule.id] = .running(activeConnections: 0)
} catch {
if desired.contains(rule.id),
@@ -201,8 +204,7 @@ final class PortForwardingController {
states[id] = .reconnecting(attempt: attempt, nextRetry: nextRetry, message: message)
}
reconnectTasks[hostID] = Task { [weak self] in
do { try await Task.sleep(for: .seconds(delay)) }
catch { return }
do { try await Task.sleep(for: .seconds(delay)) } catch { return }
guard let self else { return }
await self.reconnect(hostID, attempt: attempt)
}
+81 -49
View File
@@ -48,13 +48,14 @@ nonisolated struct RemoteFile: Identifiable, Hashable, Sendable {
}
var permissionDescription: String {
let type: Character = switch kind {
case .file: "-"
case .directory: "d"
case .symbolicLink: "l"
case .special: "s"
case .unknown: "?"
}
let type: Character =
switch kind {
case .file: "-"
case .directory: "d"
case .symbolicLink: "l"
case .special: "s"
case .unknown: "?"
}
let masks: [UInt32] = [0o400, 0o200, 0o100, 0o040, 0o020, 0o010, 0o004, 0o002, 0o001]
let letters: [Character] = ["r", "w", "x", "r", "w", "x", "r", "w", "x"]
return String([type] + zip(masks, letters).map { permissions & $0.0 == 0 ? "-" : $0.1 })
@@ -99,18 +100,20 @@ nonisolated private struct SFTPProgressMeter {
0.001,
Double(components.seconds) + Double(components.attoseconds) / 1e18
)
report(SFTPProgress(
completedBytes: completed, totalBytes: total,
bytesPerSecond: Int64(Double(sampledBytes) / seconds)
))
report(
SFTPProgress(
completedBytes: completed, totalBytes: total,
bytesPerSecond: Int64(Double(sampledBytes) / seconds)
))
sampledBytes = 0
sampledAt = .now
}
nonisolated func finish() {
report(SFTPProgress(
completedBytes: completed, totalBytes: total, bytesPerSecond: 0
))
report(
SFTPProgress(
completedBytes: completed, totalBytes: total, bytesPerSecond: 0
))
}
}
@@ -450,12 +453,14 @@ private nonisolated final class SFTPWorker: @unchecked Sendable {
func listDirectory(at path: String) async throws -> [RemoteFile] {
try await queue.perform {
let sftp = try self.requireSFTP()
guard let handle = path.withCString({
libssh2_sftp_open_ex(
sftp, $0, UInt32(path.utf8.count), 0, 0,
Int32(LIBSSH2_SFTP_OPENDIR)
)
}) else {
guard
let handle = path.withCString({
libssh2_sftp_open_ex(
sftp, $0, UInt32(path.utf8.count), 0, 0,
Int32(LIBSSH2_SFTP_OPENDIR)
)
})
else {
throw self.error("Unable to open \(path)")
}
defer { _ = libssh2_sftp_close_handle(handle) }
@@ -470,7 +475,8 @@ private nonisolated final class SFTPWorker: @unchecked Sendable {
let bytes = name.prefix(Int(count)).map { UInt8(bitPattern: $0) }
let filename = String(decoding: bytes, as: UTF8.self)
guard filename != ".", filename != ".." else { continue }
result.append(Self.remoteFile(path: Self.join(path, filename), name: filename, attributes: attributes))
result.append(
Self.remoteFile(path: Self.join(path, filename), name: filename, attributes: attributes))
} else if count == 0 {
break
} else {
@@ -511,9 +517,13 @@ private nonisolated final class SFTPWorker: @unchecked Sendable {
while data.count <= maximumBytes {
let wanted = min(buffer.count, maximumBytes + 1 - data.count)
let count = libssh2_sftp_read(handle, &buffer, wanted)
if count > 0 { data.append(Data(bytes: buffer, count: count)) }
else if count == 0 { break }
else { throw self.error("Unable to read \(path)") }
if count > 0 {
data.append(Data(bytes: buffer, count: count))
} else if count == 0 {
break
} else {
throw self.error("Unable to read \(path)")
}
}
guard data.count <= maximumBytes else {
throw SFTPError.operation("\(path) exceeds the inline read limit of \(maximumBytes) bytes.")
@@ -604,9 +614,11 @@ private nonisolated final class SFTPWorker: @unchecked Sendable {
func removeFile(at path: String) async throws {
try await queue.perform {
let sftp = try self.requireSFTP()
guard path.withCString({
libssh2_sftp_unlink_ex(sftp, $0, UInt32(path.utf8.count))
}) == 0 else {
guard
path.withCString({
libssh2_sftp_unlink_ex(sftp, $0, UInt32(path.utf8.count))
}) == 0
else {
throw self.error("Unable to delete \(path)")
}
}
@@ -615,9 +627,11 @@ private nonisolated final class SFTPWorker: @unchecked Sendable {
func removeDirectory(at path: String) async throws {
try await queue.perform {
let sftp = try self.requireSFTP()
guard path.withCString({
libssh2_sftp_rmdir_ex(sftp, $0, UInt32(path.utf8.count))
}) == 0 else {
guard
path.withCString({
libssh2_sftp_rmdir_ex(sftp, $0, UInt32(path.utf8.count))
}) == 0
else {
throw self.error("Unable to delete \(path)")
}
}
@@ -631,13 +645,15 @@ private nonisolated final class SFTPWorker: @unchecked Sendable {
try await queue.perform {
let sftp = try self.requireSFTP()
let file: FileHandle
do { file = try FileHandle(forReadingFrom: localURL) }
catch { throw SFTPError.localFile(error.localizedDescription) }
do { file = try FileHandle(forReadingFrom: localURL) } catch {
throw SFTPError.localFile(error.localizedDescription)
}
defer { try? file.close() }
try self.withAtomicRemoteWrite(
sftp: sftp, destination: remotePath, overwrite: overwrite
) { handle in
let total = (try? localURL.resourceValues(forKeys: [.fileSizeKey]).fileSize)
let total =
(try? localURL.resourceValues(forKeys: [.fileSizeKey]).fileSize)
.map(Int64.init) ?? 0
var meter = SFTPProgressMeter(total: total, report: progress)
while true {
@@ -674,7 +690,8 @@ private nonisolated final class SFTPWorker: @unchecked Sendable {
try await queue.perform {
try self.withRemoteReadHandle(at: remotePath) { handle in
let fm = FileManager.default
let temporary = localURL.deletingLastPathComponent().appendingPathComponent(".termind-part-\(UUID().uuidString)")
let temporary = localURL.deletingLastPathComponent().appendingPathComponent(
".termind-part-\(UUID().uuidString)")
guard fm.createFile(atPath: temporary.path, contents: nil) else {
throw SFTPError.localFile("Unable to create \(temporary.path)")
}
@@ -693,8 +710,11 @@ private nonisolated final class SFTPWorker: @unchecked Sendable {
if count > 0 {
try file.write(contentsOf: Data(bytes: buffer, count: count))
meter.add(count)
} else if count == 0 { break }
else { throw self.error("Unable to download \(remotePath)") }
} else if count == 0 {
break
} else {
throw self.error("Unable to download \(remotePath)")
}
}
try file.synchronize()
meter.finish()
@@ -824,12 +844,14 @@ private nonisolated final class SFTPWorker: @unchecked Sendable {
) throws {
let temporary = destination + temporaryTag + UUID().uuidString
let flags = UInt(LIBSSH2_FXF_WRITE | LIBSSH2_FXF_CREAT | LIBSSH2_FXF_TRUNC)
guard let handle = temporary.withCString({
libssh2_sftp_open_ex(
sftp, $0, UInt32(temporary.utf8.count), flags, 0o600,
Int32(LIBSSH2_SFTP_OPENFILE)
)
}) else {
guard
let handle = temporary.withCString({
libssh2_sftp_open_ex(
sftp, $0, UInt32(temporary.utf8.count), flags, 0o600,
Int32(LIBSSH2_SFTP_OPENFILE)
)
})
else {
throw error("Unable to create \(destination)")
}
var succeeded = false
@@ -848,7 +870,8 @@ private nonisolated final class SFTPWorker: @unchecked Sendable {
succeeded = true
}
private func renameOnQueue(sftp: OpaquePointer, from source: String, to destination: String, overwrite: Bool) throws {
private func renameOnQueue(sftp: OpaquePointer, from source: String, to destination: String, overwrite: Bool) throws
{
let flags = overwrite ? Int(LIBSSH2_SFTP_RENAME_OVERWRITE) : 0
let result = source.withCString { sourceBuffer in
destination.withCString { destinationBuffer in
@@ -888,7 +911,10 @@ private nonisolated final class SFTPWorker: @unchecked Sendable {
private func cleanup() {
keepaliveTimer?.cancel()
keepaliveTimer = nil
if let sftp { _ = libssh2_sftp_shutdown(sftp); self.sftp = nil }
if let sftp {
_ = libssh2_sftp_shutdown(sftp)
self.sftp = nil
}
if let session {
_ = libssh2_session_disconnect_ex(session, SSH_DISCONNECT_BY_APPLICATION, "Termind closed SFTP", "")
_ = libssh2_session_free(session)
@@ -934,11 +960,17 @@ private nonisolated final class SFTPWorker: @unchecked Sendable {
) -> RemoteFile {
let mode = UInt32(attributes.permissions)
let kind: RemoteFile.Kind
if mode & UInt32(LIBSSH2_SFTP_S_IFMT) == UInt32(LIBSSH2_SFTP_S_IFDIR) { kind = .directory }
else if mode & UInt32(LIBSSH2_SFTP_S_IFMT) == UInt32(LIBSSH2_SFTP_S_IFREG) { kind = .file }
else if mode & UInt32(LIBSSH2_SFTP_S_IFMT) == UInt32(LIBSSH2_SFTP_S_IFLNK) { kind = .symbolicLink }
else if mode == 0 { kind = .unknown }
else { kind = .special }
if mode & UInt32(LIBSSH2_SFTP_S_IFMT) == UInt32(LIBSSH2_SFTP_S_IFDIR) {
kind = .directory
} else if mode & UInt32(LIBSSH2_SFTP_S_IFMT) == UInt32(LIBSSH2_SFTP_S_IFREG) {
kind = .file
} else if mode & UInt32(LIBSSH2_SFTP_S_IFMT) == UInt32(LIBSSH2_SFTP_S_IFLNK) {
kind = .symbolicLink
} else if mode == 0 {
kind = .unknown
} else {
kind = .special
}
return RemoteFile(
path: path, name: name, kind: kind, size: Int64(attributes.filesize),
modifiedAt: attributes.mtime == 0 ? nil : Date(timeIntervalSince1970: TimeInterval(attributes.mtime)),
+22 -12
View File
@@ -147,9 +147,11 @@ private nonisolated final class SSHExecWorker: @unchecked Sendable {
}
// Blocking for the two setup calls, non-blocking for the pump below.
libssh2_session_set_blocking(session, 1)
guard let channel = libssh2_channel_open_ex(
session, "session", 7, 2 * 1_024 * 1_024, 32 * 1_024, nil, 0
) else {
guard
let channel = libssh2_channel_open_ex(
session, "session", 7, 2 * 1_024 * 1_024, 32 * 1_024, nil, 0
)
else {
throw notStarted(session, prefix: "Unable to open SSH exec channel")
}
// Only a command that ran to completion leaves a channel worth closing
@@ -212,14 +214,18 @@ private nonisolated final class SSHExecWorker: @unchecked Sendable {
bytes.count - inputOffset
)
}
if written > 0 { inputOffset += written; progressed = true }
else if written != LIBSSH2_ERROR_EAGAIN {
if written > 0 {
inputOffset += written
progressed = true
} else if written != LIBSSH2_ERROR_EAGAIN {
throw failed(session, prefix: "Unable to write command input")
}
} else if !sentEOF {
let result = libssh2_channel_send_eof(channel)
if result == 0 { sentEOF = true; progressed = true }
else if result != LIBSSH2_ERROR_EAGAIN {
if result == 0 {
sentEOF = true
progressed = true
} else if result != LIBSSH2_ERROR_EAGAIN {
throw failed(session, prefix: "Unable to finish command input")
}
}
@@ -241,8 +247,11 @@ private nonisolated final class SSHExecWorker: @unchecked Sendable {
truncated: &errorTruncated
)
}
} else if count == LIBSSH2_ERROR_EAGAIN || count == 0 { break }
else { throw failed(session, prefix: "Unable to read command output") }
} else if count == LIBSSH2_ERROR_EAGAIN || count == 0 {
break
} else {
throw failed(session, prefix: "Unable to read command output")
}
}
}
if !progressed {
@@ -297,9 +306,10 @@ private nonisolated final class SSHExecWorker: @unchecked Sendable {
)
let signal: String?
if signalResult == 0, let exitSignal {
signal = String(decoding: UnsafeBufferPointer(
start: exitSignal, count: exitSignalLength
).map(UInt8.init(bitPattern:)), as: UTF8.self)
signal = String(
decoding: UnsafeBufferPointer(
start: exitSignal, count: exitSignalLength
).map(UInt8.init(bitPattern:)), as: UTF8.self)
libssh2_free(session, exitSignal)
} else {
signal = nil
@@ -133,7 +133,10 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
queue.async { [self] in
do {
guard let session else { throw TermindSSHError.disconnected }
if rules[rule.id] != nil { continuation.resume(); return }
if rules[rule.id] != nil {
continuation.resume()
return
}
let context = RuleContext(rule: rule)
switch rule.kind {
case .local, .dynamic:
@@ -181,7 +184,10 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
func disconnect() async {
await withCheckedContinuation { continuation in
queue.async { [self] in cleanup(); continuation.resume() }
queue.async { [self] in
cleanup()
continuation.resume()
}
}
}
@@ -223,12 +229,16 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
let flow = Flow(socket: accepted, dynamic: context.rule.kind == .dynamic)
if context.rule.kind == .local {
guard let targetHost = context.rule.targetHost, let targetPort = context.rule.targetPort else {
Darwin.close(accepted); continue
Darwin.close(accepted)
continue
}
flow.channel = targetHost.withCString { target in
libssh2_channel_direct_tcpip_ex(session, target, Int32(targetPort), "127.0.0.1", 0)
}
guard flow.channel != nil else { Darwin.close(accepted); continue }
guard flow.channel != nil else {
Darwin.close(accepted)
continue
}
}
context.flows[flow.id] = flow
}
@@ -261,18 +271,21 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
let count = Darwin.recv(flow.socket, &buffer, buffer.count, 0)
if count > 0 {
let data = Data(buffer.prefix(count))
if flow.phase == .forwarding { flow.toChannel.append(data) }
else {
if flow.phase == .forwarding {
flow.toChannel.append(data)
} else {
flow.handshake.append(data)
if flow.handshake.count > 64 * 1024 {
close(flow, in: context); return
close(flow, in: context)
return
}
handleSOCKSHandshake(flow, session: session)
}
} else if count == 0 {
flow.socketEOF = true
} else if errno != EAGAIN && errno != EWOULDBLOCK {
close(flow, in: context); return
close(flow, in: context)
return
}
}
@@ -282,24 +295,36 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
guard let base = bytes.baseAddress?.assumingMemoryBound(to: CChar.self) else { return 0 }
return libssh2_channel_write_ex(channel, 0, base, bytes.count)
}
if written > 0 { flow.toChannel.removeFirst(written) }
else if written == LIBSSH2_ERROR_EAGAIN { break }
else { close(flow, in: context); return }
if written > 0 {
flow.toChannel.removeFirst(written)
} else if written == LIBSSH2_ERROR_EAGAIN {
break
} else {
close(flow, in: context)
return
}
}
if flow.socketEOF, flow.toChannel.isEmpty, !flow.channelWriteClosed {
let result = libssh2_channel_send_eof(channel)
if result == 0 { flow.channelWriteClosed = true }
else if result != LIBSSH2_ERROR_EAGAIN {
close(flow, in: context); return
if result == 0 {
flow.channelWriteClosed = true
} else if result != LIBSSH2_ERROR_EAGAIN {
close(flow, in: context)
return
}
}
if flow.toSocket.count < highWaterMark {
var buffer = [CChar](repeating: 0, count: 32 * 1024)
while flow.toSocket.count < highWaterMark {
let count = libssh2_channel_read_ex(channel, 0, &buffer, buffer.count)
if count > 0 { flow.toSocket.append(Data(bytes: buffer, count: count)) }
else if count == LIBSSH2_ERROR_EAGAIN || count == 0 { break }
else { close(flow, in: context); return }
if count > 0 {
flow.toSocket.append(Data(bytes: buffer, count: count))
} else if count == LIBSSH2_ERROR_EAGAIN || count == 0 {
break
} else {
close(flow, in: context)
return
}
}
}
if libssh2_channel_eof(channel) == 1 { flow.channelEOF = true }
@@ -310,16 +335,21 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
guard let base = bytes.baseAddress else { return 0 }
return Darwin.send(flow.socket, base, bytes.count, 0)
}
if sent > 0 { flow.toSocket.removeFirst(sent) }
else if errno == EAGAIN || errno == EWOULDBLOCK { break }
else { close(flow, in: context); return }
if sent > 0 {
flow.toSocket.removeFirst(sent)
} else if errno == EAGAIN || errno == EWOULDBLOCK {
break
} else {
close(flow, in: context)
return
}
}
if flow.channelEOF, flow.toSocket.isEmpty, !flow.socketWriteClosed {
_ = Darwin.shutdown(flow.socket, SHUT_WR)
flow.socketWriteClosed = true
}
if flow.socketEOF,
(flow.channel == nil || flow.channelEOF),
flow.channel == nil || flow.channelEOF,
flow.toChannel.isEmpty, flow.toSocket.isEmpty
{
close(flow, in: context)
@@ -335,7 +365,9 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
guard flow.handshake[0] == 0x05,
flow.handshake[2..<(2 + methods)].contains(0x00)
else {
flow.toSocket.append(contentsOf: [0x05, 0xff]); flow.socketEOF = true; return
flow.toSocket.append(contentsOf: [0x05, 0xff])
flow.socketEOF = true
return
}
flow.handshake.removeFirst(2 + methods)
flow.toSocket.append(contentsOf: [0x05, 0x00])
@@ -344,7 +376,9 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
case .request:
guard flow.handshake.count >= 4 else { return }
guard flow.handshake[0] == 0x05, flow.handshake[1] == 0x01 else {
flow.toSocket.append(socksReply(0x07)); flow.socketEOF = true; return
flow.toSocket.append(socksReply(0x07))
flow.socketEOF = true
return
}
let parsed: (String, Int, Int)?
switch flow.handshake[3] {
@@ -377,7 +411,9 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
Int(flow.handshake[20]) << 8 | Int(flow.handshake[21]), 22
)
default:
flow.toSocket.append(socksReply(0x08)); flow.socketEOF = true; return
flow.toSocket.append(socksReply(0x08))
flow.socketEOF = true
return
}
guard let (host, port, consumed) = parsed else { return }
flow.handshake.removeFirst(consumed)
@@ -385,12 +421,15 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
libssh2_channel_direct_tcpip_ex(session, target, Int32(port), "127.0.0.1", 0)
}
guard flow.channel != nil else {
flow.toSocket.append(socksReply(0x05)); flow.socketEOF = true; return
flow.toSocket.append(socksReply(0x05))
flow.socketEOF = true
return
}
flow.toSocket.append(socksReply(0x00))
flow.phase = .forwarding
if !flow.handshake.isEmpty {
flow.toChannel.append(flow.handshake); flow.handshake.removeAll()
flow.toChannel.append(flow.handshake)
flow.handshake.removeAll()
}
case .forwarding: break
}
@@ -437,7 +476,10 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
private func close(_ flow: Flow, in context: RuleContext) {
context.flows[flow.id] = nil
if flow.socket >= 0 { Darwin.close(flow.socket); flow.socket = -1 }
if flow.socket >= 0 {
Darwin.close(flow.socket)
flow.socket = -1
}
if let channel = flow.channel {
_ = libssh2_channel_close(channel)
_ = libssh2_channel_free(channel)
@@ -447,7 +489,10 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
private func close(_ context: RuleContext) {
for flow in Array(context.flows.values) { close(flow, in: context) }
if context.socket >= 0 { Darwin.close(context.socket); context.socket = -1 }
if context.socket >= 0 {
Darwin.close(context.socket)
context.socket = -1
}
if let listener = context.remoteListener {
_ = libssh2_channel_forward_cancel(listener)
context.remoteListener = nil
@@ -455,7 +500,8 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
}
private func cleanup() {
timer?.cancel(); timer = nil
timer?.cancel()
timer = nil
for context in Array(rules.values) { close(context) }
rules.removeAll()
if let session {
@@ -89,7 +89,7 @@ nonisolated struct SSHKeyMaterial: Sendable {
var blobLength = 0
let result = privateKey.withCString { privateBuffer in
let parse: (UnsafePointer<CChar>?) -> Int32 = { passphraseBuffer in
termind_libssh2_publickey_from_private_memory(
libssh2PublicKeyFromPrivateMemory(
session, &method, &methodLength, &blob, &blobLength,
privateBuffer, privateKey.utf8.count, passphraseBuffer
)
@@ -307,7 +307,7 @@ extension SSHKeyMaterial.Generation {
/// argument is nil. Using it here keeps imports and authentication consistent,
/// including passphrase-protected OpenSSH keys.
@_silgen_name("_libssh2_pub_priv_keyfilememory")
private nonisolated func termind_libssh2_publickey_from_private_memory(
private nonisolated func libssh2PublicKeyFromPrivateMemory(
_ session: OpaquePointer?,
_ method: UnsafeMutablePointer<UnsafeMutablePointer<UInt8>?>?,
_ methodLength: UnsafeMutablePointer<Int>?,
@@ -2,7 +2,7 @@ import CSSH2
import Darwin
import Foundation
/// Installs a small cwd-reporting hook before remote output reaches Ghostty.
/// Installs a small cwd-reporting hook before remote output reaches Ghostty.
/// The probe, its echo, and the installation command stay inside this state
/// machine; only the login banner and the final integrated prompt are emitted.
nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
@@ -49,19 +49,17 @@ nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
// sourcing it. Its output precedes the shell marker, so a missing `sh`
// still leaves the original shell-only bootstrap path usable.
startCommand = Data(
(
" command sh -c 'f=; did=; dlike=; dver=; "
+ "[ -r /etc/os-release ] && f=/etc/os-release; "
+ "[ -z \"$f\" ] && [ -r /usr/lib/os-release ] && f=/usr/lib/os-release; "
+ "if [ -n \"$f\" ]; then while IFS= read -r line; do case \"$line\" in "
+ "ID=*) did=${line#ID=} ;; ID_LIKE=*) dlike=${line#ID_LIKE=} ;; "
+ "VERSION_ID=*) dver=${line#VERSION_ID=} ;; esac; "
+ "done < \"$f\"; fi; kn=$(uname -s 2>/dev/null); "
+ "kr=$(uname -r 2>/dev/null); ka=$(uname -m 2>/dev/null); "
+ "printf \"\\n__TRMD_%s_SYSTEM__%s|%s|%s|%s|%s|%s__\\n\" "
+ "\"$1\" \"$did\" \"$dlike\" \"$dver\" \"$kn\" \"$kr\" \"$ka\"' sh '\(token)'; "
+ "printf '\\n__TRMD_%s_SHELL__%s__\\n' '\(token)' \"$SHELL\"\n"
).utf8
(" command sh -c 'f=; did=; dlike=; dver=; "
+ "[ -r /etc/os-release ] && f=/etc/os-release; "
+ "[ -z \"$f\" ] && [ -r /usr/lib/os-release ] && f=/usr/lib/os-release; "
+ "if [ -n \"$f\" ]; then while IFS= read -r line; do case \"$line\" in "
+ "ID=*) did=${line#ID=} ;; ID_LIKE=*) dlike=${line#ID_LIKE=} ;; "
+ "VERSION_ID=*) dver=${line#VERSION_ID=} ;; esac; "
+ "done < \"$f\"; fi; kn=$(uname -s 2>/dev/null); "
+ "kr=$(uname -r 2>/dev/null); ka=$(uname -m 2>/dev/null); "
+ "printf \"\\n__TRMD_%s_SYSTEM__%s|%s|%s|%s|%s|%s__\\n\" "
+ "\"$1\" \"$did\" \"$dlike\" \"$dver\" \"$kn\" \"$kr\" \"$ka\"' sh '\(token)'; "
+ "printf '\\n__TRMD_%s_SHELL__%s__\\n' '\(token)' \"$SHELL\"\n").utf8
)
deadline = now + Self.responseTimeout
}
@@ -337,7 +335,8 @@ nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
}
private func installationCommand(for shell: String?) -> Data? {
let marker = "builtin printf '\\n__TRMD_%s_HOME__%s\\n' '\(token)' \"$(__termind_encode \"$HOME\")\"; builtin printf '\\n__TRMD_%s_DONE__\\n' '\(token)'"
let marker =
"builtin printf '\\n__TRMD_%s_HOME__%s\\n' '\(token)' \"$(__termind_encode \"$HOME\")\"; builtin printf '\\n__TRMD_%s_DONE__\\n' '\(token)'"
let command: String
switch shell {
case "bash":
@@ -424,7 +423,8 @@ nonisolated struct RemoteCommandEventFilter: Sendable {
if let statusText = fields.first, fields.count <= 2,
let status = Int32(statusText), (0...255).contains(status)
{
let duration = fields.count == 2
let duration =
fields.count == 2
? Self.duration(milliseconds: fields[1])
: nil
update.events.append(.commandFinished(status, duration: duration))
@@ -829,7 +829,8 @@ nonisolated final class SSHSessionWorker: @unchecked Sendable {
let printableName = keyName.unicodeScalars
.map { CharacterSet.controlCharacters.contains($0) ? " " : String($0) }
.joined()
let normalizedName = printableName
let normalizedName =
printableName
.split(whereSeparator: \Character.isWhitespace)
.joined(separator: " ")
let safeName = normalizedName.isEmpty ? "Key" : normalizedName
@@ -67,8 +67,9 @@ nonisolated enum SSHSocketConnector {
// the following libssh2 handshake wait for bytes already discarded.
response.append(try readSome(socket, maximum: 1))
}
guard let firstLine = String(decoding: response, as: UTF8.self)
.split(separator: "\n", maxSplits: 1).first
guard
let firstLine = String(decoding: response, as: UTF8.self)
.split(separator: "\n", maxSplits: 1).first
else { throw TermindSSHError.proxy("The HTTP proxy returned an empty response.") }
let fields = firstLine.split(separator: " ")
guard fields.count >= 2, let status = Int(fields[1]), (200..<300).contains(status) else {
@@ -191,7 +192,8 @@ nonisolated enum SSHSocketConnector {
var descriptor = pollfd(fd: socket, events: Int16(POLLOUT), revents: 0)
let result = Darwin.poll(&descriptor, 1, Int32(max(1, timeout * 1000)))
guard result > 0 else {
throw TermindSSHError.socket(result == 0 ? String(localized: "Connection timed out.") : String(cString: strerror(errno)))
throw TermindSSHError.socket(
result == 0 ? String(localized: "Connection timed out.") : String(cString: strerror(errno)))
}
var socketError: Int32 = 0
var errorLength = socklen_t(MemoryLayout<Int32>.size)
@@ -216,7 +218,8 @@ nonisolated enum SSHSocketConnector {
var buffer = [UInt8](repeating: 0, count: maximum)
let count = Darwin.recv(socket, &buffer, buffer.count, 0)
guard count > 0 else {
throw TermindSSHError.proxy(count == 0 ? "The proxy closed the connection." : String(cString: strerror(errno)))
throw TermindSSHError.proxy(
count == 0 ? "The proxy closed the connection." : String(cString: strerror(errno)))
}
return Data(buffer.prefix(count))
}
@@ -98,7 +98,8 @@ nonisolated struct SSHUserCertificate: Hashable, Sendable {
throw SSHUserCertificateError.invalidFormat
}
let publicBlob = SSHWireWriter.field(Data(plainMethod.utf8))
let publicBlob =
SSHWireWriter.field(Data(plainMethod.utf8))
+ keyFields.reduce(into: Data()) { $0.append(SSHWireWriter.field($1)) }
if let publicKey {
let publicFields = publicKey.split(whereSeparator: \Character.isWhitespace)
@@ -36,7 +36,8 @@ nonisolated enum SecureEnclaveKey {
nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, flags, &accessError
)
else {
let detail = accessError?.takeRetainedValue().localizedDescription
let detail =
accessError?.takeRetainedValue().localizedDescription
?? String(localized: "Unknown Security framework error")
throw SecureEnclaveKeyError.accessControl(detail)
}
@@ -47,7 +48,8 @@ nonisolated enum SecureEnclaveKey {
)
let publicBlob = sshPublicBlob(for: privateKey.publicKey.x963Representation)
let trimmedComment = comment.trimmingCharacters(in: .whitespacesAndNewlines)
let publicKey = "ecdsa-sha2-nistp256 \(publicBlob.base64EncodedString())"
let publicKey =
"ecdsa-sha2-nistp256 \(publicBlob.base64EncodedString())"
+ (trimmedComment.isEmpty ? "" : " \(trimmedComment)")
return SecureEnclaveKeyMaterial(
credential: SecureEnclaveKeyCredential(
@@ -53,7 +53,8 @@ nonisolated final class SystemSerialPortService: SerialPortServing, @unchecked S
defer { IOObjectRelease(iterator) }
while case let service = IOIteratorNext(iterator), service != 0 {
defer { IOObjectRelease(service) }
let product = property("USB Product Name", of: service)
let product =
property("USB Product Name", of: service)
?? property("Product Name", of: service)
?? property(kIOTTYBaseNameKey, of: service)
for key in [kIOCalloutDeviceKey, kIODialinDeviceKey] {
@@ -269,7 +270,10 @@ nonisolated private final class POSIXSerialPortConnection: SerialPortConnection,
var offset = 0
while offset < raw.count {
let count = Darwin.write(descriptor, base.advanced(by: offset), raw.count - offset)
if count > 0 { offset += count; continue }
if count > 0 {
offset += count
continue
}
if count < 0, errno == EINTR { continue }
throw SerialPortError.system(
operation: String(localized: "Write serial port"), code: errno
@@ -302,9 +306,10 @@ nonisolated private final class POSIXSerialPortConnection: SerialPortConnection,
receive(Data(bytes.prefix(count)))
} else if count == 0 || (errno != EAGAIN && errno != EINTR) {
let code = count == 0 ? ENXIO : errno
finish(SerialPortError.system(
operation: String(localized: "Read serial port"), code: code
).localizedDescription)
finish(
SerialPortError.system(
operation: String(localized: "Read serial port"), code: code
).localizedDescription)
}
}
@@ -196,8 +196,8 @@ struct ConnectionJourneyView: View {
.frame(width: 52, height: 52)
.overlay {
Image(systemName: headerSymbol)
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(TermindColor.selectedForeground)
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(TermindColor.selectedForeground)
}
VStack(alignment: .leading, spacing: 3) {
@@ -327,7 +327,7 @@ struct ConnectionJourneyView: View {
.foregroundStyle(.tertiary)
Text(entry.endpoint).foregroundStyle(.secondary)
Text(entry.message)
}
}
.font(.caption.monospaced())
.textSelection(.enabled)
}
@@ -362,11 +362,12 @@ struct ConnectionJourneyView: View {
}
private var headerDetail: String {
let protocolName: String = switch journey.purpose {
case .sshSession: "SSH"
case .sftpSession: "SFTP"
case .serialSession: String(localized: "Serial")
}
let protocolName: String =
switch journey.purpose {
case .sshSession: "SSH"
case .sftpSession: "SFTP"
case .serialSession: String(localized: "Serial")
}
let address = journey.destination?.address ?? ""
if journey.mode == .reconnection {
return String(localized: "Reconnecting \(protocolName) · \(address)")
@@ -506,9 +507,10 @@ private struct ConnectionLogNativeList: NSViewRepresentable {
content.update(
listView, items: items,
renderer: { item in
AnyView(renderer(item)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14))
AnyView(
renderer(item)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14))
},
locale: locale, colorScheme: colorScheme, animated: true
)
@@ -517,9 +519,14 @@ private struct ConnectionLogNativeList: NSViewRepresentable {
private func height(for item: ConnectionLogListItem, width: CGFloat) -> CGFloat {
guard width > 0, let root = content.rootView(for: item) else { return 26 }
measurement.rootView = root
return max(26, ceil(measurement.sizeThatFits(in: CGSize(
width: width, height: .greatestFiniteMagnitude
)).height) + 8)
return max(
26,
ceil(
measurement.sizeThatFits(
in: CGSize(
width: width, height: .greatestFiniteMagnitude
)
).height) + 8)
}
private func configure(_ row: HostingListRow, item: ConnectionLogListItem) {
@@ -1169,14 +1176,14 @@ private struct JourneyFailureCard: View {
Button(action: cancel) {
Text(cancelLabel).frame(width: 88)
}
.buttonStyle(.bordered)
.keyboardShortcut(.cancelAction)
.buttonStyle(.bordered)
.keyboardShortcut(.cancelAction)
Spacer()
Button(action: retry) {
Text(retryLabel).frame(width: 88)
}
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
}
} else {
HStack {
@@ -241,9 +241,10 @@ nonisolated struct NativeDropPayload: Sendable {
continue
}
guard let type = item.types.first(where: { type in
UTType(type.rawValue)?.conforms(to: .image) == true
}), let data = item.data(forType: type)
guard
let type = item.types.first(where: { type in
UTType(type.rawValue)?.conforms(to: .image) == true
}), let data = item.data(forType: type)
else { continue }
payload.images.append(data)
}
+9 -6
View File
@@ -31,7 +31,8 @@ final class HostingListRow: ListRowView {
/// Shared renderer and diff state for SwiftUI-backed ListView coordinators.
/// List-specific setup, selection and scrolling stay in their owning adapter.
@MainActor
final class HostingListContent<Item> where
final class HostingListContent<Item>
where
Item: Identifiable & Hashable & SendableMetatype, Item.ID: Hashable
{
typealias Renderer = @MainActor (Item) -> AnyView
@@ -72,10 +73,11 @@ final class HostingListContent<Item> where
func rootView(for item: Item) -> AnyView? {
guard let renderer else { return nil }
return AnyView(renderer(item)
.id(item.id)
.environment(\.locale, locale)
.environment(\.colorScheme, colorScheme))
return AnyView(
renderer(item)
.id(item.id)
.environment(\.locale, locale)
.environment(\.colorScheme, colorScheme))
}
}
@@ -84,7 +86,8 @@ protocol CursorHostingListItem: Identifiable, Hashable {
}
/// Fixed-height hosting list shared by keyboard-driven chooser sheets.
struct CursorHostingList<Item>: NSViewRepresentable where
struct CursorHostingList<Item>: NSViewRepresentable
where
Item: CursorHostingListItem & SendableMetatype, Item.ID: Hashable
{
typealias NSViewType = ListView<Item>
@@ -80,14 +80,25 @@ struct OnboardingCoachMark: View {
case .statusBarPath:
switch presentation.pathDirection {
case .sshToSFTP:
String(localized: "Click the path to open this directory with SFTP. Right-click to copy it or open it in a new workspace.")
String(
localized:
"Click the path to open this directory with SFTP. Right-click to copy it or open it in a new workspace."
)
case .sftpToSSH:
String(localized: "Click the path to open this directory with SSH. Right-click to copy it or open it in a new workspace.")
String(
localized:
"Click the path to open this directory with SSH. Right-click to copy it or open it in a new workspace."
)
case nil:
String(localized: "Click the path to open the same directory in another session. Right-click for more options.")
String(
localized:
"Click the path to open the same directory in another session. Right-click for more options.")
}
case .commandGenerator:
String(localized: "Type what you want to do at the shell prompt, then press ⌘I. The generated command is inserted but never run automatically.")
String(
localized:
"Type what you want to do at the shell prompt, then press ⌘I. The generated command is inserted but never run automatically."
)
}
}
+19 -19
View File
@@ -203,27 +203,27 @@ struct ManagedChooserHeader: View {
private var searchRow: some View {
HStack(spacing: Metrics.Space.s) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField(prompt, text: $query)
.textFieldStyle(.plain)
.focused($searchFocused)
.onSubmit(onSubmit)
.onKeyPress(.upArrow) { moveCursor(-1) }
.onKeyPress(.downArrow) { moveCursor(1) }
.onKeyPress(.space, action: toggleCursor)
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField(prompt, text: $query)
.textFieldStyle(.plain)
.focused($searchFocused)
.onSubmit(onSubmit)
.onKeyPress(.upArrow) { moveCursor(-1) }
.onKeyPress(.downArrow) { moveCursor(1) }
.onKeyPress(.space, action: toggleCursor)
if !query.isEmpty {
Button {
query = ""
searchFocused = true
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("Clear search"))
if !query.isEmpty {
Button {
query = ""
searchFocused = true
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("Clear search"))
}
}
.padding(Metrics.Space.s)
.background(
+14 -10
View File
@@ -233,19 +233,23 @@ struct SidebarNavigatorRow<Trailing: View>: View {
Button(action: action) {
HStack(spacing: Metrics.Space.s) {
Image(systemName: symbol)
.foregroundStyle(SidebarSelectionStyle.foreground(
isSelected: isSelected,
controlActiveState: controlActiveState,
unselected: AnyShapeStyle(.secondary)
))
.foregroundStyle(
SidebarSelectionStyle.foreground(
isSelected: isSelected,
controlActiveState: controlActiveState,
unselected: AnyShapeStyle(.secondary)
)
)
.frame(width: Metrics.Sidebar.rowIcon)
.accessibilityHidden(true)
Text(title)
.foregroundStyle(SidebarSelectionStyle.foreground(
isSelected: isSelected,
controlActiveState: controlActiveState,
unselected: AnyShapeStyle(.primary)
))
.foregroundStyle(
SidebarSelectionStyle.foreground(
isSelected: isSelected,
controlActiveState: controlActiveState,
unselected: AnyShapeStyle(.primary)
)
)
.fontWeight(isSelected ? .semibold : .regular)
Spacer(minLength: Metrics.Space.xs)
trailing
+1 -1
View File
@@ -1,5 +1,5 @@
import Foundation
import Darwin
import Foundation
@main
struct TermindMCPBridge {
@@ -299,7 +299,6 @@ struct AIProviderPersistenceTests {
#expect(AIRole.commandGenerator.unsetLabel == "Same as Chat")
}
@Test
func catalogReplacementKeepsManualModelOverrides() {
var original = reasoningModel("custom-reasoner")
@@ -501,9 +500,10 @@ struct ModelCapabilityResolverTests {
#expect(!chat.supportsNativeWebSearch(model: model))
var legacyModel = AIModel(id: "deepseek-chat")
legacyModel.abilities = ModelCapabilityResolver.resolve(
model: AIModel(id: "deepseek-chat"), provider: responsesShaped
).model.abilities
legacyModel.abilities =
ModelCapabilityResolver.resolve(
model: AIModel(id: "deepseek-chat"), provider: responsesShaped
).model.abilities
#expect(!legacyModel.abilities.contains(.webSearch))
}
+32 -20
View File
@@ -54,10 +54,12 @@ private func turnsCalling(_ names: [String]) -> [[AgentModelStreamEvent]] {
/// Requires authorization and may run beside its peers — the combination the
/// single pending-approval slot could not survive.
private func gatedTool(_ name: String, log: CallLog) -> AnyAgentTool {
AnyAgentTool(descriptor: AgentToolDescriptor(
name: name, summary: name, inputSchema: queueSchema,
target: .host, safety: .requiresAuthorization, concurrency: .parallel
)) { invocation, _ in
AnyAgentTool(
descriptor: AgentToolDescriptor(
name: name, summary: name, inputSchema: queueSchema,
target: .host, safety: .requiresAuthorization, concurrency: .parallel
)
) { invocation, _ in
await log.start(name)
await log.finish(name)
return AgentToolResult(callID: invocation.call.id, content: name)
@@ -66,10 +68,12 @@ private func gatedTool(_ name: String, log: CallLog) -> AnyAgentTool {
/// Never returns on its own; only cancellation ends it.
private func hangingTool(_ name: String, log: CallLog) -> AnyAgentTool {
AnyAgentTool(descriptor: AgentToolDescriptor(
name: name, summary: name, inputSchema: queueSchema,
target: .local, safety: .locallyReadOnly, concurrency: .parallel
)) { invocation, _ in
AnyAgentTool(
descriptor: AgentToolDescriptor(
name: name, summary: name, inputSchema: queueSchema,
target: .local, safety: .locallyReadOnly, concurrency: .parallel
)
) { invocation, _ in
await log.start(name)
try await Task.sleep(for: .seconds(30))
return AgentToolResult(callID: invocation.call.id, content: name)
@@ -99,9 +103,11 @@ struct AgentApprovalQueueTests {
)
)
conversation.start(runtime: runtime, request: AgentRunRequest(
conversationID: conversation.id, prompt: "go", permissionMode: .askForApproval
))
conversation.start(
runtime: runtime,
request: AgentRunRequest(
conversationID: conversation.id, prompt: "go", permissionMode: .askForApproval
))
await wait { conversation.pendingApprovals.count == 2 }
// Both are waiting, but only the head is presented: authorizing three
@@ -136,9 +142,11 @@ struct AgentApprovalQueueTests {
)
)
conversation.start(runtime: runtime, request: AgentRunRequest(
conversationID: conversation.id, prompt: "go", permissionMode: .askForApproval
))
conversation.start(
runtime: runtime,
request: AgentRunRequest(
conversationID: conversation.id, prompt: "go", permissionMode: .askForApproval
))
await wait { conversation.pendingApprovals.count == 2 }
conversation.respondToApproval(allow: false)
@@ -186,9 +194,12 @@ struct AgentApprovalQueueTests {
#expect(conversation.runState == .waitingForUser)
// The review finishing must not announce that nothing is outstanding.
conversation.handle(.reviewFinished(request, SecurityReviewDecision(
verdict: .approve, risk: .low, userAuthorization: .sufficient, reason: "ok"
)))
conversation.handle(
.reviewFinished(
request,
SecurityReviewDecision(
verdict: .approve, risk: .low, userAuthorization: .sufficient, reason: "ok"
)))
#expect(conversation.runState == .waitingForUser)
conversation.cancelUserInput()
@@ -234,9 +245,10 @@ struct AgentApprovalQueueTests {
approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow })
)
let stream = await runtime.start(AgentRunRequest(
conversationID: UUID(), prompt: "go", permissionMode: .askForApproval
))
let stream = await runtime.start(
AgentRunRequest(
conversationID: UUID(), prompt: "go", permissionMode: .askForApproval
))
let collector = Task {
var events: [AgentEvent] = []
for await event in stream { events.append(event) }
+29 -8
View File
@@ -120,7 +120,10 @@ struct AgentBackgroundCommandTests {
let stream = AgentCommandOutputStream(interval: .zero) { _, _ in }
let handle = await commands.adopt(
hostID: UUID(), command: "deploy", stream: stream,
task: Task { await released.wait(); return exited(0) }, session: StubSession()
task: Task {
await released.wait()
return exited(0)
}, session: StubSession()
)
stream.handler(Data("step 1 of 9\n".utf8), false)
@@ -151,7 +154,10 @@ struct AgentBackgroundCommandTests {
let stream = AgentCommandOutputStream(interval: .zero) { _, _ in }
let handle = await commands.adopt(
hostID: UUID(), command: "deploy", stream: stream,
task: Task { await released.wait(); return exited(0) }, session: StubSession()
task: Task {
await released.wait()
return exited(0)
}, session: StubSession()
)
let seen = Sink()
@@ -172,12 +178,18 @@ struct AgentBackgroundCommandTests {
let start = ContinuousClock.now
let stale = await commands.adopt(
hostID: UUID(), command: "tail -f", stream: AgentCommandOutputStream { _, _ in },
task: Task { await released.wait(); return exited(0) }, session: forgotten,
task: Task {
await released.wait()
return exited(0)
}, session: forgotten,
now: start
)
_ = await commands.adopt(
hostID: UUID(), command: "deploy", stream: AgentCommandOutputStream { _, _ in },
task: Task { await released.wait(); return exited(0) }, session: StubSession(),
task: Task {
await released.wait()
return exited(0)
}, session: StubSession(),
now: start.advanced(by: .seconds(590))
)
@@ -203,7 +215,10 @@ struct AgentBackgroundCommandTests {
let released = AsyncRelease()
_ = await commands.adopt(
hostID: UUID(), command: "tail -f", stream: AgentCommandOutputStream { _, _ in },
task: Task { await released.wait(); return exited(0) }, session: session
task: Task {
await released.wait()
return exited(0)
}, session: session
)
for _ in 0..<50 where await commands.count > 0 {
@@ -226,7 +241,10 @@ struct AgentBackgroundCommandTests {
let commands = AgentBackgroundCommands(sweepInterval: nil)
let released = AsyncRelease()
let signal = AgentInterjectionSignal()
let task = Task<SSHExecResult, any Error> { await released.wait(); return exited(0) }
let task = Task<SSHExecResult, any Error> {
await released.wait()
return exited(0)
}
let handle = await commands.adopt(
hostID: UUID(), command: "deploy", stream: AgentCommandOutputStream { _, _ in },
task: task, session: StubSession()
@@ -262,7 +280,10 @@ struct AgentBackgroundCommandTests {
signal.disarm()
let finished = Sink()
let waiter = Task { await signal.wait(); finished.append("released") }
let waiter = Task {
await signal.wait()
finished.append("released")
}
try await Task.sleep(for: .milliseconds(30))
#expect(finished.values.isEmpty, "nothing is queued, so nothing to hurry for")
@@ -302,7 +323,7 @@ private actor AsyncRelease {
isReleased = true
let pending = waiters
waiters.removeAll()
pending.forEach { $0.resume() }
for waiter in pending { waiter.resume() }
}
}
@@ -19,12 +19,16 @@ struct AgentBuiltInToolAvailabilityTests {
sweepInterval: nil
)
let mainAssistant = TermindAgentTools.tools(in: .init(
hosts: hosts, connections: connections
)).map(\.descriptor)
let workspaceSidebar = TermindAgentTools.tools(in: .init(
hosts: hosts, connections: connections, includesTerminalSuggestion: true
)).map(\.descriptor)
let mainAssistant = TermindAgentTools.tools(
in: .init(
hosts: hosts, connections: connections
)
).map(\.descriptor)
let workspaceSidebar = TermindAgentTools.tools(
in: .init(
hosts: hosts, connections: connections, includesTerminalSuggestion: true
)
).map(\.descriptor)
#expect(!mainAssistant.contains { $0.name == "terminal_suggest" })
#expect(workspaceSidebar.contains { $0.name == "terminal_suggest" })
@@ -44,18 +48,19 @@ struct AgentBuiltInToolAvailabilityTests {
conversationID: UUID(), base: FileManager.default.temporaryDirectory
)
let web = ScrubberWebClient(
document: { _ in throw AgentWebError.emptyDocument },
search: { _, _ in [] }
document: { _ in throw AgentWebError.emptyDocument },
search: { _, _ in [] }
)
let descriptors = AgentToolCatalog.registry(
builtIn: .init(
workspace: workspace, web: web, search: web,
tasks: AgentTaskList { _ in }, plans: AgentPlanRecorder { _ in }
),
additional: TermindAgentTools.tools(in: .init(
hosts: hosts, connections: connections,
includesTerminalSuggestion: true, workspace: workspace
))
additional: TermindAgentTools.tools(
in: .init(
hosts: hosts, connections: connections,
includesTerminalSuggestion: true, workspace: workspace
))
).descriptors
for descriptor in descriptors where descriptor.target != .mcp {
+25 -21
View File
@@ -301,9 +301,10 @@ struct AgentCompactionTests {
let transcript = conversation.transcript
let estimate = conversation.contextEstimate
let record = try #require(conversation.prepareCompaction(
summary: "summary", cut: span.cut
))
let record = try #require(
conversation.prepareCompaction(
summary: "summary", cut: span.cut
))
#expect(conversation.transcript == transcript)
#expect(conversation.contextEstimate == estimate)
@@ -317,13 +318,15 @@ struct AgentCompactionTests {
conversation.appendUserTurn("old " + filler(tokens: 600))
conversation.appendUserTurn("recent " + filler(tokens: 20))
let firstSpan = try #require(conversation.compactionSpan(keepRecentTokens: 20))
let first = try #require(conversation.applyCompaction(
summary: "first summary", cut: firstSpan.cut
))
let first = try #require(
conversation.applyCompaction(
summary: "first summary", cut: firstSpan.cut
))
let second = try #require(conversation.prepareCompaction(
summary: "second summary", cut: 1
))
let second = try #require(
conversation.prepareCompaction(
summary: "second summary", cut: 1
))
#expect(second.compactedThroughMessageID == first.compactedThroughMessageID)
#expect(second.sequence == first.sequence + 1)
@@ -399,18 +402,19 @@ struct AgentCompactionTests {
@Test
func restoreDoesNotResurrectCompactedHistory() {
let at: (TimeInterval) -> Date = { Date(timeIntervalSinceReferenceDate: $0) }
let restored = AgentCompactionProjection.restore(messages: [
AgentTranscriptMessage(role: .user, text: "very old", createdAt: at(1)),
AgentTranscriptMessage(role: .assistant, text: "older reply", createdAt: at(2)),
AgentTranscriptMessage(
role: .user, text: "## Goal\nfirst", isCompaction: true, createdAt: at(2)
),
AgentTranscriptMessage(role: .user, text: "recent", createdAt: at(3)),
AgentTranscriptMessage(
role: .user, text: "## Goal\nsecond", isCompaction: true, createdAt: at(3)
),
AgentTranscriptMessage(role: .user, text: "newest", createdAt: at(4)),
], records: [])
let restored = AgentCompactionProjection.restore(
messages: [
AgentTranscriptMessage(role: .user, text: "very old", createdAt: at(1)),
AgentTranscriptMessage(role: .assistant, text: "older reply", createdAt: at(2)),
AgentTranscriptMessage(
role: .user, text: "## Goal\nfirst", isCompaction: true, createdAt: at(2)
),
AgentTranscriptMessage(role: .user, text: "recent", createdAt: at(3)),
AgentTranscriptMessage(
role: .user, text: "## Goal\nsecond", isCompaction: true, createdAt: at(3)
),
AgentTranscriptMessage(role: .user, text: "newest", createdAt: at(4)),
], records: [])
// The last marker, not the first: a conversation compacted twice has
// two, and the older summary was folded into the newer one.
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff. Show more