diff --git a/Termind/Application/Connections/SSHConnectionJourney.swift b/Termind/Application/Connections/ConnectionJourney.swift similarity index 94% rename from Termind/Application/Connections/SSHConnectionJourney.swift rename to Termind/Application/Connections/ConnectionJourney.swift index e071bd18..5e8bc18e 100644 --- a/Termind/Application/Connections/SSHConnectionJourney.swift +++ b/Termind/Application/Connections/ConnectionJourney.swift @@ -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? 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" diff --git a/Termind/Application/Connections/RemoteSessionConnectionController.swift b/Termind/Application/Connections/RemoteSessionConnectionController.swift index 53b2efe8..fff8319e 100644 --- a/Termind/Application/Connections/RemoteSessionConnectionController.swift +++ b/Termind/Application/Connections/RemoteSessionConnectionController.swift @@ -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 } diff --git a/Termind/Features/AI/AgentToolPresentation.swift b/Termind/Features/AI/AgentToolPresentation.swift index 78de2daf..5e86d8b8 100644 --- a/Termind/Features/AI/AgentToolPresentation.swift +++ b/Termind/Features/AI/AgentToolPresentation.swift @@ -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. diff --git a/Termind/Features/AI/ConversationModel.swift b/Termind/Features/AI/ConversationModel.swift index dc066a3f..2e43a56a 100644 --- a/Termind/Features/AI/ConversationModel.swift +++ b/Termind/Features/AI/ConversationModel.swift @@ -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 diff --git a/Termind/Features/AI/ConversationTranscriptView.swift b/Termind/Features/AI/ConversationTranscriptView.swift index 7c0a1f86..558f1777 100644 --- a/Termind/Features/AI/ConversationTranscriptView.swift +++ b/Termind/Features/AI/ConversationTranscriptView.swift @@ -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 { diff --git a/Termind/Features/AI/MarkdownBlockListView.swift b/Termind/Features/AI/MarkdownBlockListView.swift index 5f83fbc6..22f59365 100644 --- a/Termind/Features/AI/MarkdownBlockListView.swift +++ b/Termind/Features/AI/MarkdownBlockListView.swift @@ -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): diff --git a/Termind/Features/AI/TranscriptListView.swift b/Termind/Features/AI/TranscriptListView.swift index e4eb1f39..30d00651 100644 --- a/Termind/Features/AI/TranscriptListView.swift +++ b/Termind/Features/AI/TranscriptListView.swift @@ -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 = [] + 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) { 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 = [] 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 { diff --git a/Termind/Features/AI/TranscriptNativeRows.swift b/Termind/Features/AI/TranscriptNativeRows.swift index 5ef3c22d..66080b8d 100644 --- a/Termind/Features/AI/TranscriptNativeRows.swift +++ b/Termind/Features/AI/TranscriptNativeRows.swift @@ -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) diff --git a/Termind/Features/SFTP/SFTPBrowserView.swift b/Termind/Features/SFTP/SFTPBrowserView.swift index 09edbe41..d3dd0fab 100644 --- a/Termind/Features/SFTP/SFTPBrowserView.swift +++ b/Termind/Features/SFTP/SFTPBrowserView.swift @@ -301,7 +301,7 @@ struct SFTPBrowserView: View { } if sftpSession.connectionJourney.isPresented { - SSHConnectionJourneyView( + ConnectionJourneyView( journey: sftpSession.connectionJourney, authenticationPrompt: sftpSession.pendingAuthentication, hostKey: sftpSession.pendingHostKey, diff --git a/Termind/Features/SFTP/SFTPSessionModel.swift b/Termind/Features/SFTP/SFTPSessionModel.swift index 10020783..c25655bf 100644 --- a/Termind/Features/SFTP/SFTPSessionModel.swift +++ b/Termind/Features/SFTP/SFTPSessionModel.swift @@ -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? diff --git a/Termind/Features/Terminal/SSHSessionModel.swift b/Termind/Features/Terminal/SSHSessionModel.swift index 98d44c6f..0aecf8a6 100644 --- a/Termind/Features/Terminal/SSHSessionModel.swift +++ b/Termind/Features/Terminal/SSHSessionModel.swift @@ -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) diff --git a/Termind/Features/Terminal/SerialSessionModel.swift b/Termind/Features/Terminal/SerialSessionModel.swift index 8e4a4d16..606c1d23 100644 --- a/Termind/Features/Terminal/SerialSessionModel.swift +++ b/Termind/Features/Terminal/SerialSessionModel.swift @@ -33,7 +33,7 @@ final class SerialSessionModel: Identifiable { private var connection: (any SerialPortConnection)? private var connectionTask: Task? 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 }, diff --git a/Termind/Features/Terminal/TerminalPlaceholderView.swift b/Termind/Features/Terminal/TerminalPlaceholderView.swift index bf4a7177..fe2c2c54 100644 --- a/Termind/Features/Terminal/TerminalPlaceholderView.swift +++ b/Termind/Features/Terminal/TerminalPlaceholderView.swift @@ -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, diff --git a/Termind/Features/Workspaces/WorkspaceContentView.swift b/Termind/Features/Workspaces/WorkspaceContentView.swift index e7be0781..4e9e6985 100644 --- a/Termind/Features/Workspaces/WorkspaceContentView.swift +++ b/Termind/Features/Workspaces/WorkspaceContentView.swift @@ -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) diff --git a/Termind/UI/Components/SSHConnectionJourneyView.swift b/Termind/UI/Components/ConnectionJourneyView.swift similarity index 99% rename from Termind/UI/Components/SSHConnectionJourneyView.swift rename to Termind/UI/Components/ConnectionJourneyView.swift index 2cb416ef..14c96844 100644 --- a/Termind/UI/Components/SSHConnectionJourneyView.swift +++ b/Termind/UI/Components/ConnectionJourneyView.swift @@ -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 diff --git a/TermindTests/AgentToolPresentationTests.swift b/TermindTests/AgentToolPresentationTests.swift index d80e5b01..af3e3be8 100644 --- a/TermindTests/AgentToolPresentationTests.swift +++ b/TermindTests/AgentToolPresentationTests.swift @@ -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( diff --git a/TermindTests/ChatComposerStateTests.swift b/TermindTests/ChatComposerStateTests.swift index 6164cb04..58f71086 100644 --- a/TermindTests/ChatComposerStateTests.swift +++ b/TermindTests/ChatComposerStateTests.swift @@ -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"], ]) } diff --git a/TermindTests/SSHConnectionJourneyTests.swift b/TermindTests/ConnectionJourneyTests.swift similarity index 97% rename from TermindTests/SSHConnectionJourneyTests.swift rename to TermindTests/ConnectionJourneyTests.swift index f9c26e83..31beff12 100644 --- a/TermindTests/SSHConnectionJourneyTests.swift +++ b/TermindTests/ConnectionJourneyTests.swift @@ -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" diff --git a/TermindTests/MarkdownParsingTests.swift b/TermindTests/MarkdownParsingTests.swift index c1e4804b..e67fd028 100644 --- a/TermindTests/MarkdownParsingTests.swift +++ b/TermindTests/MarkdownParsingTests.swift @@ -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) diff --git a/TermindTests/SerialFeatureTests.swift b/TermindTests/SerialFeatureTests.swift index 62738bf8..52163420 100644 --- a/TermindTests/SerialFeatureTests.swift +++ b/TermindTests/SerialFeatureTests.swift @@ -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) diff --git a/TermindTests/TranscriptListViewTests.swift b/TermindTests/TranscriptListViewTests.swift index b7698cc4..0e240c72 100644 --- a/TermindTests/TranscriptListViewTests.swift +++ b/TermindTests/TranscriptListViewTests.swift @@ -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 + )) + ) + } }