Rename SSH connection journey
This commit is contained in:
1 parent
f5e240ea1b
commit
e744ab8a67
21 files changed
+1310
-283
No files matched your search
+13
-13
@@ -1,12 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
nonisolated enum SSHConnectionPurpose: String, Hashable, Sendable {
|
||||
nonisolated enum ConnectionPurpose: String, Hashable, Sendable {
|
||||
case sshSession
|
||||
case sftpSession
|
||||
case serialSession
|
||||
}
|
||||
|
||||
nonisolated struct SSHConnectionEndpoint: Identifiable, Hashable, Sendable {
|
||||
nonisolated struct ConnectionEndpoint: Identifiable, Hashable, Sendable {
|
||||
nonisolated enum Role: Hashable, Sendable {
|
||||
case local
|
||||
case jump
|
||||
@@ -44,11 +44,11 @@ nonisolated struct SSHAuthenticationSubmission: Hashable, Sendable {
|
||||
var identityID: Identity.ID? = nil
|
||||
}
|
||||
|
||||
/// UI-facing state for one SSH attempt. The transport emits facts; this model
|
||||
/// UI-facing state for one connection attempt. The transport emits facts; this model
|
||||
/// turns them into a monotonic route so reconnect probes cannot visually move
|
||||
/// a completed path backwards.
|
||||
@Observable
|
||||
final class SSHConnectionJourney {
|
||||
final class ConnectionJourney {
|
||||
enum Mode: Equatable {
|
||||
case connection
|
||||
case reconnection
|
||||
@@ -84,9 +84,9 @@ final class SSHConnectionJourney {
|
||||
var message: String
|
||||
}
|
||||
|
||||
private(set) var purpose: SSHConnectionPurpose = .sshSession
|
||||
private(set) var purpose: ConnectionPurpose = .sshSession
|
||||
private(set) var mode: Mode = .connection
|
||||
private(set) var endpoints: [SSHConnectionEndpoint] = []
|
||||
private(set) var endpoints: [ConnectionEndpoint] = []
|
||||
private(set) var phase: Phase = .hidden
|
||||
private(set) var activeEndpointIndex = 0
|
||||
private(set) var completedEndpointIndex = 0
|
||||
@@ -99,24 +99,24 @@ final class SSHConnectionJourney {
|
||||
private var completionTask: Task<Void, Never>?
|
||||
|
||||
var isPresented: Bool { phase != .hidden }
|
||||
var destination: SSHConnectionEndpoint? { endpoints.last }
|
||||
var destination: ConnectionEndpoint? { endpoints.last }
|
||||
var waitsForLocalIdentity: Bool { localRequirement == .identity }
|
||||
var waitsForLocalPassphrase: Bool { localRequirement == .passphrase }
|
||||
|
||||
func begin(
|
||||
host: Host, route: [Host], purpose: SSHConnectionPurpose,
|
||||
host: Host, route: [Host], purpose: ConnectionPurpose,
|
||||
username: String?, mode: Mode = .connection
|
||||
) {
|
||||
completionTask?.cancel()
|
||||
let nextEndpoints =
|
||||
[
|
||||
SSHConnectionEndpoint(
|
||||
ConnectionEndpoint(
|
||||
id: .local, hostID: nil,
|
||||
name: String(localized: "This Mac"), address: "localhost", role: .local
|
||||
)
|
||||
]
|
||||
+ route.enumerated().map { index, node in
|
||||
SSHConnectionEndpoint(
|
||||
ConnectionEndpoint(
|
||||
id: .host(node.id), hostID: node.id, name: node.name,
|
||||
address: index == route.count - 1 && username?.isEmpty == false
|
||||
? "\(username!)@\(node.displayAddress)" : node.displayAddress,
|
||||
@@ -157,11 +157,11 @@ final class SSHConnectionJourney {
|
||||
purpose = .serialSession
|
||||
self.mode = mode
|
||||
endpoints = [
|
||||
SSHConnectionEndpoint(
|
||||
ConnectionEndpoint(
|
||||
id: .local, hostID: nil,
|
||||
name: String(localized: "This Mac"), address: "localhost", role: .local
|
||||
),
|
||||
SSHConnectionEndpoint(
|
||||
ConnectionEndpoint(
|
||||
id: .serial(address), hostID: nil,
|
||||
name: name, address: address, role: .destination
|
||||
),
|
||||
@@ -331,7 +331,7 @@ final class SSHConnectionJourney {
|
||||
}
|
||||
}
|
||||
|
||||
private extension SSHConnectionPurpose {
|
||||
private extension ConnectionPurpose {
|
||||
var logName: String {
|
||||
switch self {
|
||||
case .sshSession: "SSH session"
|
||||
@@ -1,11 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
/// Shared connection-state composition for SSH-backed interactive sessions.
|
||||
/// Shared connection-state composition for interactive sessions.
|
||||
/// Transport-specific models own their I/O, while this object owns the common
|
||||
/// connection journey and retry lifecycle shown by both Terminal and SFTP.
|
||||
@Observable
|
||||
final class RemoteSessionConnectionController {
|
||||
let journey = SSHConnectionJourney()
|
||||
let journey = ConnectionJourney()
|
||||
let reconnect = SessionReconnectController()
|
||||
|
||||
var reconnectState: ReconnectState? { reconnect.state }
|
||||
|
||||
@@ -46,18 +46,6 @@ nonisolated enum AgentToolPresentation {
|
||||
return .warning
|
||||
}
|
||||
|
||||
/// A stable 30% sample for a batch. Invocation identifiers are random UUIDs,
|
||||
/// so using their first 32 bits gives the desired distribution without
|
||||
/// making a SwiftUI row redraw or scroll reuse roll the dice again.
|
||||
static func showsReasoning(forBatchID id: UUID) -> Bool {
|
||||
let sample = withUnsafeBytes(of: id.uuid) { bytes in
|
||||
bytes.prefix(4).reduce(UInt32.zero) { value, byte in
|
||||
(value << 8) | UInt32(byte)
|
||||
}
|
||||
}
|
||||
return sample < (UInt32.max / 10) * 3
|
||||
}
|
||||
|
||||
/// Converts provider-authored Markdown into the one-line prose a tool
|
||||
/// batch header can accommodate. Structure and emphasis remain available
|
||||
/// in the live reasoning row; here only the visible words are reused.
|
||||
|
||||
@@ -334,22 +334,34 @@ nonisolated enum TimelineItem: Identifiable, Hashable, Sendable {
|
||||
nonisolated struct AgentToolBatch: Identifiable, Hashable, Sendable {
|
||||
var id: UUID { cards.first?.id ?? Self.emptyID }
|
||||
var cards: [AgentToolCard]
|
||||
/// The assistant response that requested every tool in this batch.
|
||||
var sourceMessageID: UUID
|
||||
/// A single-line projection of that response's display-safe reasoning.
|
||||
/// Kept only while at least one member is unfinished.
|
||||
var reasoningTitle: String?
|
||||
/// Batches are run-scoped rather than provider-turn-scoped. A reasoning
|
||||
/// message or another model round trip must not split one visual activity
|
||||
/// into a succession of structurally different rows.
|
||||
var runID: UUID
|
||||
/// The latest provider-authored reasoning that can temporarily occupy the
|
||||
/// batch title. The renderer keeps the Markdown and derives plain text only
|
||||
/// for accessibility.
|
||||
var reasoning: AgentToolReasoning?
|
||||
|
||||
private static let emptyID = UUID(
|
||||
uuidString: "00000000-0000-0000-0000-000000000000"
|
||||
)!
|
||||
}
|
||||
|
||||
nonisolated struct AgentToolReasoning: Hashable, Sendable {
|
||||
var sourceMessageID: UUID
|
||||
var markdown: String
|
||||
|
||||
/// Content is part of the revision: every streamed reasoning update earns
|
||||
/// its own complete display window, while recycling the same row does not.
|
||||
var revision: String { sourceMessageID.uuidString + "\u{0}" + markdown }
|
||||
}
|
||||
|
||||
nonisolated enum AgentWorkItem: Identifiable, Hashable, Sendable {
|
||||
case reasoning(AgentReasoningBlock)
|
||||
case message(ChatMessage)
|
||||
case tool(AgentToolCard)
|
||||
/// Two or more contiguous tool calls, regardless of their current result.
|
||||
/// One or more run-scoped tool calls, regardless of their current result.
|
||||
/// The view shows activity until every member is terminal, then summarizes.
|
||||
case tools(AgentToolBatch)
|
||||
|
||||
@@ -543,6 +555,13 @@ final class ConversationModel: Identifiable {
|
||||
/// is taken into account.
|
||||
private var runtimeState: AgentRunState = .idle
|
||||
var toolCards: [AgentToolCard] = []
|
||||
/// Tool proposals are durable individual events, but publishing each one to
|
||||
/// the transcript would briefly draw a single row and then replace it with
|
||||
/// a batch. Hold the UI projection until every call from the source message
|
||||
/// is known; state/result events update the staged card in place.
|
||||
private var stagedToolCards: [UUID: AgentToolCard] = [:]
|
||||
private var stagedToolOrder: [UUID] = []
|
||||
private var activeTurnMessageID: UUID?
|
||||
private(set) var runSummaries: [UUID: AgentRunSummary] = [:]
|
||||
private(set) var messageRunIDs: [UUID: UUID] = [:]
|
||||
private var activeRunID: UUID?
|
||||
@@ -1084,18 +1103,53 @@ final class ConversationModel: Identifiable {
|
||||
guard owner == runID else { return nil }
|
||||
return visibleByID[messageID]
|
||||
}
|
||||
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
|
||||
&& message.reasoning.isEmpty
|
||||
let toolProcess = toolProcessMessageIDs.contains(message.id)
|
||||
&& (!message.text.isEmpty
|
||||
|| (message.isStreaming && message.reasoning.isEmpty))
|
||||
return livePlaceholder || toolProcess
|
||||
// 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
|
||||
&& !hasVisibleReasoning
|
||||
&& !workHasNonReasoningOutput)
|
||||
|| toolProcess
|
||||
}
|
||||
let reasoningItems = processMessages.flatMap { message -> [AgentWorkItem] in
|
||||
guard message.id == activeReasoningMessageID else { return [] }
|
||||
return message.reasoning.map(AgentWorkItem.reasoning)
|
||||
let groupableCards = cards.filter {
|
||||
$0.descriptor.presentation?.groupsWithAdjacentTools != false
|
||||
}
|
||||
let hasUnfinishedGroupableTool = groupableCards.contains {
|
||||
!$0.state.isTerminal
|
||||
}
|
||||
let latestToolReasoning = processMessages
|
||||
.sorted { $0.createdAt < $1.createdAt }
|
||||
.reversed()
|
||||
.compactMap { message -> AgentToolReasoning? in
|
||||
guard let markdown = message.reasoning.last?.text,
|
||||
!markdown.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
else { return nil }
|
||||
return AgentToolReasoning(
|
||||
sourceMessageID: message.id,
|
||||
markdown: markdown
|
||||
)
|
||||
}
|
||||
.first
|
||||
// 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
|
||||
? processMessages.flatMap { message -> [AgentWorkItem] in
|
||||
guard message.id == activeReasoningMessageID else { return [] }
|
||||
return message.reasoning.map(AgentWorkItem.reasoning)
|
||||
}
|
||||
: []
|
||||
let activeReasoningBlockID = processMessages.first(where: {
|
||||
$0.id == activeReasoningMessageID && $0.isStreaming && $0.text.isEmpty
|
||||
})?.reasoning.last?.id
|
||||
@@ -1111,22 +1165,10 @@ final class ConversationModel: Identifiable {
|
||||
default: return $0.id.uuidString < $1.id.uuidString
|
||||
}
|
||||
}
|
||||
let sourceMessageIDs = Dictionary(uniqueKeysWithValues: cards.compactMap { card in
|
||||
card.invocation.sourceMessageID.map { (card.id, $0) }
|
||||
})
|
||||
let reasoningTitles = Dictionary(uniqueKeysWithValues: processMessages.compactMap {
|
||||
message -> (UUID, String)? in
|
||||
// The live reasoning row owns this text until its provider
|
||||
// turn ends. A tool batch from the same response may reuse it
|
||||
// afterwards, but must never render a duplicate alongside it.
|
||||
guard message.id != activeReasoningMessageID else { return nil }
|
||||
let reasoning = message.reasoning.map(\.text).joined(separator: "\n\n")
|
||||
let title = AgentToolPresentation.reasoningTitle(from: reasoning)
|
||||
return title.isEmpty ? nil : (message.id, title)
|
||||
})
|
||||
items = Self.groupAdjacentTools(
|
||||
in: items, sourceMessageIDs: sourceMessageIDs,
|
||||
reasoningTitles: reasoningTitles
|
||||
items = Self.groupRunTools(
|
||||
in: items,
|
||||
runID: runID,
|
||||
reasoning: hasUnfinishedGroupableTool ? latestToolReasoning : nil
|
||||
)
|
||||
let stored = runSummaries[runID]
|
||||
// A user stop is useful transcript state even when it interrupted a
|
||||
@@ -1164,53 +1206,41 @@ final class ConversationModel: Identifiable {
|
||||
}.sorted { $0.orderedAt < $1.orderedAt }
|
||||
}
|
||||
|
||||
private static func groupAdjacentTools(
|
||||
in items: [AgentWorkItem], sourceMessageIDs: [AgentToolCard.ID: UUID],
|
||||
reasoningTitles: [UUID: String]
|
||||
/// Produces one stable batch for every grouping-enabled tool in the run.
|
||||
/// Reasoning, provider-turn boundaries and ordinary process messages remain
|
||||
/// in chronological order but no longer flush the tool collection. Tools
|
||||
/// that explicitly opt out keep their direct row.
|
||||
private static func groupRunTools(
|
||||
in items: [AgentWorkItem],
|
||||
runID: UUID,
|
||||
reasoning: AgentToolReasoning?
|
||||
) -> [AgentWorkItem] {
|
||||
var grouped: [AgentWorkItem] = []
|
||||
var adjacent: [AgentToolCard] = []
|
||||
var adjacentSourceMessageID: UUID?
|
||||
func flush() {
|
||||
guard !adjacent.isEmpty else { return }
|
||||
if adjacent.count == 1 {
|
||||
grouped.append(.tool(adjacent[0]))
|
||||
} else if let sourceMessageID = adjacentSourceMessageID {
|
||||
let hasUnfinished = adjacent.contains { !$0.state.isTerminal }
|
||||
grouped.append(.tools(AgentToolBatch(
|
||||
cards: adjacent,
|
||||
sourceMessageID: sourceMessageID,
|
||||
reasoningTitle: hasUnfinished ? reasoningTitles[sourceMessageID] : nil
|
||||
)))
|
||||
}
|
||||
adjacent.removeAll(keepingCapacity: true)
|
||||
adjacentSourceMessageID = nil
|
||||
let batched = items.compactMap { item -> AgentToolCard? in
|
||||
guard case .tool(let card) = item,
|
||||
card.descriptor.presentation?.groupsWithAdjacentTools != false
|
||||
else { return nil }
|
||||
return card
|
||||
}
|
||||
guard !batched.isEmpty else { return items }
|
||||
|
||||
var grouped: [AgentWorkItem] = []
|
||||
var insertedBatch = false
|
||||
for item in items {
|
||||
if case .tool(let card) = item {
|
||||
if card.descriptor.presentation?.groupsWithAdjacentTools == false {
|
||||
flush()
|
||||
grouped.append(.tool(card))
|
||||
continue
|
||||
} else if !insertedBatch {
|
||||
grouped.append(.tools(AgentToolBatch(
|
||||
cards: batched,
|
||||
runID: runID,
|
||||
reasoning: reasoning
|
||||
)))
|
||||
insertedBatch = true
|
||||
}
|
||||
guard let sourceMessageID = sourceMessageIDs[card.id] else {
|
||||
flush()
|
||||
grouped.append(.tool(card))
|
||||
continue
|
||||
}
|
||||
if let adjacentSourceMessageID,
|
||||
adjacentSourceMessageID != sourceMessageID
|
||||
{
|
||||
flush()
|
||||
}
|
||||
adjacentSourceMessageID = sourceMessageID
|
||||
adjacent.append(card)
|
||||
} else {
|
||||
flush()
|
||||
grouped.append(item)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return grouped
|
||||
}
|
||||
|
||||
@@ -1531,6 +1561,7 @@ final class ConversationModel: Identifiable {
|
||||
|
||||
func stop() {
|
||||
settleStreamingPresentation(for: activeRunID)
|
||||
publishAllStagedTools()
|
||||
for index in toolCards.indices where toolCards[index].state == .running {
|
||||
toolCards[index].state = .interrupted
|
||||
}
|
||||
@@ -2043,6 +2074,7 @@ final class ConversationModel: Identifiable {
|
||||
func handle(_ event: AgentEvent) {
|
||||
switch event {
|
||||
case .runState(let state):
|
||||
if state.isTerminal { publishAllStagedTools() }
|
||||
runtimeState = state
|
||||
if state.isTerminal { settleStreamingPresentation(for: activeRunID) }
|
||||
refreshRunState()
|
||||
@@ -2063,6 +2095,7 @@ final class ConversationModel: Identifiable {
|
||||
messageRunIDs[prompt.id] = run.id
|
||||
}
|
||||
case .runFinished(let run):
|
||||
publishAllStagedTools()
|
||||
settleStreamingPresentation(for: run.id)
|
||||
runSummaries[run.id] = run
|
||||
if activeRunID == run.id { activeRunID = nil }
|
||||
@@ -2070,8 +2103,10 @@ final class ConversationModel: Identifiable {
|
||||
activeReasoningMessageID = nil
|
||||
case .turnFinished:
|
||||
activeReasoningMessageID = nil
|
||||
activeTurnMessageID = nil
|
||||
case .messageStarted(let started):
|
||||
activeReasoningMessageID = nil
|
||||
activeTurnMessageID = started.id
|
||||
if let activeRunID { messageRunIDs[started.id] = activeRunID }
|
||||
if let index = messages.firstIndex(where: { $0.id == started.id }) {
|
||||
messages[index].isStreaming = true
|
||||
@@ -2144,30 +2179,32 @@ final class ConversationModel: Identifiable {
|
||||
}
|
||||
contextEstimateTracker.upsert(message)
|
||||
publishContextEstimateImmediately()
|
||||
publishStagedTools(
|
||||
for: message.id,
|
||||
force: message.toolCalls.isEmpty
|
||||
)
|
||||
updatedAt = .now
|
||||
case .toolProposed(let invocation, let descriptor):
|
||||
toolCards.append(AgentToolCard(invocation: invocation, descriptor: descriptor))
|
||||
stageTool(invocation, descriptor: descriptor)
|
||||
case .toolStarted(let invocation): setToolState(invocation.id, .running)
|
||||
// The card only, and the card's state is left alone: this is a call
|
||||
// still running, showing its work. Nothing here reaches `transcript` —
|
||||
// the model is owed one result per call, and it gets that from
|
||||
// `toolFinished`.
|
||||
case .toolProgress(let invocation, let partial):
|
||||
if let index = toolCards.firstIndex(where: { $0.id == invocation.id }) {
|
||||
toolCards[index].result = partial
|
||||
}
|
||||
updateToolCard(invocation.id) { $0.result = partial }
|
||||
case .toolFinished(let invocation, let result):
|
||||
if let index = toolCards.firstIndex(where: { $0.id == invocation.id }) {
|
||||
toolCards[index].result = result
|
||||
updateToolCard(invocation.id) { card in
|
||||
card.result = result
|
||||
// A denied call did not fail — someone said no. The denial is
|
||||
// an error to the *model*, which is what makes the turn
|
||||
// replayable, but the card kept being relabelled `Failed` the
|
||||
// instant it was refused, so `denied` never reached the screen.
|
||||
if toolCards[index].state != .denied {
|
||||
if card.state != .denied {
|
||||
if result.isInterrupted {
|
||||
toolCards[index].state = .interrupted
|
||||
card.state = .interrupted
|
||||
} else {
|
||||
toolCards[index].state = result.isError ? .failed : .completed
|
||||
card.state = result.isError ? .failed : .completed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2194,9 +2231,7 @@ final class ConversationModel: Identifiable {
|
||||
refreshRunState()
|
||||
case .reviewFinished(let request, let decision):
|
||||
reviewing.remove(request.invocation.id)
|
||||
if let index = toolCards.firstIndex(where: { $0.id == request.invocation.id }) {
|
||||
toolCards[index].review = decision
|
||||
}
|
||||
updateToolCard(request.invocation.id) { $0.review = decision }
|
||||
refreshRunState()
|
||||
case .reviewFailed(let request, _, _):
|
||||
reviewing.remove(request.invocation.id)
|
||||
@@ -2222,9 +2257,90 @@ final class ConversationModel: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
private func stageTool(
|
||||
_ invocation: AgentToolInvocation,
|
||||
descriptor: AgentToolDescriptor
|
||||
) {
|
||||
let card = AgentToolCard(invocation: invocation, descriptor: descriptor)
|
||||
guard let sourceMessageID = invocation.sourceMessageID else {
|
||||
toolCards.append(card)
|
||||
return
|
||||
}
|
||||
if stagedToolCards[invocation.id] == nil {
|
||||
stagedToolOrder.append(invocation.id)
|
||||
}
|
||||
stagedToolCards[invocation.id] = card
|
||||
|
||||
guard let source = transcript.first(where: { $0.id == sourceMessageID }) else {
|
||||
// Provider-native activities arrive while the assistant message is
|
||||
// still streaming. Keep all of them staged until messageFinished
|
||||
// seals that turn, then publish the complete set in one mutation.
|
||||
if activeTurnMessageID == sourceMessageID { return }
|
||||
// Restored/legacy/manual event streams may not carry the source
|
||||
// assistant message. There is no authoritative call set to await,
|
||||
// so prefer a visible stable batch over hiding the card forever.
|
||||
publishStagedTools(for: sourceMessageID, force: true)
|
||||
return
|
||||
}
|
||||
publishStagedTools(
|
||||
for: sourceMessageID,
|
||||
force: source.toolCalls.isEmpty
|
||||
)
|
||||
}
|
||||
|
||||
/// Publishes every card for a provider response in one observable mutation.
|
||||
/// `force` is used for provider-native activities, whose synthetic calls do
|
||||
/// not occur in the assistant message's model-facing `toolCalls` array.
|
||||
private func publishStagedTools(for sourceMessageID: UUID, force: Bool = false) {
|
||||
let stagedIDs = stagedToolOrder.filter {
|
||||
stagedToolCards[$0]?.invocation.sourceMessageID == sourceMessageID
|
||||
}
|
||||
guard !stagedIDs.isEmpty else { return }
|
||||
|
||||
if !force {
|
||||
guard let source = transcript.first(where: { $0.id == sourceMessageID }),
|
||||
!source.toolCalls.isEmpty
|
||||
else { return }
|
||||
let stagedCallIDs = Set(stagedIDs.compactMap {
|
||||
stagedToolCards[$0]?.invocation.call.id
|
||||
})
|
||||
guard source.toolCalls.allSatisfy({ stagedCallIDs.contains($0.id) }) else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let cards = stagedIDs.compactMap { stagedToolCards.removeValue(forKey: $0) }
|
||||
let published = Set(stagedIDs)
|
||||
stagedToolOrder.removeAll { published.contains($0) }
|
||||
toolCards.append(contentsOf: cards)
|
||||
}
|
||||
|
||||
private func publishAllStagedTools() {
|
||||
guard !stagedToolOrder.isEmpty else { return }
|
||||
let cards = stagedToolOrder.compactMap { stagedToolCards.removeValue(forKey: $0) }
|
||||
stagedToolOrder.removeAll(keepingCapacity: true)
|
||||
toolCards.append(contentsOf: cards)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func updateToolCard(
|
||||
_ id: UUID,
|
||||
_ update: (inout AgentToolCard) -> Void
|
||||
) -> Bool {
|
||||
if var staged = stagedToolCards[id] {
|
||||
update(&staged)
|
||||
stagedToolCards[id] = staged
|
||||
return true
|
||||
}
|
||||
guard let index = toolCards.firstIndex(where: { $0.id == id }) else {
|
||||
return false
|
||||
}
|
||||
update(&toolCards[index])
|
||||
return true
|
||||
}
|
||||
|
||||
private func setToolState(_ id: UUID, _ state: AgentToolCard.State) {
|
||||
guard let index = toolCards.firstIndex(where: { $0.id == id }) else { return }
|
||||
toolCards[index].state = state
|
||||
updateToolCard(id) { $0.state = state }
|
||||
}
|
||||
|
||||
/// Adds a final speech-recognition result without disturbing text the user
|
||||
|
||||
@@ -460,10 +460,8 @@ private struct AgentToolBatchView: View {
|
||||
}
|
||||
|
||||
private var displayedReasoning: String? {
|
||||
guard !unfinished.isEmpty, let reasoning = batch.reasoningTitle,
|
||||
AgentToolPresentation.showsReasoning(forBatchID: batch.id)
|
||||
else { return nil }
|
||||
return reasoning
|
||||
guard !unfinished.isEmpty, let reasoning = batch.reasoning else { return nil }
|
||||
return AgentToolPresentation.reasoningTitle(from: reasoning.markdown)
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
|
||||
@@ -252,6 +252,140 @@ nonisolated enum TermindMarkdownParser {
|
||||
.joined(separator: " ")
|
||||
}
|
||||
|
||||
/// A one-line AppKit projection for transient tool titles. It applies the
|
||||
/// same sanitization as the full transcript renderer, preserves inline
|
||||
/// emphasis/code/strikethrough/safe links, and flattens block structure
|
||||
/// without allowing it to change the disclosure header's height.
|
||||
@MainActor
|
||||
static func singleLineAttributedText(
|
||||
_ source: String,
|
||||
font: NSFont = .systemFont(ofSize: NSFont.systemFontSize),
|
||||
color: NSColor = .secondaryLabelColor
|
||||
) -> NSAttributedString {
|
||||
let result = NSMutableAttributedString()
|
||||
let base: [NSAttributedString.Key: Any] = [
|
||||
.font: font,
|
||||
.foregroundColor: color,
|
||||
]
|
||||
|
||||
func append(_ value: String, attributes: [NSAttributedString.Key: Any]) {
|
||||
result.append(NSAttributedString(string: value, attributes: attributes))
|
||||
}
|
||||
func separator() {
|
||||
guard result.length > 0,
|
||||
!result.string.hasSuffix(" ")
|
||||
else { return }
|
||||
append(" ", attributes: base)
|
||||
}
|
||||
func converted(
|
||||
_ source: NSFont,
|
||||
trait: NSFontTraitMask
|
||||
) -> NSFont {
|
||||
NSFontManager.shared.convert(source, toHaveTrait: trait)
|
||||
}
|
||||
func inlines(
|
||||
_ nodes: [MarkdownInlineNode],
|
||||
attributes: [NSAttributedString.Key: Any]
|
||||
) {
|
||||
for node in nodes {
|
||||
switch node {
|
||||
case .text(let text), .html(let text), .math(let text, _):
|
||||
append(text, attributes: attributes)
|
||||
case .code(let text):
|
||||
var code = attributes
|
||||
let size = (attributes[.font] as? NSFont)?.pointSize ?? font.pointSize
|
||||
code[.font] = NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
||||
append(text, attributes: code)
|
||||
case .softBreak, .lineBreak:
|
||||
separator()
|
||||
case .emphasis(let children):
|
||||
var emphasized = attributes
|
||||
emphasized[.font] = converted(
|
||||
attributes[.font] as? NSFont ?? font,
|
||||
trait: .italicFontMask
|
||||
)
|
||||
inlines(children, attributes: emphasized)
|
||||
case .strong(let children):
|
||||
var strong = attributes
|
||||
strong[.font] = converted(
|
||||
attributes[.font] as? NSFont ?? font,
|
||||
trait: .boldFontMask
|
||||
)
|
||||
inlines(children, attributes: strong)
|
||||
case .strikethrough(let children):
|
||||
var struck = attributes
|
||||
struck[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
|
||||
inlines(children, attributes: struck)
|
||||
case .link(let destination, let children):
|
||||
var link = attributes
|
||||
if let url = URL(string: destination), isAllowedLink(destination) {
|
||||
link[.link] = url
|
||||
link[.foregroundColor] = NSColor.linkColor
|
||||
link[.underlineStyle] = NSUnderlineStyle.single.rawValue
|
||||
}
|
||||
inlines(children, attributes: link)
|
||||
case .image(_, let children):
|
||||
inlines(children, attributes: attributes)
|
||||
}
|
||||
}
|
||||
}
|
||||
func blocks(_ nodes: [MarkdownBlockNode]) {
|
||||
for node in nodes {
|
||||
separator()
|
||||
switch node {
|
||||
case .paragraph(let content), .heading(_, let content):
|
||||
inlines(content, attributes: base)
|
||||
case .codeBlock(_, let content):
|
||||
append(
|
||||
content,
|
||||
attributes: [
|
||||
.font: NSFont.monospacedSystemFont(
|
||||
ofSize: font.pointSize,
|
||||
weight: .regular
|
||||
),
|
||||
.foregroundColor: color,
|
||||
]
|
||||
)
|
||||
case .blockquote(let children):
|
||||
blocks(children)
|
||||
case .bulletedList(_, let items), .numberedList(_, _, let items):
|
||||
for item in items { blocks(item.children) }
|
||||
case .taskList(_, let items):
|
||||
for item in items { blocks(item.children) }
|
||||
case .table(_, let rows):
|
||||
for row in rows {
|
||||
for cell in row.cells {
|
||||
inlines(cell.content, attributes: base)
|
||||
separator()
|
||||
}
|
||||
}
|
||||
case .thematicBreak:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
blocks(parse(source))
|
||||
let whitespace = try? NSRegularExpression(pattern: #"\s+"#)
|
||||
if let matches = whitespace?.matches(
|
||||
in: result.string,
|
||||
range: NSRange(location: 0, length: result.length)
|
||||
) {
|
||||
for match in matches.reversed() {
|
||||
result.replaceCharacters(in: match.range, with: " ")
|
||||
}
|
||||
}
|
||||
while result.string.hasPrefix(" ") {
|
||||
result.deleteCharacters(in: NSRange(location: 0, length: 1))
|
||||
}
|
||||
while result.string.hasSuffix(" ") {
|
||||
result.deleteCharacters(
|
||||
in: NSRange(location: result.length - 1, length: 1)
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func plainText(_ block: MarkdownBlockNode) -> [String] {
|
||||
switch block {
|
||||
case .paragraph(let content), .heading(_, let content):
|
||||
|
||||
@@ -107,11 +107,17 @@ nonisolated struct TranscriptEntry: Identifiable, Hashable, Sendable {
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TranscriptPresentationState {
|
||||
static let reasoningTitleDuration: TimeInterval = 3
|
||||
|
||||
private struct DisclosureState {
|
||||
var isExpanded: Bool
|
||||
var readerDidToggle: Bool
|
||||
var revision: UInt64
|
||||
}
|
||||
private struct ReasoningTitleState {
|
||||
var revision: String
|
||||
var expiresAt: Date
|
||||
}
|
||||
private struct MessageEditState {
|
||||
var isEditing = false
|
||||
var text = ""
|
||||
@@ -120,6 +126,8 @@ final class TranscriptPresentationState {
|
||||
}
|
||||
|
||||
private var disclosureStates: [UUID: DisclosureState] = [:]
|
||||
private var pendingAutoCollapseIDs: Set<UUID> = []
|
||||
private var reasoningTitles: [UUID: ReasoningTitleState] = [:]
|
||||
private var messageEdits: [UUID: MessageEditState] = [:]
|
||||
private var nextRevision: UInt64 = 1
|
||||
|
||||
@@ -128,6 +136,7 @@ final class TranscriptPresentationState {
|
||||
}
|
||||
func readerDidToggle(_ id: UUID) -> Bool { disclosureStates[id]?.readerDidToggle == true }
|
||||
func toggleDisclosure(_ id: UUID, default value: Bool = false) {
|
||||
pendingAutoCollapseIDs.remove(id)
|
||||
disclosureStates[id] = .init(
|
||||
isExpanded: !(disclosureStates[id]?.isExpanded ?? value),
|
||||
readerDidToggle: true,
|
||||
@@ -136,15 +145,56 @@ final class TranscriptPresentationState {
|
||||
}
|
||||
func followDefault(_ expanded: Bool, for id: UUID) {
|
||||
guard disclosureStates[id]?.readerDidToggle != true else { return }
|
||||
pendingAutoCollapseIDs.remove(id)
|
||||
disclosureStates[id] = .init(
|
||||
isExpanded: expanded, readerDidToggle: false, revision: takeRevision()
|
||||
)
|
||||
}
|
||||
func deferAutoCollapse(_ id: UUID) {
|
||||
guard disclosureStates[id]?.readerDidToggle != true else { return }
|
||||
pendingAutoCollapseIDs.insert(id)
|
||||
disclosureStates[id] = .init(
|
||||
isExpanded: true, readerDidToggle: false, revision: takeRevision()
|
||||
)
|
||||
}
|
||||
func hasPendingAutoCollapse(_ id: UUID) -> Bool {
|
||||
pendingAutoCollapseIDs.contains(id)
|
||||
}
|
||||
@discardableResult
|
||||
func completeAutoCollapse(_ id: UUID) -> Bool {
|
||||
guard pendingAutoCollapseIDs.remove(id) != nil,
|
||||
disclosureStates[id]?.readerDidToggle != true
|
||||
else { return false }
|
||||
disclosureStates[id] = .init(
|
||||
isExpanded: false, readerDidToggle: false, revision: takeRevision()
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Returns an absolute deadline so remounting or measuring a row cannot
|
||||
/// restart its transient reasoning title. A changed Markdown revision does
|
||||
/// deliberately receive a fresh three-second window.
|
||||
func reasoningTitleDeadline(
|
||||
for id: UUID,
|
||||
revision: String,
|
||||
now: Date = .now
|
||||
) -> Date {
|
||||
if let state = reasoningTitles[id], state.revision == revision {
|
||||
return state.expiresAt
|
||||
}
|
||||
let deadline = now.addingTimeInterval(Self.reasoningTitleDuration)
|
||||
reasoningTitles[id] = .init(revision: revision, expiresAt: deadline)
|
||||
return deadline
|
||||
}
|
||||
func prune(validTimelineIDs: Set<UUID>) {
|
||||
let staleDisclosures = disclosureStates.keys.filter { !validTimelineIDs.contains($0) }
|
||||
let staleReasoningTitles = reasoningTitles.keys.filter { !validTimelineIDs.contains($0) }
|
||||
let staleEdits = messageEdits.keys.filter { !validTimelineIDs.contains($0) }
|
||||
guard !staleDisclosures.isEmpty || !staleEdits.isEmpty else { return }
|
||||
guard !staleDisclosures.isEmpty || !staleReasoningTitles.isEmpty || !staleEdits.isEmpty
|
||||
else { return }
|
||||
for id in staleDisclosures { disclosureStates[id] = nil }
|
||||
pendingAutoCollapseIDs.formIntersection(validTimelineIDs)
|
||||
for id in staleReasoningTitles { reasoningTitles[id] = nil }
|
||||
for id in staleEdits { messageEdits[id] = nil }
|
||||
}
|
||||
|
||||
@@ -254,7 +304,7 @@ struct TranscriptListView: NSViewRepresentable {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class Coordinator {
|
||||
final class Coordinator: NSObject {
|
||||
private var content: [TranscriptEntry] = []
|
||||
private var latestUserMessageID: UUID?
|
||||
private var configuration: TranscriptListConfiguration?
|
||||
@@ -266,6 +316,11 @@ struct TranscriptListView: NSViewRepresentable {
|
||||
private var invalidations: Set<UUID> = []
|
||||
private var invalidationScheduled = false
|
||||
private var invalidationMayFollow = false
|
||||
private var autoCollapseCheckScheduled = false
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
func makeListView() -> NSViewType {
|
||||
let view = NSViewType()
|
||||
@@ -274,6 +329,13 @@ struct TranscriptListView: NSViewRepresentable {
|
||||
view.topInset = Metrics.Space.xl
|
||||
view.bottomInset = Metrics.Space.xl
|
||||
view.rowAnimator = nil
|
||||
view.postsBoundsChangedNotifications = true
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(viewportDidChange),
|
||||
name: NSView.boundsDidChangeNotification,
|
||||
object: view
|
||||
)
|
||||
view.rows {
|
||||
ListRow(TranscriptEmptyRow.self)
|
||||
.when { $0.kind == .empty }
|
||||
@@ -421,6 +483,12 @@ struct TranscriptListView: NSViewRepresentable {
|
||||
sizingPool.removeAll()
|
||||
listView.reloadData()
|
||||
}
|
||||
prepareWorkCompletionTransitions(
|
||||
from: content,
|
||||
to: entries,
|
||||
in: listView,
|
||||
presentation: presentation
|
||||
)
|
||||
let plan = TranscriptListUpdatePlan.make(old: content, new: entries)
|
||||
if plan.replacesAll {
|
||||
if !content.isEmpty { sizingPool.removeAll() }
|
||||
@@ -435,6 +503,72 @@ struct TranscriptListView: NSViewRepresentable {
|
||||
content = entries
|
||||
self.latestUserMessageID = latestUserMessageID
|
||||
followBottom(listView, if: follow)
|
||||
schedulePendingAutoCollapseCheck()
|
||||
}
|
||||
|
||||
/// A successful run is allowed to adopt its collapsed default only
|
||||
/// after the row is wholly outside the reader's viewport. Partial
|
||||
/// visibility is still visibility: collapsing then would remove words
|
||||
/// from under the reader and change the height they are looking at.
|
||||
private func prepareWorkCompletionTransitions(
|
||||
from old: [TranscriptEntry],
|
||||
to new: [TranscriptEntry],
|
||||
in view: NSViewType,
|
||||
presentation: TranscriptPresentationState
|
||||
) {
|
||||
let previous = Dictionary(uniqueKeysWithValues: old.map { ($0.id, $0) })
|
||||
for entry in new {
|
||||
guard case .work(let group) = entry.content,
|
||||
group.run.state == .completed,
|
||||
!group.appearsAfterAnswer,
|
||||
case .work(let oldGroup)? = previous[entry.id]?.content,
|
||||
oldGroup.run.state != .completed,
|
||||
!presentation.readerDidToggle(entry.id)
|
||||
else { continue }
|
||||
|
||||
if rowIntersectsViewport(entry.id, in: view) {
|
||||
presentation.deferAutoCollapse(entry.id)
|
||||
} else {
|
||||
presentation.followDefault(false, for: entry.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func viewportDidChange() {
|
||||
schedulePendingAutoCollapseCheck()
|
||||
}
|
||||
|
||||
private func schedulePendingAutoCollapseCheck() {
|
||||
guard !autoCollapseCheckScheduled else { return }
|
||||
autoCollapseCheckScheduled = true
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
autoCollapseCheckScheduled = false
|
||||
collapsePendingWorkOutsideViewport()
|
||||
}
|
||||
}
|
||||
|
||||
private func collapsePendingWorkOutsideViewport() {
|
||||
guard let view = listView, let presentation else { return }
|
||||
var collapsed: [UUID] = []
|
||||
for entry in content where presentation.hasPendingAutoCollapse(entry.id) {
|
||||
guard !rowIntersectsViewport(entry.id, in: view),
|
||||
presentation.completeAutoCollapse(entry.id)
|
||||
else { continue }
|
||||
collapsed.append(entry.id)
|
||||
}
|
||||
guard !collapsed.isEmpty else { return }
|
||||
for id in collapsed { refreshDisplayedRow(id, in: view) }
|
||||
view.layoutSubtreeIfNeeded()
|
||||
}
|
||||
|
||||
private func rowIntersectsViewport(_ id: UUID, in view: NSViewType) -> Bool {
|
||||
let row = view.rectForRow(with: id)
|
||||
guard !row.isEmpty, view.bounds.width > 0, view.bounds.height > 0 else {
|
||||
return false
|
||||
}
|
||||
let viewport = CGRect(origin: view.contentOffset, size: view.bounds.size)
|
||||
return row.intersects(viewport)
|
||||
}
|
||||
|
||||
private func height(for entry: TranscriptEntry, width: CGFloat) -> CGFloat {
|
||||
|
||||
@@ -873,25 +873,14 @@ private enum NativeTranscriptFactory {
|
||||
let stack = NativeStack.vertical(spacing: Metrics.Space.s)
|
||||
let expanded = context.presentation.isExpanded(batch.id)
|
||||
let unfinished = batch.cards.filter { !$0.state.isTerminal }
|
||||
let displayedReasoning: String? =
|
||||
if !unfinished.isEmpty,
|
||||
let reasoning = batch.reasoningTitle,
|
||||
AgentToolPresentation.showsReasoning(forBatchID: batch.id)
|
||||
{
|
||||
reasoning
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
let title: String
|
||||
let fallbackTitle: String
|
||||
if unfinished.isEmpty {
|
||||
title = AgentToolPresentation.batchSummary(
|
||||
fallbackTitle = AgentToolPresentation.batchSummary(
|
||||
for: batch.cards,
|
||||
locale: context.configuration.locale
|
||||
)
|
||||
} else if let displayedReasoning {
|
||||
title = displayedReasoning
|
||||
} else {
|
||||
title = unfinished.map { card in
|
||||
fallbackTitle = unfinished.map { card in
|
||||
AgentToolPresentation.summary(
|
||||
for: card,
|
||||
hostName: card.invocation.targetHostID.flatMap(context.actions.hostName),
|
||||
@@ -899,16 +888,27 @@ private enum NativeTranscriptFactory {
|
||||
)
|
||||
}.joined(separator: "; ")
|
||||
}
|
||||
let transientReasoning = unfinished.isEmpty ? nil : batch.reasoning
|
||||
let reasoningDeadline = transientReasoning.flatMap { reasoning in
|
||||
context.measurement
|
||||
? nil
|
||||
: context.presentation.reasoningTitleDeadline(
|
||||
for: batch.id,
|
||||
revision: reasoning.revision
|
||||
)
|
||||
}
|
||||
let header = NativeDisclosureHeader(
|
||||
symbol: AgentToolPresentation.batchSymbol(for: batch.cards),
|
||||
title: title,
|
||||
title: fallbackTitle,
|
||||
iconColor: batchColor(AgentToolPresentation.batchTint(for: batch.cards)),
|
||||
titleColor: .secondaryLabelColor,
|
||||
expanded: expanded,
|
||||
active: !unfinished.isEmpty,
|
||||
reduceMotion: context.configuration.reduceMotion,
|
||||
measurement: context.measurement,
|
||||
lineLimit: displayedReasoning == nil ? 0 : 1
|
||||
lineLimit: 1,
|
||||
transientMarkdownTitle: transientReasoning?.markdown,
|
||||
transientUntil: reasoningDeadline
|
||||
)
|
||||
header.toolTip = expanded ? String(localized: "Hide tools") : String(localized: "Show tools")
|
||||
header.handler = {
|
||||
@@ -928,8 +928,13 @@ private enum NativeTranscriptFactory {
|
||||
|
||||
private static func plan(_ card: AgentPlanCard, context: NativeTranscriptContext) -> NSView {
|
||||
let container = NativeCard(radius: Metrics.Radius.large)
|
||||
container.fillProvider = { NSColor.quaternaryLabelColor.withAlphaComponent(0.2) }
|
||||
container.strokeProvider = { NSColor.quaternaryLabelColor }
|
||||
// Keep the plan as the quiet, paper-like object it was in the SwiftUI
|
||||
// transcript. `controlBackgroundColor` is the system's primary light
|
||||
// surface (and remains appearance-aware in Dark Mode); using a label
|
||||
// hierarchy color as a fill made the AppKit port noticeably grey.
|
||||
container.fillProvider = { .controlBackgroundColor }
|
||||
container.strokeProvider = { .separatorColor }
|
||||
container.layer?.masksToBounds = true
|
||||
let stack = NativeStack.vertical(spacing: Metrics.Space.s)
|
||||
stack.edgeInsets = .init(
|
||||
top: Metrics.Space.l,
|
||||
@@ -965,7 +970,8 @@ private enum NativeTranscriptFactory {
|
||||
reduceMotion: context.configuration.reduceMotion,
|
||||
measurement: context.measurement,
|
||||
lineLimit: expanded ? 0 : 1,
|
||||
trailingText: outcome
|
||||
trailingText: outcome,
|
||||
titleFont: .systemFont(ofSize: NSFont.systemFontSize, weight: .medium)
|
||||
)
|
||||
header.toolTip = expanded ? String(localized: "Hide the plan") : String(localized: "Show the plan")
|
||||
header.handler = {
|
||||
@@ -977,6 +983,8 @@ private enum NativeTranscriptFactory {
|
||||
source: card.plan.body,
|
||||
slot: "plan",
|
||||
shell: true,
|
||||
horizontalInset: Metrics.Space.l * 2
|
||||
+ Metrics.Sidebar.rowIcon + Metrics.Space.s,
|
||||
context: context
|
||||
)
|
||||
let indented = NativeStack.vertical()
|
||||
@@ -995,7 +1003,7 @@ private enum NativeTranscriptFactory {
|
||||
indented.addArrangedSubview(preview)
|
||||
stack.addArrangedSubview(indented)
|
||||
if card.outcome == .pending {
|
||||
let actions = NativeStack.horizontal()
|
||||
let actions = NativeStack.horizontal(spacing: Metrics.Space.m)
|
||||
let discard = NativeButton(title: String(localized: "Discard"))
|
||||
discard.contentTintColor = .systemRed
|
||||
discard.handler = { context.actions.resolvePlan(card.id, .discarded) }
|
||||
@@ -1101,6 +1109,7 @@ private enum NativeTranscriptFactory {
|
||||
shell: Bool,
|
||||
tone: TermindMarkdownView.Tone = .primary,
|
||||
fillsWidth: Bool = true,
|
||||
horizontalInset: CGFloat = 0,
|
||||
context: NativeTranscriptContext
|
||||
) -> NativeMarkdownView {
|
||||
let view = NativeMarkdownView()
|
||||
@@ -1113,7 +1122,7 @@ private enum NativeTranscriptFactory {
|
||||
tone: tone
|
||||
),
|
||||
measurement: context.measurement,
|
||||
proposedWidth: context.contentWidth,
|
||||
proposedWidth: max(1, context.contentWidth - horizontalInset),
|
||||
fillsWidth: fillsWidth,
|
||||
actions: .init(
|
||||
enabled: context.configuration.showsTerminalActions
|
||||
@@ -1124,16 +1133,26 @@ private enum NativeTranscriptFactory {
|
||||
return view
|
||||
}
|
||||
|
||||
private static func labelRow(symbol: String, text: String, color: NSColor) -> NSView {
|
||||
let row = NativeStack.horizontal()
|
||||
private static func labelRow(
|
||||
symbol: String,
|
||||
text: String,
|
||||
color: NSColor,
|
||||
font: NSFont = .systemFont(ofSize: NSFont.systemFontSize),
|
||||
spacing: CGFloat = Metrics.Space.s,
|
||||
symbolPointSize: CGFloat? = nil
|
||||
) -> NSView {
|
||||
let row = NativeStack.horizontal(spacing: spacing)
|
||||
let image = NSImageView(
|
||||
image: NSImage(
|
||||
systemSymbolName: symbol,
|
||||
accessibilityDescription: nil
|
||||
) ?? NSImage())
|
||||
image.contentTintColor = color
|
||||
if let symbolPointSize {
|
||||
image.symbolConfiguration = .init(pointSize: symbolPointSize, weight: .medium)
|
||||
}
|
||||
row.addArrangedSubview(image)
|
||||
row.addArrangedSubview(NativeLabel(text, color: color))
|
||||
row.addArrangedSubview(NativeLabel(text, font: font, color: color))
|
||||
return row
|
||||
}
|
||||
|
||||
@@ -1143,11 +1162,18 @@ private enum NativeTranscriptFactory {
|
||||
) -> NSView {
|
||||
let assessment = command.risk
|
||||
let card = NativeCard(radius: Metrics.Radius.medium)
|
||||
card.fillProvider = { NSColor.quaternaryLabelColor.withAlphaComponent(0.2) }
|
||||
card.identifier = NSUserInterfaceItemIdentifier("Transcript.CommandCard")
|
||||
// Match the hierarchical SwiftUI fills from the original command card.
|
||||
// `quaternaryLabelColor` already carries an alpha component, so replacing
|
||||
// it with 0.2/0.4 made the AppKit port roughly ten times darker.
|
||||
card.fillProvider = {
|
||||
nativeHierarchicalColor(.quaternaryLabelColor, opacity: 0.2)
|
||||
}
|
||||
if assessment.risk == .destructive {
|
||||
card.strokeProvider = { NSColor.systemRed.withAlphaComponent(0.4) }
|
||||
}
|
||||
let stack = NativeStack.vertical()
|
||||
let stack = NativeStack.vertical(spacing: Metrics.Space.m)
|
||||
stack.identifier = NSUserInterfaceItemIdentifier("Transcript.CommandCardContent")
|
||||
stack.edgeInsets = .init(
|
||||
top: Metrics.Space.l,
|
||||
left: Metrics.Space.l,
|
||||
@@ -1155,29 +1181,42 @@ private enum NativeTranscriptFactory {
|
||||
right: Metrics.Space.l
|
||||
)
|
||||
card.setContent(stack)
|
||||
let header = NativeStack.horizontal(spacing: Metrics.Space.xs)
|
||||
let header = NativeStack.horizontal(spacing: Metrics.Space.m)
|
||||
header.identifier = NSUserInterfaceItemIdentifier("Transcript.CommandCardHeader")
|
||||
header.addArrangedSubview(
|
||||
labelRow(
|
||||
symbol: "sparkles",
|
||||
text: String(localized: "Suggested Command"),
|
||||
color: .secondaryLabelColor
|
||||
color: .secondaryLabelColor,
|
||||
font: .systemFont(
|
||||
ofSize: NSFont.smallSystemFontSize,
|
||||
weight: .medium
|
||||
),
|
||||
spacing: Metrics.Space.xs,
|
||||
symbolPointSize: NSFont.smallSystemFontSize
|
||||
))
|
||||
header.addArrangedSubview(NativeFlexibleSpacer())
|
||||
header.addArrangedSubview(NativeRiskBadge(assessment: assessment))
|
||||
stack.addArrangedSubview(header)
|
||||
|
||||
let commandBackground = NativeCard(radius: Metrics.Radius.small)
|
||||
commandBackground.identifier = NSUserInterfaceItemIdentifier(
|
||||
"Transcript.CommandCardCommandBackground"
|
||||
)
|
||||
commandBackground.fillProvider = {
|
||||
NSColor.quaternaryLabelColor.withAlphaComponent(0.4)
|
||||
nativeHierarchicalColor(.quaternaryLabelColor, opacity: 0.4)
|
||||
}
|
||||
let commandLabel = NativeLabel(
|
||||
command.command,
|
||||
font: .monospacedSystemFont(
|
||||
ofSize: NSFont.systemFontSize,
|
||||
ofSize: NSFont.systemFontSize - 1,
|
||||
weight: .regular
|
||||
),
|
||||
selectable: true
|
||||
)
|
||||
commandLabel.identifier = NSUserInterfaceItemIdentifier(
|
||||
"Transcript.CommandCardCommand"
|
||||
)
|
||||
let commandInsets = NativeStack.vertical()
|
||||
commandInsets.edgeInsets = .init(
|
||||
top: Metrics.Space.m,
|
||||
@@ -1189,23 +1228,34 @@ private enum NativeTranscriptFactory {
|
||||
commandBackground.setContent(commandInsets)
|
||||
stack.addArrangedSubview(commandBackground)
|
||||
if !command.explanation.isEmpty {
|
||||
stack.addArrangedSubview(
|
||||
NativeLabel(
|
||||
command.explanation,
|
||||
color: .secondaryLabelColor
|
||||
))
|
||||
let explanation = NativeLabel(
|
||||
command.explanation,
|
||||
font: .systemFont(ofSize: NSFont.smallSystemFontSize),
|
||||
color: .secondaryLabelColor
|
||||
)
|
||||
explanation.identifier = NSUserInterfaceItemIdentifier(
|
||||
"Transcript.CommandCardExplanation"
|
||||
)
|
||||
stack.addArrangedSubview(explanation)
|
||||
}
|
||||
for reason in assessment.reasons {
|
||||
stack.addArrangedSubview(
|
||||
NativeLabel(
|
||||
"• \(reason)",
|
||||
font: .systemFont(ofSize: NSFont.smallSystemFontSize),
|
||||
color: riskColor(assessment.risk)
|
||||
))
|
||||
if !assessment.reasons.isEmpty {
|
||||
let reasons = NativeStack.vertical(spacing: Metrics.Space.xxs)
|
||||
reasons.identifier = NSUserInterfaceItemIdentifier("Transcript.CommandCardReasons")
|
||||
for reason in assessment.reasons {
|
||||
reasons.addArrangedSubview(
|
||||
NativeLabel(
|
||||
"• \(reason)",
|
||||
font: .systemFont(ofSize: NSFont.smallSystemFontSize - 1),
|
||||
color: riskColor(assessment.risk)
|
||||
))
|
||||
}
|
||||
stack.addArrangedSubview(reasons)
|
||||
}
|
||||
let actions = NativeStack.horizontal()
|
||||
let actions = NativeStack.horizontal(spacing: Metrics.Space.s)
|
||||
actions.identifier = NSUserInterfaceItemIdentifier("Transcript.CommandCardActions")
|
||||
if context.configuration.showsTerminalActions {
|
||||
let insert = NativeButton(title: String(localized: "Insert"))
|
||||
let insert = NativeButton(title: String(localized: "Insert"), bordered: true)
|
||||
insert.identifier = NSUserInterfaceItemIdentifier("Transcript.CommandCardInsert")
|
||||
insert.isEnabled = context.actions.canDeliverCommand()
|
||||
insert.handler = { context.actions.deliverCommand(command.command, false) }
|
||||
let run = NativeButton(
|
||||
@@ -1213,6 +1263,7 @@ private enum NativeTranscriptFactory {
|
||||
bordered: true,
|
||||
prominent: true
|
||||
)
|
||||
run.identifier = NSUserInterfaceItemIdentifier("Transcript.CommandCardRun")
|
||||
run.isEnabled = context.actions.canDeliverCommand()
|
||||
run.bezelColor = assessment.risk == .destructive ? .systemRed : .controlAccentColor
|
||||
run.handler = { [weak run] in
|
||||
@@ -1589,28 +1640,12 @@ private final class NativeMarkdownView: NSView {
|
||||
layer?.mask = nil
|
||||
return
|
||||
}
|
||||
wantsLayer = true
|
||||
let gradient = CAGradientLayer()
|
||||
gradient.colors = [
|
||||
NSColor.white.cgColor,
|
||||
NSColor.white.withAlphaComponent(0.18).cgColor,
|
||||
NSColor.white.cgColor,
|
||||
]
|
||||
gradient.locations = [0, 0.5, 1]
|
||||
gradient.startPoint = CGPoint(x: 0, y: 0.5)
|
||||
gradient.endPoint = CGPoint(x: 1, y: 0.5)
|
||||
layer?.mask = gradient
|
||||
let animation = CABasicAnimation(keyPath: "transform.translation.x")
|
||||
animation.fromValue = -96
|
||||
animation.toValue = 192
|
||||
animation.duration = 1.55
|
||||
animation.repeatCount = .infinity
|
||||
gradient.add(animation, forKey: "scan")
|
||||
installNativeScanningMask(on: self)
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
layer?.mask?.frame = bounds.insetBy(dx: -48, dy: 0)
|
||||
layoutNativeScanningMask(on: self)
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: NSSize {
|
||||
@@ -1876,6 +1911,7 @@ private final class NativeDisclosureHeader: NSControl {
|
||||
private var titleLabel: NativeLabel!
|
||||
private var timer: Timer?
|
||||
private var titleProvider: (() -> String)?
|
||||
private var fallbackTitle: String?
|
||||
|
||||
init(
|
||||
symbol: String?,
|
||||
@@ -1889,7 +1925,10 @@ private final class NativeDisclosureHeader: NSControl {
|
||||
measurement: Bool = false,
|
||||
lineLimit: Int = 1,
|
||||
titleProvider: (() -> String)? = nil,
|
||||
trailingText: String? = nil
|
||||
trailingText: String? = nil,
|
||||
transientMarkdownTitle: String? = nil,
|
||||
transientUntil: Date? = nil,
|
||||
titleFont: NSFont = .systemFont(ofSize: NSFont.systemFontSize)
|
||||
) {
|
||||
super.init(frame: .zero)
|
||||
if let symbol {
|
||||
@@ -1909,7 +1948,22 @@ private final class NativeDisclosureHeader: NSControl {
|
||||
measurement: measurement
|
||||
)
|
||||
} else {
|
||||
titleLabel = NativeLabel(title, color: titleColor)
|
||||
titleLabel = NativeLabel(title, font: titleFont, color: titleColor)
|
||||
}
|
||||
if active {
|
||||
titleLabel.identifier = NSUserInterfaceItemIdentifier(
|
||||
"Transcript.ScanningTitle"
|
||||
)
|
||||
}
|
||||
let showsTransientTitle = transientMarkdownTitle != nil
|
||||
&& transientUntil.map { $0 > .now } == true
|
||||
if showsTransientTitle, let transientMarkdownTitle {
|
||||
titleLabel.attributedStringValue = TermindMarkdownParser.singleLineAttributedText(
|
||||
transientMarkdownTitle,
|
||||
font: .systemFont(ofSize: NSFont.systemFontSize),
|
||||
color: titleColor
|
||||
)
|
||||
titleLabel.allowsEditingTextAttributes = true
|
||||
}
|
||||
titleLabel.maximumNumberOfLines = lineLimit
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
@@ -1953,7 +2007,10 @@ private final class NativeDisclosureHeader: NSControl {
|
||||
row.topAnchor.constraint(equalTo: topAnchor),
|
||||
row.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
])
|
||||
setAccessibilityLabel(title)
|
||||
let accessibleTitle = showsTransientTitle
|
||||
? transientMarkdownTitle.map(TermindMarkdownParser.plainText) ?? title
|
||||
: title
|
||||
setAccessibilityLabel(accessibleTitle)
|
||||
if let titleProvider, !measurement {
|
||||
self.titleProvider = titleProvider
|
||||
timer = Timer.scheduledTimer(
|
||||
@@ -1963,6 +2020,18 @@ private final class NativeDisclosureHeader: NSControl {
|
||||
userInfo: nil,
|
||||
repeats: true
|
||||
)
|
||||
} else if showsTransientTitle, let transientUntil, !measurement {
|
||||
fallbackTitle = title
|
||||
let timer = Timer(
|
||||
fireAt: transientUntil,
|
||||
interval: 0,
|
||||
target: self,
|
||||
selector: #selector(showFallbackTitle),
|
||||
userInfo: nil,
|
||||
repeats: false
|
||||
)
|
||||
self.timer = timer
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1979,6 +2048,15 @@ private final class NativeDisclosureHeader: NSControl {
|
||||
titleLabel.stringValue = titleProvider()
|
||||
}
|
||||
|
||||
@objc private func showFallbackTitle() {
|
||||
guard let fallbackTitle else { return }
|
||||
titleLabel.stringValue = fallbackTitle
|
||||
titleLabel.allowsEditingTextAttributes = false
|
||||
setAccessibilityLabel(fallbackTitle)
|
||||
self.fallbackTitle = nil
|
||||
timer = nil
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
if isEnabled { handler?() }
|
||||
}
|
||||
@@ -2116,10 +2194,14 @@ private final class NativeRiskBadge: NSView {
|
||||
self.assessment = assessment
|
||||
label = NativeLabel(
|
||||
assessment.risk.label,
|
||||
font: .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium),
|
||||
font: .systemFont(
|
||||
ofSize: NSFont.smallSystemFontSize - 1,
|
||||
weight: .medium
|
||||
),
|
||||
color: Self.color(assessment.risk)
|
||||
)
|
||||
super.init(frame: .zero)
|
||||
identifier = NSUserInterfaceItemIdentifier("Transcript.CommandCardRiskBadge")
|
||||
wantsLayer = true
|
||||
layer?.cornerRadius = 10
|
||||
toolTip = assessment.reasons.first ?? assessment.risk.label
|
||||
@@ -2131,6 +2213,10 @@ private final class NativeRiskBadge: NSView {
|
||||
) ?? NSImage()
|
||||
)
|
||||
image.contentTintColor = Self.color(assessment.risk)
|
||||
image.symbolConfiguration = .init(
|
||||
pointSize: NSFont.smallSystemFontSize - 1,
|
||||
weight: .medium
|
||||
)
|
||||
row.addArrangedSubview(image)
|
||||
row.addArrangedSubview(label)
|
||||
row.edgeInsets = .init(top: 2, left: Metrics.Space.s, bottom: 2, right: Metrics.Space.s)
|
||||
@@ -2422,15 +2508,17 @@ private final class NativeActivityLabel: NativeLabel {
|
||||
) {
|
||||
self.alternatives = alternatives
|
||||
super.init(text, color: color)
|
||||
guard !measurement, !reduceMotion, alternatives.count > 1 else { return }
|
||||
timer = Timer.scheduledTimer(
|
||||
timeInterval: 2.8,
|
||||
target: self,
|
||||
selector: #selector(advanceText),
|
||||
userInfo: nil,
|
||||
repeats: true
|
||||
)
|
||||
installScanAnimation()
|
||||
guard !measurement, !reduceMotion else { return }
|
||||
if alternatives.count > 1 {
|
||||
timer = Timer.scheduledTimer(
|
||||
timeInterval: 2.8,
|
||||
target: self,
|
||||
selector: #selector(advanceText),
|
||||
userInfo: nil,
|
||||
repeats: true
|
||||
)
|
||||
}
|
||||
installNativeScanningMask(on: self)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
@@ -2446,32 +2534,89 @@ private final class NativeActivityLabel: NativeLabel {
|
||||
stringValue = alternatives[index]
|
||||
}
|
||||
|
||||
private func installScanAnimation() {
|
||||
wantsLayer = true
|
||||
let gradient = CAGradientLayer()
|
||||
gradient.colors = [
|
||||
NSColor.white.cgColor,
|
||||
NSColor.white.withAlphaComponent(0.18).cgColor,
|
||||
NSColor.white.cgColor,
|
||||
]
|
||||
gradient.locations = [0, 0.5, 1]
|
||||
gradient.startPoint = CGPoint(x: 0, y: 0.5)
|
||||
gradient.endPoint = CGPoint(x: 1, y: 0.5)
|
||||
layer?.mask = gradient
|
||||
let animation = CABasicAnimation(keyPath: "transform.translation.x")
|
||||
animation.fromValue = -80
|
||||
animation.toValue = 120
|
||||
animation.duration = 1.55
|
||||
animation.repeatCount = .infinity
|
||||
gradient.add(animation, forKey: "scan")
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
layer?.mask?.frame = bounds.insetBy(dx: -40, dy: 0)
|
||||
layoutNativeScanningMask(on: self)
|
||||
}
|
||||
}
|
||||
|
||||
/// AppKit equivalent of the historical SwiftUI `ScanningTextMask`: the mask is
|
||||
/// opaque everywhere, then a soft destination-out band travels across it. A
|
||||
/// narrow gradient used directly as the mask makes everything outside that
|
||||
/// gradient disappear, which is the inverse of the intended treatment.
|
||||
private nonisolated final class NativeScanningMaskLayer: CALayer {
|
||||
@NSManaged var progress: CGFloat
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
progress = 0
|
||||
contentsScale = 2
|
||||
}
|
||||
|
||||
override init(layer: Any) {
|
||||
super.init(layer: layer)
|
||||
progress = (layer as? NativeScanningMaskLayer)?.progress ?? 0
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override class func needsDisplay(forKey key: String) -> Bool {
|
||||
key == #keyPath(progress) || super.needsDisplay(forKey: key)
|
||||
}
|
||||
|
||||
override func draw(in context: CGContext) {
|
||||
context.setFillColor(CGColor(gray: 1, alpha: 1))
|
||||
context.fill(bounds)
|
||||
|
||||
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 }
|
||||
|
||||
context.saveGState()
|
||||
context.setBlendMode(.destinationOut)
|
||||
context.drawLinearGradient(
|
||||
gradient,
|
||||
start: CGPoint(x: origin, y: bounds.midY),
|
||||
end: CGPoint(x: origin + bandWidth, y: bounds.midY),
|
||||
options: [.drawsBeforeStartLocation, .drawsAfterEndLocation]
|
||||
)
|
||||
context.restoreGState()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func installNativeScanningMask(on view: NSView) {
|
||||
view.wantsLayer = true
|
||||
let mask = NativeScanningMaskLayer()
|
||||
mask.frame = view.bounds
|
||||
mask.progress = 1
|
||||
view.layer?.mask = mask
|
||||
|
||||
let animation = CABasicAnimation(keyPath: #keyPath(NativeScanningMaskLayer.progress))
|
||||
animation.fromValue = 0
|
||||
animation.toValue = 1
|
||||
animation.duration = 1.55
|
||||
animation.repeatCount = .infinity
|
||||
animation.timingFunction = CAMediaTimingFunction(name: .linear)
|
||||
mask.add(animation, forKey: "scan")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func layoutNativeScanningMask(on view: NSView) {
|
||||
guard let mask = view.layer?.mask as? NativeScanningMaskLayer else { return }
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
mask.frame = view.bounds
|
||||
mask.setNeedsDisplay()
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private class NativeLabel: NSTextField {
|
||||
init(
|
||||
@@ -2523,14 +2668,20 @@ private final class NativeStack: NSStackView {
|
||||
super.addArrangedSubview(view)
|
||||
guard orientation == .vertical else { return }
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.widthAnchor.constraint(equalTo: widthAnchor).isActive = true
|
||||
view.widthAnchor.constraint(
|
||||
equalTo: widthAnchor,
|
||||
constant: -(edgeInsets.left + edgeInsets.right)
|
||||
).isActive = true
|
||||
}
|
||||
|
||||
override func insertArrangedSubview(_ view: NSView, at index: Int) {
|
||||
super.insertArrangedSubview(view, at: index)
|
||||
guard orientation == .vertical else { return }
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.widthAnchor.constraint(equalTo: widthAnchor).isActive = true
|
||||
view.widthAnchor.constraint(
|
||||
equalTo: widthAnchor,
|
||||
constant: -(edgeInsets.left + edgeInsets.right)
|
||||
).isActive = true
|
||||
}
|
||||
|
||||
func removeAllArrangedSubviews() {
|
||||
@@ -2610,11 +2761,18 @@ private class NativeButton: NSButton {
|
||||
self.title = title
|
||||
bezelStyle = bordered ? .rounded : .inline
|
||||
isBordered = bordered
|
||||
controlSize = .small
|
||||
if prominent {
|
||||
bezelColor = .controlAccentColor
|
||||
contentTintColor = .white
|
||||
attributedTitle = NSAttributedString(
|
||||
string: title,
|
||||
attributes: [
|
||||
.font: font ?? NSFont.systemFont(ofSize: NSFont.systemFontSize),
|
||||
.foregroundColor: NSColor.white,
|
||||
]
|
||||
)
|
||||
}
|
||||
controlSize = .small
|
||||
target = self
|
||||
action = #selector(run)
|
||||
setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
|
||||
|
||||
@@ -301,7 +301,7 @@ struct SFTPBrowserView: View {
|
||||
}
|
||||
|
||||
if sftpSession.connectionJourney.isPresented {
|
||||
SSHConnectionJourneyView(
|
||||
ConnectionJourneyView(
|
||||
journey: sftpSession.connectionJourney,
|
||||
authenticationPrompt: sftpSession.pendingAuthentication,
|
||||
hostKey: sftpSession.pendingHostKey,
|
||||
|
||||
@@ -230,7 +230,7 @@ final class SFTPSessionModel: Identifiable {
|
||||
private var pendingHostKeyHostID: Host.ID?
|
||||
var pendingAuthentication: SSHAuthenticationPrompt?
|
||||
let connection = RemoteSessionConnectionController()
|
||||
var connectionJourney: SSHConnectionJourney { connection.journey }
|
||||
var connectionJourney: ConnectionJourney { connection.journey }
|
||||
var pendingBatch: PreparedBatch?
|
||||
var pendingRemoteCopyBatch: PreparedRemoteCopyBatch?
|
||||
var operationError: String?
|
||||
|
||||
@@ -208,7 +208,7 @@ final class SSHSessionModel: Identifiable {
|
||||
/// either Continue or Continue and Save.
|
||||
var pendingAuthentication: SSHAuthenticationPrompt?
|
||||
let connection = RemoteSessionConnectionController()
|
||||
var connectionJourney: SSHConnectionJourney { connection.journey }
|
||||
var connectionJourney: ConnectionJourney { connection.journey }
|
||||
|
||||
let terminalState: TerminalViewState
|
||||
#if canImport(AppKit)
|
||||
|
||||
@@ -33,7 +33,7 @@ final class SerialSessionModel: Identifiable {
|
||||
private var connection: (any SerialPortConnection)?
|
||||
private var connectionTask: Task<Void, Never>?
|
||||
let connectionController = RemoteSessionConnectionController()
|
||||
var connectionJourney: SSHConnectionJourney { connectionController.journey }
|
||||
var connectionJourney: ConnectionJourney { connectionController.journey }
|
||||
|
||||
var title: String {
|
||||
if let name = configuration.deviceName, !name.isEmpty { return name }
|
||||
@@ -250,7 +250,7 @@ struct SerialTerminalView: View {
|
||||
@ViewBuilder
|
||||
private var connectionJourney: some View {
|
||||
if isVisible, session.connectionJourney.isPresented {
|
||||
SSHConnectionJourneyView(
|
||||
ConnectionJourneyView(
|
||||
journey: session.connectionJourney,
|
||||
submitAuthentication: { _, _, _, _, _, _ in },
|
||||
continueHostKey: { _ in },
|
||||
|
||||
@@ -125,7 +125,7 @@ struct TerminalPlaceholderView: View {
|
||||
@ViewBuilder
|
||||
private var connectionJourney: some View {
|
||||
if isVisible, sshSession.connectionJourney.isPresented {
|
||||
SSHConnectionJourneyView(
|
||||
ConnectionJourneyView(
|
||||
journey: sshSession.connectionJourney,
|
||||
authenticationPrompt: sshSession.pendingAuthentication,
|
||||
hostKey: sshSession.pendingHostKey,
|
||||
|
||||
@@ -610,7 +610,7 @@ private struct PaneView: View {
|
||||
private var header: some View {
|
||||
if presentsConnectionJourney {
|
||||
headerContent.background {
|
||||
SSHConnectionJourneyBackground()
|
||||
ConnectionJourneyBackground()
|
||||
}
|
||||
} else if pane.kind == .sshSession || pane.kind == .serialSession {
|
||||
headerContent.background(terminalAppearance.background)
|
||||
|
||||
+8
-8
@@ -122,8 +122,8 @@ extension View {
|
||||
}
|
||||
}
|
||||
|
||||
struct SSHConnectionJourneyView: View {
|
||||
let journey: SSHConnectionJourney
|
||||
struct ConnectionJourneyView: View {
|
||||
let journey: ConnectionJourney
|
||||
var authenticationPrompt: SSHAuthenticationPrompt?
|
||||
var hostKey: SSHHostKeyPrompt?
|
||||
var submitAuthentication: (Identity.ID?, String, String?, String?, SSHKeyRef.ID?, Bool) -> Void
|
||||
@@ -170,7 +170,7 @@ struct SSHConnectionJourneyView: View {
|
||||
Spacer(minLength: 24)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background { SSHConnectionJourneyBackground() }
|
||||
.background { ConnectionJourneyBackground() }
|
||||
.transition(.opacity)
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
@@ -432,7 +432,7 @@ nonisolated private struct ConnectionLogListItem: Identifiable, Hashable, Sendab
|
||||
}
|
||||
|
||||
/// The system window surface shared by a connection journey and its pane header.
|
||||
struct SSHConnectionJourneyBackground: View {
|
||||
struct ConnectionJourneyBackground: View {
|
||||
var body: some View {
|
||||
Color(nsColor: .windowBackgroundColor)
|
||||
}
|
||||
@@ -614,11 +614,11 @@ private enum JourneyVisualState {
|
||||
}
|
||||
|
||||
private struct JourneyEndpointView: View {
|
||||
let endpoint: SSHConnectionEndpoint
|
||||
let purpose: SSHConnectionPurpose
|
||||
let endpoint: ConnectionEndpoint
|
||||
let purpose: ConnectionPurpose
|
||||
let state: JourneyVisualState
|
||||
let showsActivity: Bool
|
||||
let localRequirement: SSHConnectionJourney.LocalRequirement?
|
||||
let localRequirement: ConnectionJourney.LocalRequirement?
|
||||
let reduceMotion: Bool
|
||||
|
||||
var body: some View {
|
||||
@@ -682,7 +682,7 @@ private struct JourneyEndpointView: View {
|
||||
|
||||
private struct JourneySegmentView: View {
|
||||
let state: JourneyVisualState
|
||||
let checkpoint: SSHConnectionJourney.CheckpointKind?
|
||||
let checkpoint: ConnectionJourney.CheckpointKind?
|
||||
let checkpointIsWaiting: Bool
|
||||
let reduceMotion: Bool
|
||||
|
||||
@@ -782,20 +782,6 @@ struct AgentToolPresentationTests {
|
||||
#expect(title == "Checked both hosts. Compare the logs Verify sshd")
|
||||
}
|
||||
|
||||
@Test
|
||||
func reasoningBatchSamplingIsStableAtThirtyPercent() throws {
|
||||
let included = try #require(UUID(
|
||||
uuidString: "00000000-0000-4000-8000-000000000001"
|
||||
))
|
||||
let excluded = try #require(UUID(
|
||||
uuidString: "ffffffff-ffff-4fff-8fff-ffffffffffff"
|
||||
))
|
||||
|
||||
#expect(AgentToolPresentation.showsReasoning(forBatchID: included))
|
||||
#expect(AgentToolPresentation.showsReasoning(forBatchID: included))
|
||||
#expect(!AgentToolPresentation.showsReasoning(forBatchID: excluded))
|
||||
}
|
||||
|
||||
@Test
|
||||
func countedArrayActivityRoundTrips() throws {
|
||||
let activity = AgentToolDescriptor.Presentation.Activity.localizedArrayCount(
|
||||
|
||||
@@ -378,6 +378,180 @@ struct ChatComposerStateTests {
|
||||
#expect(conversation.workGroups.first?.run.state == .cancelled)
|
||||
}
|
||||
|
||||
@Test
|
||||
func workingPlaceholderDisappearsAsSoonAsAToolIsVisible() throws {
|
||||
let (conversation, runID, assistant) = beginStreamingConversation()
|
||||
let before = try #require(conversation.workGroups.first)
|
||||
#expect(before.items.contains { if case .message = $0 { true } else { false } })
|
||||
|
||||
conversation.handle(.messageFinished(AgentTranscriptMessage(
|
||||
id: assistant.id,
|
||||
role: .assistant,
|
||||
toolCalls: [AgentToolCall(
|
||||
id: "call-1", name: "lookup", arguments: .object([:])
|
||||
)],
|
||||
createdAt: assistant.createdAt
|
||||
)))
|
||||
let invocation = AgentToolInvocation(
|
||||
runID: runID,
|
||||
call: AgentToolCall(id: "call-1", name: "lookup", arguments: .object([:])),
|
||||
sourceMessageID: assistant.id
|
||||
)
|
||||
|
||||
conversation.handle(.toolProposed(invocation, AgentToolDescriptor(
|
||||
name: "lookup", summary: "lookup", inputSchema: .object([:]),
|
||||
target: .local, safety: .locallyReadOnly
|
||||
)))
|
||||
|
||||
let after = try #require(conversation.workGroups.first)
|
||||
#expect(!after.items.contains { if case .message = $0 { true } else { false } })
|
||||
guard case .tools(let batch) = try #require(after.items.first) else {
|
||||
Issue.record("Expected the visible tool to replace Working")
|
||||
return
|
||||
}
|
||||
#expect(batch.cards.map(\.id) == [invocation.id])
|
||||
}
|
||||
|
||||
@Test
|
||||
func existingWorkOutputSuppressesLaterWorkingButStillAcceptsReasoning() throws {
|
||||
let conversation = ConversationModel()
|
||||
let runID = UUID()
|
||||
let first = AgentTranscriptMessage(
|
||||
role: .assistant,
|
||||
toolCalls: [AgentToolCall(
|
||||
id: "call-1", name: "lookup", arguments: .object([:])
|
||||
)]
|
||||
)
|
||||
conversation.handle(.runStarted(AgentRunSummary(
|
||||
id: runID, state: .running, startedAt: first.createdAt
|
||||
)))
|
||||
conversation.handle(.messageStarted(first))
|
||||
conversation.handle(.messageFinished(first))
|
||||
let invocation = AgentToolInvocation(
|
||||
runID: runID,
|
||||
call: first.toolCalls[0],
|
||||
sourceMessageID: first.id
|
||||
)
|
||||
conversation.handle(.toolProposed(invocation, AgentToolDescriptor(
|
||||
name: "lookup", summary: "lookup", inputSchema: .object([:]),
|
||||
target: .local, safety: .locallyReadOnly
|
||||
)))
|
||||
|
||||
let next = AgentTranscriptMessage(role: .assistant)
|
||||
conversation.handle(.turnStarted(index: 1))
|
||||
conversation.handle(.messageStarted(next))
|
||||
|
||||
let group = try #require(conversation.workGroups.first)
|
||||
#expect(!group.items.contains { item in
|
||||
guard case .message(let message) = item else { return false }
|
||||
return message.id == next.id
|
||||
})
|
||||
#expect(group.items.contains { if case .tools = $0 { true } else { false } })
|
||||
|
||||
conversation.handle(.reasoningDelta(
|
||||
messageID: next.id,
|
||||
text: "Evaluate the first result."
|
||||
))
|
||||
let updated = try #require(conversation.workGroups.first)
|
||||
#expect(!updated.items.contains { item in
|
||||
guard case .message(let message) = item else { return false }
|
||||
return message.id == next.id
|
||||
})
|
||||
guard case .tools(let batch) = try #require(
|
||||
updated.items.first(where: { if case .tools = $0 { true } else { false } })
|
||||
) else {
|
||||
Issue.record("Expected reasoning to move into the tool title")
|
||||
return
|
||||
}
|
||||
#expect(batch.reasoning?.sourceMessageID == next.id)
|
||||
#expect(batch.reasoning?.markdown == "Evaluate the first result.")
|
||||
}
|
||||
|
||||
@Test
|
||||
func reasoningAloneDoesNotSuppressTheNextWorkingPlaceholder() throws {
|
||||
let conversation = ConversationModel()
|
||||
let runID = UUID()
|
||||
let first = AgentTranscriptMessage(role: .assistant)
|
||||
conversation.handle(.runStarted(AgentRunSummary(
|
||||
id: runID, state: .running, startedAt: first.createdAt
|
||||
)))
|
||||
conversation.handle(.messageStarted(first))
|
||||
conversation.handle(.reasoningDelta(
|
||||
messageID: first.id,
|
||||
text: "First reasoning block."
|
||||
))
|
||||
conversation.handle(.messageFinished(AgentTranscriptMessage(
|
||||
id: first.id,
|
||||
role: .assistant,
|
||||
reasoning: [AgentReasoningBlock(text: "First reasoning block.")],
|
||||
createdAt: first.createdAt
|
||||
)))
|
||||
conversation.handle(.turnFinished(index: 0, stopReason: .completed))
|
||||
|
||||
let next = AgentTranscriptMessage(role: .assistant)
|
||||
conversation.handle(.turnStarted(index: 1))
|
||||
conversation.handle(.messageStarted(next))
|
||||
|
||||
let group = try #require(conversation.workGroups.first)
|
||||
#expect(group.items.contains { item in
|
||||
guard case .message(let message) = item else { return false }
|
||||
return message.id == next.id && message.text.isEmpty
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
func newReasoningRemainsVisibleAfterTheExistingToolBatchIsTerminal() throws {
|
||||
let conversation = ConversationModel()
|
||||
let runID = UUID()
|
||||
let source = AgentTranscriptMessage(
|
||||
role: .assistant,
|
||||
toolCalls: [AgentToolCall(
|
||||
id: "call-1", name: "lookup", arguments: .object([:])
|
||||
)]
|
||||
)
|
||||
conversation.handle(.runStarted(AgentRunSummary(
|
||||
id: runID, state: .running, startedAt: source.createdAt
|
||||
)))
|
||||
conversation.handle(.messageFinished(source))
|
||||
let invocation = AgentToolInvocation(
|
||||
runID: runID, call: source.toolCalls[0], sourceMessageID: source.id
|
||||
)
|
||||
conversation.handle(.toolProposed(invocation, AgentToolDescriptor(
|
||||
name: "lookup", summary: "lookup", inputSchema: .object([:]),
|
||||
target: .local, safety: .locallyReadOnly
|
||||
)))
|
||||
conversation.handle(.toolFinished(
|
||||
invocation,
|
||||
AgentToolResult(callID: invocation.call.id, content: "ok")
|
||||
))
|
||||
|
||||
let next = AgentTranscriptMessage(role: .assistant)
|
||||
conversation.handle(.turnStarted(index: 1))
|
||||
conversation.handle(.messageStarted(next))
|
||||
let waiting = try #require(conversation.workGroups.first)
|
||||
#expect(!waiting.items.contains {
|
||||
if case .message = $0 { true } else { false }
|
||||
})
|
||||
|
||||
conversation.handle(.reasoningDelta(
|
||||
messageID: next.id,
|
||||
text: "Reason about the completed result."
|
||||
))
|
||||
|
||||
let group = try #require(conversation.workGroups.first)
|
||||
#expect(group.items.contains { item in
|
||||
guard case .reasoning(let block) = item else { return false }
|
||||
return block.text == "Reason about the completed result."
|
||||
})
|
||||
guard case .tools(let batch) = try #require(
|
||||
group.items.first(where: { if case .tools = $0 { true } else { false } })
|
||||
) else {
|
||||
Issue.record("Expected the completed tool batch to remain")
|
||||
return
|
||||
}
|
||||
#expect(batch.reasoning == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func terminalRunEventSettlesAnUnfinishedMessage() {
|
||||
let (conversation, runID, assistant) = beginStreamingConversation()
|
||||
@@ -723,7 +897,7 @@ struct ChatComposerStateTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func twoAdjacentToolsStayBatchedAcrossLiveFailureAndCompletionStates() throws {
|
||||
func toolBatchPublishesAtomicallyAndStaysStableAcrossCompletion() throws {
|
||||
let conversation = ConversationModel()
|
||||
conversation.appendUserTurn("run both")
|
||||
let runID = UUID()
|
||||
@@ -735,26 +909,33 @@ struct ChatComposerStateTests {
|
||||
name: "lookup", summary: "lookup", inputSchema: .object([:]),
|
||||
target: .local, safety: .locallyReadOnly
|
||||
)
|
||||
let sourceMessageID = UUID()
|
||||
let source = AgentTranscriptMessage(
|
||||
role: .assistant,
|
||||
toolCalls: [
|
||||
AgentToolCall(id: "first", name: "lookup", arguments: .object([:])),
|
||||
AgentToolCall(id: "second", name: "lookup", arguments: .object([:])),
|
||||
],
|
||||
createdAt: start.addingTimeInterval(0.5)
|
||||
)
|
||||
conversation.handle(.messageFinished(source))
|
||||
let first = AgentToolInvocation(
|
||||
runID: runID,
|
||||
call: AgentToolCall(id: "first", name: "lookup", arguments: .object([:])),
|
||||
sourceMessageID: sourceMessageID,
|
||||
sourceMessageID: source.id,
|
||||
createdAt: start.addingTimeInterval(1)
|
||||
)
|
||||
let second = AgentToolInvocation(
|
||||
runID: runID,
|
||||
call: AgentToolCall(id: "second", name: "lookup", arguments: .object([:])),
|
||||
sourceMessageID: sourceMessageID,
|
||||
sourceMessageID: source.id,
|
||||
createdAt: start.addingTimeInterval(2)
|
||||
)
|
||||
conversation.handle(.toolProposed(first, descriptor))
|
||||
guard case .tool(let single) = try #require(conversation.workGroups.first?.items.first)
|
||||
else {
|
||||
Issue.record("One tool should remain a direct row")
|
||||
return
|
||||
}
|
||||
#expect(single.id == first.id)
|
||||
#expect(conversation.workGroups.isEmpty)
|
||||
conversation.handle(.toolFinished(first, AgentToolResult(
|
||||
callID: first.call.id, content: "failed", isError: true
|
||||
)))
|
||||
#expect(conversation.workGroups.isEmpty)
|
||||
|
||||
conversation.handle(.toolProposed(second, descriptor))
|
||||
guard case .tools(let live) = try #require(conversation.workGroups.first?.items.first)
|
||||
@@ -763,10 +944,8 @@ struct ChatComposerStateTests {
|
||||
return
|
||||
}
|
||||
#expect(live.cards.map(\.id) == [first.id, second.id])
|
||||
#expect(live.cards.map(\.state) == [.failed, .proposed])
|
||||
|
||||
conversation.handle(.toolFinished(first, AgentToolResult(
|
||||
callID: first.call.id, content: "failed", isError: true
|
||||
)))
|
||||
conversation.handle(.toolFinished(second, AgentToolResult(
|
||||
callID: second.call.id, content: "ok"
|
||||
)))
|
||||
@@ -779,6 +958,51 @@ struct ChatComposerStateTests {
|
||||
#expect(terminal.cards.map(\.state) == [.failed, .completed])
|
||||
}
|
||||
|
||||
@Test
|
||||
func providerNativeToolsPublishAtomicallyWhenTheirTurnFinishes() throws {
|
||||
let conversation = ConversationModel()
|
||||
let runID = UUID()
|
||||
let source = AgentTranscriptMessage(role: .assistant)
|
||||
conversation.handle(.runStarted(AgentRunSummary(
|
||||
id: runID, state: .running, startedAt: source.createdAt
|
||||
)))
|
||||
conversation.handle(.messageStarted(source))
|
||||
let descriptor = AgentToolDescriptor(
|
||||
name: "web_search", summary: "search", inputSchema: .object([:]),
|
||||
target: .network, safety: .locallyReadOnly
|
||||
)
|
||||
let invocations = ["first", "second"].map { id in
|
||||
AgentToolInvocation(
|
||||
runID: runID,
|
||||
call: AgentToolCall(
|
||||
id: id, name: "web_search",
|
||||
arguments: .object(["query": .string(id)])
|
||||
),
|
||||
sourceMessageID: source.id
|
||||
)
|
||||
}
|
||||
for invocation in invocations {
|
||||
conversation.handle(.toolProposed(invocation, descriptor))
|
||||
conversation.handle(.toolFinished(invocation, AgentToolResult(
|
||||
callID: invocation.call.id,
|
||||
content: "[]",
|
||||
metadata: [AgentToolResult.providerNativeKey: .bool(true)]
|
||||
)))
|
||||
#expect(conversation.toolCards.isEmpty)
|
||||
}
|
||||
|
||||
conversation.handle(.messageFinished(source))
|
||||
|
||||
#expect(conversation.toolCards.map(\.id) == invocations.map(\.id))
|
||||
guard case .tools(let batch) = try #require(
|
||||
conversation.workGroups.first?.items.first
|
||||
) else {
|
||||
Issue.record("Expected one provider-native batch")
|
||||
return
|
||||
}
|
||||
#expect(batch.cards.map(\.state) == [.completed, .completed])
|
||||
}
|
||||
|
||||
@Test
|
||||
func liveToolBatchReusesOnlyItsProviderTurnsReasoning() throws {
|
||||
let conversation = ConversationModel()
|
||||
@@ -822,8 +1046,9 @@ struct ChatComposerStateTests {
|
||||
Issue.record("Expected a live tool batch")
|
||||
return
|
||||
}
|
||||
#expect(live.sourceMessageID == source.id)
|
||||
#expect(live.reasoningTitle == "Compare both hosts. Then verify them.")
|
||||
#expect(live.runID == runID)
|
||||
#expect(live.reasoning?.sourceMessageID == source.id)
|
||||
#expect(live.reasoning?.markdown == "**Compare** both hosts.\nThen verify them.")
|
||||
|
||||
for invocation in invocations {
|
||||
conversation.handle(.toolFinished(invocation, AgentToolResult(
|
||||
@@ -836,11 +1061,11 @@ struct ChatComposerStateTests {
|
||||
Issue.record("Expected the completed tools to remain batched")
|
||||
return
|
||||
}
|
||||
#expect(terminal.reasoningTitle == nil)
|
||||
#expect(terminal.reasoning == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func liveToolBatchDoesNotDuplicateCurrentlyVisibleReasoning() throws {
|
||||
func liveToolBatchOwnsCurrentlyStreamingReasoningWithoutADuplicateRow() throws {
|
||||
let conversation = ConversationModel()
|
||||
conversation.appendUserTurn("inspect both")
|
||||
let runID = UUID()
|
||||
@@ -874,7 +1099,7 @@ struct ChatComposerStateTests {
|
||||
}
|
||||
|
||||
let overlappingGroup = try #require(conversation.workGroups.first)
|
||||
#expect(overlappingGroup.items.contains { item in
|
||||
#expect(!overlappingGroup.items.contains { item in
|
||||
guard case .reasoning = item else { return false }
|
||||
return true
|
||||
})
|
||||
@@ -887,7 +1112,7 @@ struct ChatComposerStateTests {
|
||||
Issue.record("Expected a live tool batch")
|
||||
return
|
||||
}
|
||||
#expect(overlappingBatch.reasoningTitle == nil)
|
||||
#expect(overlappingBatch.reasoning?.markdown == reasoning.text)
|
||||
|
||||
conversation.handle(.turnFinished(index: 0, stopReason: .toolCalls))
|
||||
let settledGroup = try #require(conversation.workGroups.first)
|
||||
@@ -904,7 +1129,7 @@ struct ChatComposerStateTests {
|
||||
Issue.record("Expected the live tool batch to remain")
|
||||
return
|
||||
}
|
||||
#expect(settledBatch.reasoningTitle == reasoning.text)
|
||||
#expect(settledBatch.reasoning?.markdown == reasoning.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1033,7 +1258,7 @@ struct ChatComposerStateTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func finishedReasoningDisappearsWithoutMergingToolsAcrossProviderTurns() async throws {
|
||||
func finishedReasoningDisappearsWhileToolsMergeAcrossProviderTurns() async throws {
|
||||
let conversation = ConversationModel()
|
||||
conversation.appendUserTurn("inspect both")
|
||||
conversation.start(
|
||||
@@ -1090,10 +1315,7 @@ struct ChatComposerStateTests {
|
||||
return "tools:\(batch.cards.map(\.invocation.call.id).joined(separator: ","))"
|
||||
}
|
||||
}
|
||||
#expect(kinds == [
|
||||
"tools:call-1,call-2",
|
||||
"tool:call-3",
|
||||
])
|
||||
#expect(kinds == ["tools:call-1,call-2,call-3"])
|
||||
#expect(conversation.timeline.compactMap { item in
|
||||
if case .work = item { return "work" }
|
||||
return nil
|
||||
@@ -1101,7 +1323,7 @@ struct ChatComposerStateTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func providerTurnsWithoutReasoningStillSeparateToolBatches() async throws {
|
||||
func providerTurnsWithoutReasoningShareOneRunScopedToolBatch() async throws {
|
||||
let conversation = ConversationModel()
|
||||
conversation.appendUserTurn("inspect four")
|
||||
conversation.start(
|
||||
@@ -1150,7 +1372,7 @@ struct ChatComposerStateTests {
|
||||
case .reasoning, .message: []
|
||||
}
|
||||
}.filter { !$0.isEmpty }
|
||||
#expect(batches == [["call-1", "call-2"], ["call-3", "call-4"]])
|
||||
#expect(batches == [["call-1", "call-2", "call-3", "call-4"]])
|
||||
}
|
||||
|
||||
|
||||
@@ -1164,6 +1386,17 @@ struct ChatComposerStateTests {
|
||||
conversation.handle(.runStarted(AgentRunSummary(
|
||||
id: runID, state: .running, startedAt: startedAt
|
||||
)))
|
||||
let calls = [
|
||||
"lookup", "lookup", "terminal_suggest", "lookup", "lookup",
|
||||
].enumerated().map { offset, name in
|
||||
AgentToolCall(id: "call-\(offset)", name: name, arguments: .object([:]))
|
||||
}
|
||||
conversation.handle(.messageFinished(AgentTranscriptMessage(
|
||||
id: sourceMessageID,
|
||||
role: .assistant,
|
||||
toolCalls: calls,
|
||||
createdAt: startedAt
|
||||
)))
|
||||
let lookup = AgentToolDescriptor(
|
||||
name: "lookup", summary: "lookup", inputSchema: .object([:]),
|
||||
target: .local, safety: .locallyReadOnly
|
||||
@@ -1194,7 +1427,7 @@ struct ChatComposerStateTests {
|
||||
}
|
||||
}.filter { !$0.isEmpty }
|
||||
#expect(groups == [
|
||||
["lookup", "lookup"], ["terminal_suggest"], ["lookup", "lookup"],
|
||||
["lookup", "lookup", "lookup", "lookup"], ["terminal_suggest"],
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
+8
-8
@@ -5,13 +5,13 @@ import Testing
|
||||
|
||||
@Suite
|
||||
@MainActor
|
||||
struct SSHConnectionJourneyTests {
|
||||
struct ConnectionJourneyTests {
|
||||
@Test
|
||||
func routeStartsAtBlueLocalEndpointAndKeepsHostsPending() {
|
||||
let jump = Host(name: "Bastion", host: "jump.example")
|
||||
var destination = Host(name: "Production", host: "prod.example")
|
||||
destination.jumpHostID = jump.id
|
||||
let journey = SSHConnectionJourney()
|
||||
let journey = ConnectionJourney()
|
||||
|
||||
journey.begin(
|
||||
host: destination, route: [jump, destination],
|
||||
@@ -30,7 +30,7 @@ struct SSHConnectionJourneyTests {
|
||||
@Test
|
||||
func reconnectUsesTheSameJourneyInReconnectMode() {
|
||||
let destination = Host(name: "Production", host: "prod.example")
|
||||
let journey = SSHConnectionJourney()
|
||||
let journey = ConnectionJourney()
|
||||
|
||||
journey.begin(
|
||||
host: destination, route: [destination],
|
||||
@@ -46,7 +46,7 @@ struct SSHConnectionJourneyTests {
|
||||
@Test
|
||||
func authenticationCheckpointAppearsOnlyWhenRequested() {
|
||||
let destination = Host(name: "Server", host: "server.example")
|
||||
let journey = SSHConnectionJourney()
|
||||
let journey = ConnectionJourney()
|
||||
journey.begin(
|
||||
host: destination, route: [destination],
|
||||
purpose: .sftpSession, username: nil
|
||||
@@ -71,7 +71,7 @@ struct SSHConnectionJourneyTests {
|
||||
func missingLocalIdentityKeepsEntireRemoteRoutePending() {
|
||||
let jump = Host(name: "Bastion", host: "jump.example")
|
||||
let destination = Host(name: "Server", host: "server.example")
|
||||
let journey = SSHConnectionJourney()
|
||||
let journey = ConnectionJourney()
|
||||
journey.begin(
|
||||
host: destination, route: [jump, destination],
|
||||
purpose: .sshSession, username: nil
|
||||
@@ -93,7 +93,7 @@ struct SSHConnectionJourneyTests {
|
||||
@Test
|
||||
func localRequirementTransitionsBetweenIdentityAndPassphraseSteps() {
|
||||
let destination = Host(name: "Server", host: "server.example")
|
||||
let journey = SSHConnectionJourney()
|
||||
let journey = ConnectionJourney()
|
||||
journey.begin(
|
||||
host: destination, route: [destination],
|
||||
purpose: .sshSession, username: "deploy"
|
||||
@@ -136,7 +136,7 @@ struct SSHConnectionJourneyTests {
|
||||
func routeProgressIsMonotonicAcrossReconnectProbes() {
|
||||
let first = Host(name: "Jump", host: "jump.example")
|
||||
let destination = Host(name: "Server", host: "server.example")
|
||||
let journey = SSHConnectionJourney()
|
||||
let journey = ConnectionJourney()
|
||||
journey.begin(
|
||||
host: destination, route: [first, destination],
|
||||
purpose: .sshSession, username: "root"
|
||||
@@ -157,7 +157,7 @@ struct SSHConnectionJourneyTests {
|
||||
func failureKeepsFutureRoutePendingAndRecordsSanitizedLog() {
|
||||
let first = Host(name: "Jump", host: "jump.example")
|
||||
let destination = Host(name: "Server", host: "server.example")
|
||||
let journey = SSHConnectionJourney()
|
||||
let journey = ConnectionJourney()
|
||||
journey.begin(
|
||||
host: destination, route: [first, destination],
|
||||
purpose: .sshSession, username: "deploy"
|
||||
@@ -1,3 +1,4 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import MarkdownParser
|
||||
import Testing
|
||||
@@ -203,6 +204,29 @@ struct MarkdownParsingTests {
|
||||
#expect(title == "Checked both hosts. Compare the logs Verify sshd")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func transientToolTitleFlattensBlocksAndPreservesSafeInlineMarkdown() throws {
|
||||
let title = TermindMarkdownParser.singleLineAttributedText("""
|
||||
**Checked** *both* hosts and [`sshd`](https://example.com).
|
||||
|
||||
- ~~old result~~
|
||||
- final result
|
||||
""")
|
||||
|
||||
#expect(title.string == "Checked both hosts and sshd. old result final result")
|
||||
let checked = (title.string as NSString).range(of: "Checked")
|
||||
let both = (title.string as NSString).range(of: "both")
|
||||
let sshd = (title.string as NSString).range(of: "sshd")
|
||||
let old = (title.string as NSString).range(of: "old result")
|
||||
let checkedFont = try #require(title.attribute(.font, at: checked.location, effectiveRange: nil) as? NSFont)
|
||||
let bothFont = try #require(title.attribute(.font, at: both.location, effectiveRange: nil) as? NSFont)
|
||||
#expect(checkedFont.fontDescriptor.symbolicTraits.contains(.bold))
|
||||
#expect(bothFont.fontDescriptor.symbolicTraits.contains(.italic))
|
||||
#expect(title.attribute(.link, at: sshd.location, effectiveRange: nil) as? URL == URL(string: "https://example.com"))
|
||||
#expect(title.attribute(.strikethroughStyle, at: old.location, effectiveRange: nil) != nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func emptySourceProducesNothing() {
|
||||
#expect(TermindMarkdownParser.parse("").isEmpty)
|
||||
|
||||
@@ -87,7 +87,7 @@ struct SerialFeatureTests {
|
||||
@MainActor
|
||||
@Test
|
||||
func serialConnectionUsesTheSharedConnectionJourney() {
|
||||
let journey = SSHConnectionJourney()
|
||||
let journey = ConnectionJourney()
|
||||
journey.beginSerial(name: "USB UART", address: "/dev/cu.usbserial-01")
|
||||
|
||||
#expect(journey.purpose == .serialSession)
|
||||
|
||||
@@ -122,6 +122,41 @@ struct TranscriptPresentationStateTests {
|
||||
#expect(!state.readerDidToggle(id))
|
||||
}
|
||||
|
||||
@Test
|
||||
func deferredAutoCollapseWaitsForTheCoordinatorAndRespectsReaderOverride() {
|
||||
let id = UUID()
|
||||
let state = TranscriptPresentationState()
|
||||
|
||||
state.deferAutoCollapse(id)
|
||||
#expect(state.isExpanded(id))
|
||||
#expect(state.hasPendingAutoCollapse(id))
|
||||
#expect(state.completeAutoCollapse(id))
|
||||
#expect(!state.isExpanded(id))
|
||||
|
||||
state.deferAutoCollapse(id)
|
||||
state.toggleDisclosure(id, default: true)
|
||||
#expect(!state.hasPendingAutoCollapse(id))
|
||||
#expect(!state.completeAutoCollapse(id))
|
||||
}
|
||||
|
||||
@Test
|
||||
func reasoningDeadlineIsAbsoluteAndNewMarkdownResetsIt() {
|
||||
let id = UUID()
|
||||
let state = TranscriptPresentationState()
|
||||
let start = Date(timeIntervalSince1970: 1_000)
|
||||
|
||||
let first = state.reasoningTitleDeadline(
|
||||
for: id, revision: "first", now: start
|
||||
)
|
||||
#expect(first == start.addingTimeInterval(3))
|
||||
#expect(state.reasoningTitleDeadline(
|
||||
for: id, revision: "first", now: start.addingTimeInterval(2)
|
||||
) == first)
|
||||
#expect(state.reasoningTitleDeadline(
|
||||
for: id, revision: "second", now: start.addingTimeInterval(2)
|
||||
) == start.addingTimeInterval(5))
|
||||
}
|
||||
|
||||
@Test
|
||||
func messageDraftSurvivesRowRecyclingAndCanBePruned() {
|
||||
let id = UUID()
|
||||
@@ -150,6 +185,52 @@ struct TranscriptListViewBridgeTests {
|
||||
#expect(listView.rowAnimator == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func visibleCompletedWorkDefersCollapseUntilItLeavesTheViewport() async {
|
||||
let coordinator = TranscriptListView.Coordinator()
|
||||
let listView = coordinator.makeListView()
|
||||
let presentation = TranscriptPresentationState()
|
||||
let id = UUID()
|
||||
listView.frame = CGRect(x: 0, y: 0, width: 420, height: 180)
|
||||
let tail = (0..<12).map { _ in TranscriptEntry(id: UUID(), content: .empty) }
|
||||
|
||||
update(
|
||||
coordinator, listView,
|
||||
entries: [work(id: id, state: .running)] + tail,
|
||||
presentation: presentation
|
||||
)
|
||||
listView.layoutSubtreeIfNeeded()
|
||||
listView.setContentOffset(.zero, animated: false)
|
||||
listView.layoutSubtreeIfNeeded()
|
||||
update(
|
||||
coordinator, listView,
|
||||
entries: [work(id: id, state: .completed)] + tail,
|
||||
presentation: presentation
|
||||
)
|
||||
|
||||
#expect(presentation.isExpanded(id))
|
||||
#expect(presentation.hasPendingAutoCollapse(id))
|
||||
|
||||
let expandedFrame = listView.rectForRow(with: id)
|
||||
listView.setContentOffset(
|
||||
CGPoint(x: 0, y: expandedFrame.maxY - 1),
|
||||
animated: false
|
||||
)
|
||||
listView.layoutSubtreeIfNeeded()
|
||||
await Task.yield()
|
||||
#expect(presentation.isExpanded(id), "one visible point must defer collapse")
|
||||
|
||||
listView.setContentOffset(
|
||||
CGPoint(x: 0, y: listView.maximumContentOffset.y),
|
||||
animated: false
|
||||
)
|
||||
listView.layoutSubtreeIfNeeded()
|
||||
await Task.yield()
|
||||
|
||||
#expect(!presentation.isExpanded(id))
|
||||
#expect(!presentation.hasPendingAutoCollapse(id))
|
||||
}
|
||||
|
||||
@Test
|
||||
func entryKindsUseDedicatedReusableRowTypes() {
|
||||
let coordinator = TranscriptListView.Coordinator()
|
||||
@@ -342,6 +423,75 @@ struct TranscriptListViewBridgeTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func terminalSuggestionMatchesTheOriginalCommandCardHierarchy() throws {
|
||||
let coordinator = TranscriptListView.Coordinator()
|
||||
let listView = coordinator.makeListView()
|
||||
listView.appearance = NSAppearance(named: .aqua)
|
||||
listView.frame = CGRect(x: 0, y: 0, width: 420, height: 800)
|
||||
let id = UUID()
|
||||
let message = ChatMessage(
|
||||
id: id,
|
||||
role: .assistant,
|
||||
text: "",
|
||||
commands: [
|
||||
SuggestedCommand(
|
||||
command: "rm -rf /tmp/termind-preview",
|
||||
explanation: "Remove the generated preview."
|
||||
)
|
||||
]
|
||||
)
|
||||
let entry = TranscriptEntry(
|
||||
id: id,
|
||||
content: .assistant(message, canFork: false)
|
||||
)
|
||||
|
||||
update(
|
||||
coordinator,
|
||||
listView,
|
||||
entries: [entry],
|
||||
showsTerminalActions: true,
|
||||
actions: .init(canDeliverCommand: { true })
|
||||
)
|
||||
listView.layoutSubtreeIfNeeded()
|
||||
|
||||
let row = try #require(listView.rowView(for: id))
|
||||
let card = try #require(descendant(in: row, identifier: "Transcript.CommandCard"))
|
||||
let content = try #require(
|
||||
descendant(in: card, identifier: "Transcript.CommandCardContent") as? NSStackView
|
||||
)
|
||||
let commandBackground = try #require(
|
||||
descendant(in: card, identifier: "Transcript.CommandCardCommandBackground")
|
||||
)
|
||||
let command = try #require(
|
||||
descendant(in: card, identifier: "Transcript.CommandCardCommand") as? NSTextField
|
||||
)
|
||||
let explanation = try #require(
|
||||
descendant(in: card, identifier: "Transcript.CommandCardExplanation") as? NSTextField
|
||||
)
|
||||
let insert = try #require(
|
||||
descendant(in: card, identifier: "Transcript.CommandCardInsert") as? NSButton
|
||||
)
|
||||
let run = try #require(
|
||||
descendant(in: card, identifier: "Transcript.CommandCardRun") as? NSButton
|
||||
)
|
||||
|
||||
card.updateLayer()
|
||||
commandBackground.updateLayer()
|
||||
let cardColor = try #require(card.layer?.backgroundColor.flatMap(NSColor.init(cgColor:)))
|
||||
let commandColor = try #require(
|
||||
commandBackground.layer?.backgroundColor.flatMap(NSColor.init(cgColor:))
|
||||
)
|
||||
#expect(abs(cardColor.alphaComponent - 0.020) < 0.004)
|
||||
#expect(abs(commandColor.alphaComponent - 0.039) < 0.004)
|
||||
#expect(content.spacing == Metrics.Space.m)
|
||||
#expect(command.font?.fontDescriptor.symbolicTraits.contains(.monoSpace) == true)
|
||||
#expect(command.font?.pointSize == NSFont.systemFontSize - 1)
|
||||
#expect(explanation.font?.pointSize == NSFont.smallSystemFontSize)
|
||||
#expect(insert.isBordered)
|
||||
#expect(run.isBordered)
|
||||
}
|
||||
|
||||
@Test
|
||||
func userImagesAndChipsSitOutsideAndAboveTheTextBubble() throws {
|
||||
let coordinator = TranscriptListView.Coordinator()
|
||||
@@ -533,12 +683,92 @@ struct TranscriptListViewBridgeTests {
|
||||
#expect(abs(markdownFrame.maxY - row.bounds.maxY) <= 1)
|
||||
#expect(descendant(in: row, identifier: "Transcript.WorkingActivity") == nil)
|
||||
|
||||
update(coordinator, listView, entries: [streamingAssistant(id: id, text: "")])
|
||||
update(
|
||||
coordinator,
|
||||
listView,
|
||||
entries: [streamingAssistant(id: id, text: "")]
|
||||
)
|
||||
listView.layoutSubtreeIfNeeded()
|
||||
|
||||
#expect(descendant(in: row, identifier: "Transcript.WorkingActivity") != nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func workingActivityUsesAFullBoundsMovingNotchMask() throws {
|
||||
let coordinator = TranscriptListView.Coordinator()
|
||||
let listView = coordinator.makeListView()
|
||||
let id = UUID()
|
||||
listView.frame = CGRect(x: 0, y: 0, width: 420, height: 800)
|
||||
|
||||
update(
|
||||
coordinator,
|
||||
listView,
|
||||
entries: [streamingAssistant(id: id, text: "")],
|
||||
reduceMotion: false
|
||||
)
|
||||
listView.layoutSubtreeIfNeeded()
|
||||
|
||||
let row = try #require(listView.rowView(for: id))
|
||||
let activity = try #require(
|
||||
descendant(in: row, identifier: "Transcript.WorkingActivity")
|
||||
)
|
||||
activity.layoutSubtreeIfNeeded()
|
||||
let mask = try #require(activity.layer?.mask)
|
||||
#expect(mask.frame == activity.bounds)
|
||||
#expect(mask.animation(forKey: "scan") != nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func singleRunningToolTitleStillInstallsTheScanningMask() throws {
|
||||
let coordinator = TranscriptListView.Coordinator()
|
||||
let listView = coordinator.makeListView()
|
||||
let runID = UUID()
|
||||
let invocation = AgentToolInvocation(
|
||||
runID: runID,
|
||||
call: AgentToolCall(
|
||||
id: "call", name: "lookup", arguments: .object([:])
|
||||
)
|
||||
)
|
||||
var card = AgentToolCard(
|
||||
invocation: invocation,
|
||||
descriptor: AgentToolDescriptor(
|
||||
name: "lookup", summary: "lookup", inputSchema: .object([:]),
|
||||
target: .local, safety: .locallyReadOnly
|
||||
)
|
||||
)
|
||||
card.state = .running
|
||||
let entry = TranscriptEntry(
|
||||
id: runID,
|
||||
content: .work(AgentWorkGroup(
|
||||
run: AgentRunSummary(
|
||||
id: runID, state: .running, startedAt: .now
|
||||
),
|
||||
items: [.tools(AgentToolBatch(
|
||||
cards: [card], runID: runID, reasoning: nil
|
||||
))],
|
||||
orderedAt: .now,
|
||||
anchorMessageID: nil,
|
||||
appearsAfterAnswer: false,
|
||||
activeReasoningBlockID: nil
|
||||
))
|
||||
)
|
||||
listView.frame = CGRect(x: 0, y: 0, width: 420, height: 800)
|
||||
|
||||
update(
|
||||
coordinator,
|
||||
listView,
|
||||
entries: [entry],
|
||||
reduceMotion: false
|
||||
)
|
||||
listView.layoutSubtreeIfNeeded()
|
||||
|
||||
let row = try #require(listView.rowView(for: runID))
|
||||
let title = try #require(
|
||||
descendant(in: row, identifier: "Transcript.ScanningTitle")
|
||||
)
|
||||
#expect(title.layer?.mask?.animation(forKey: "scan") != nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func hoverSurvivesTheRefreshThatAddsCompletedMessageActions() throws {
|
||||
let coordinator = TranscriptListView.Coordinator()
|
||||
@@ -554,8 +784,8 @@ struct TranscriptListViewBridgeTests {
|
||||
presentation: presentation
|
||||
)
|
||||
let row = try #require(listView.rowView(for: id) as? TranscriptAssistantRow)
|
||||
let pointerEvent = try #require(pointerEvent())
|
||||
row.mouseEntered(with: pointerEvent)
|
||||
let event = try #require(pointerEvent())
|
||||
row.mouseEntered(with: event)
|
||||
|
||||
update(
|
||||
coordinator,
|
||||
@@ -570,9 +800,9 @@ struct TranscriptListViewBridgeTests {
|
||||
)
|
||||
#expect(actions.alphaValue == 1)
|
||||
|
||||
row.mouseExited(with: pointerEvent)
|
||||
row.mouseExited(with: event)
|
||||
#expect(actions.alphaValue == 0)
|
||||
row.mouseEntered(with: pointerEvent)
|
||||
row.mouseEntered(with: event)
|
||||
#expect(actions.alphaValue == 1)
|
||||
}
|
||||
|
||||
@@ -750,7 +980,10 @@ struct TranscriptListViewBridgeTests {
|
||||
_ listView: TranscriptListView.NSViewType,
|
||||
entries: [TranscriptEntry],
|
||||
presentation: TranscriptPresentationState = TranscriptPresentationState(),
|
||||
isCompact: Bool = true
|
||||
isCompact: Bool = true,
|
||||
showsTerminalActions: Bool = false,
|
||||
actions: TranscriptListActions = .init(),
|
||||
reduceMotion: Bool = true
|
||||
) {
|
||||
coordinator.update(
|
||||
listView: listView,
|
||||
@@ -758,12 +991,12 @@ struct TranscriptListViewBridgeTests {
|
||||
latestUserMessageID: nil,
|
||||
configuration: .init(
|
||||
isCompact: isCompact,
|
||||
showsTerminalActions: false,
|
||||
showsTerminalActions: showsTerminalActions,
|
||||
isConversationStreaming: false,
|
||||
locale: Locale(identifier: "en"),
|
||||
reduceMotion: true
|
||||
reduceMotion: reduceMotion
|
||||
),
|
||||
actions: .init(),
|
||||
actions: actions,
|
||||
presentation: presentation
|
||||
)
|
||||
}
|
||||
@@ -821,4 +1054,27 @@ struct TranscriptListViewBridgeTests {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func work(id: UUID, state: AgentRunState) -> TranscriptEntry {
|
||||
let start = Date(timeIntervalSince1970: 1_000)
|
||||
return TranscriptEntry(
|
||||
id: id,
|
||||
content: .work(AgentWorkGroup(
|
||||
run: AgentRunSummary(
|
||||
id: id,
|
||||
state: state,
|
||||
startedAt: start,
|
||||
finishedAt: state == .completed ? start.addingTimeInterval(2) : nil
|
||||
),
|
||||
items: [.message(ChatMessage(
|
||||
role: .assistant,
|
||||
text: String(repeating: "Visible work detail. ", count: 20)
|
||||
))],
|
||||
orderedAt: start,
|
||||
anchorMessageID: nil,
|
||||
appearsAfterAnswer: false,
|
||||
activeReasoningBlockID: nil
|
||||
))
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user