Rename SSH connection journey

This commit is contained in:
Kaede Akino committed 2026-08-29 22:30:51 +08:00
1 parent f5e240ea1b
commit e744ab8a67
21 files changed
+1310 -283

No files matched your search

@@ -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.
+192 -76
View File
@@ -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):
+136 -2
View File
@@ -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 {
+257 -99
View File
@@ -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)
+1 -1
View File
@@ -301,7 +301,7 @@ struct SFTPBrowserView: View {
}
if sftpSession.connectionJourney.isPresented {
SSHConnectionJourneyView(
ConnectionJourneyView(
journey: sftpSession.connectionJourney,
authenticationPrompt: sftpSession.pendingAuthentication,
hostKey: sftpSession.pendingHostKey,
+1 -1
View File
@@ -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)
@@ -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(
+261 -28
View File
@@ -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"],
])
}
@@ -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"
+24
View File
@@ -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)
+1 -1
View File
@@ -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)
+265 -9
View File
@@ -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
))
)
}
}