Files
Termind/TFM/Features/AI/TranscriptNativeRows.swift
T

3311 lines
127 KiB
Swift

import AgentKit
import AppKit
import ListViewKit
import MarkdownView
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import TermindRemoteInfrastructure
@MainActor
final class TranscriptMarkdownCache {
final class Package {
let source: String
let rendered: TermindMarkdownRenderedDocument
init(
source: String,
allowsShellActions: Bool,
tone: TermindMarkdownTone,
colorScheme: ColorScheme
) {
self.source = source
rendered = TermindMarkdownRenderedDocument(
source: source,
allowsShellActions: allowsShellActions,
theme: TermindMarkdownTheme.make(tone: tone, colorScheme: colorScheme)
)
}
}
private struct Key: Hashable {
let entryID: UUID
let slot: String
let source: String
let shell: Bool
let tone: TermindMarkdownTone
let colorScheme: ColorScheme
}
private let limit: Int
private var values: [Key: Package] = [:]
private var order: [Key] = []
init(limit: Int) { self.limit = limit }
func package(
entryID: UUID,
slot: String,
source: String,
shell: Bool,
tone: TermindMarkdownTone = .primary,
colorScheme: ColorScheme
) -> Package {
let key = Key(
entryID: entryID,
slot: slot,
source: source,
shell: shell,
tone: tone,
colorScheme: colorScheme
)
if let value = values[key] {
touch(key)
return value
}
let value = Package(
source: source,
allowsShellActions: shell,
tone: tone,
colorScheme: colorScheme
)
values[key] = value
touch(key)
while order.count > limit { values[order.removeFirst()] = nil }
return value
}
func prune(validEntryIDs: Set<UUID>) {
let stale = values.keys.filter { !validEntryIDs.contains($0.entryID) }
guard !stale.isEmpty else { return }
let set = Set(stale)
for key in stale { values[key] = nil }
order.removeAll { set.contains($0) }
}
private func touch(_ key: Key) {
order.removeAll { $0 == key }
order.append(key)
}
}
@MainActor
final class TranscriptRowSizingPool {
private struct Key: Hashable {
let id: UUID
let value: TranscriptEntry
let width: Int
let compact: Bool
let revision: UInt64
}
private let limit: Int
private var rows: [UUID: TranscriptNativeRow] = [:]
private var order: [UUID] = []
private var heights: [Key: CGFloat] = [:]
init(limit: Int) { self.limit = limit }
func height(
for entry: TranscriptEntry,
width: CGFloat,
configuration: TranscriptListConfiguration,
presentation: TranscriptPresentationState,
configure: (TranscriptNativeRow) -> Void
) -> CGFloat {
let key = Key(
id: entry.id,
value: entry,
width: Int((width * 2).rounded()),
compact: configuration.isCompact,
revision: presentation.layoutRevision(for: entry.presentationIDs)
)
if let height = heights[key] { return height }
let row = rows[entry.id] ?? TranscriptNativeRow.make(for: entry.kind)
rows[entry.id] = row
touch(entry.id)
configure(row)
let height = max(1, ceil(row.measuredHeight(for: width)))
heights[key] = height
while order.count > limit {
let id = order.removeFirst()
rows[id] = nil
heights = heights.filter { $0.key.id != id }
}
return height
}
func invalidate(_ id: UUID) { heights = heights.filter { $0.key.id != id } }
func prune(validEntryIDs: Set<UUID>) {
let stale = rows.keys.filter { !validEntryIDs.contains($0) }
guard !stale.isEmpty else { return }
let set = Set(stale)
for id in stale { rows[id] = nil }
order.removeAll { set.contains($0) }
heights = heights.filter { !set.contains($0.key.id) }
}
func removeAll() {
rows.removeAll()
order.removeAll()
heights.removeAll()
}
private func touch(_ id: UUID) {
order.removeAll { $0 == id }
order.append(id)
}
}
@MainActor
class TranscriptNativeRow: ListRowView {
private let root = NativeStack.vertical(spacing: Metrics.Space.s)
private var widthConstraint: NSLayoutConstraint?
private var contentWidthConstraint: NSLayoutConstraint?
private var configuration: TranscriptListConfiguration?
private var topInset: CGFloat = 0
private var representedID: UUID?
private var activeContent: NSView?
private var hoverViews: [NSView] = []
private var isHovering = false
private var tracking: NSTrackingArea?
static func make(for kind: TranscriptEntry.Kind) -> TranscriptNativeRow {
switch kind {
case .empty: TranscriptEmptyRow()
case .user: TranscriptUserRow()
case .assistant: TranscriptAssistantRow()
case .tool: TranscriptToolRow()
case .work: TranscriptWorkRow()
case .command: TranscriptCommandRow()
case .plan: TranscriptPlanRow()
case .compaction: TranscriptCompactionRow()
case .compactionActivity: TranscriptCompactionActivityRow()
case .failure: TranscriptFailureRow()
case .interrupted: TranscriptInterruptedRow()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(root)
root.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
root.leadingAnchor.constraint(equalTo: leadingAnchor),
root.trailingAnchor.constraint(equalTo: trailingAnchor),
root.topAnchor.constraint(equalTo: topAnchor),
root.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
override func prepareForReuse() {
super.prepareForReuse()
resetHoverState()
contentWidthConstraint?.isActive = false
contentWidthConstraint = nil
root.removeAllArrangedSubviews()
representedID = nil
activeContent = nil
hoverViews.removeAll()
if let tracking { removeTrackingArea(tracking) }
tracking = nil
}
func configure(
entry: TranscriptEntry,
proposedWidth: CGFloat,
configuration: TranscriptListConfiguration,
actions: TranscriptListActions,
presentation: TranscriptPresentationState,
markdownCache: TranscriptMarkdownCache,
measurement: Bool,
invalidate: @escaping (UUID, Bool) -> Void
) {
self.configuration = configuration
topInset = entry.hasLeadingSpacing ? Metrics.Chat.turnSpacing : 0
let contentWidth = max(
1,
min(
proposedWidth - configuration.horizontalInset * 2,
configuration.isCompact ? .greatestFiniteMagnitude : Metrics.Chat.contentMaxWidth
)
)
let context = NativeTranscriptContext(
entryID: entry.id,
contentWidth: contentWidth,
configuration: configuration,
actions: actions,
presentation: presentation,
markdownCache: markdownCache,
measurement: measurement,
invalidate: invalidate,
registerHoverView: { [weak self] view in
guard !measurement else { return }
guard let self else { return }
hoverViews.append(view)
view.alphaValue = isHovering ? 1 : 0
},
presentError: { [weak self] title, message in
guard !measurement else { return }
self?.presentError(title: title, message: message)
}
)
if representedID == entry.id, let activeContent {
contentWidthConstraint?.constant = contentWidth
hoverViews.removeAll()
if NativeTranscriptFactory.update(activeContent, for: entry, context: context) {
synchronizeHoverWithPointer()
return
}
}
let content = NativeTranscriptFactory.view(for: entry, context: context)
representedID = entry.id
activeContent = content
contentWidthConstraint?.isActive = false
root.removeAllArrangedSubviews()
if topInset > 0 { root.addArrangedSubview(NativeSpacer(height: topInset)) }
let centered = NSView()
centered.translatesAutoresizingMaskIntoConstraints = false
centered.addSubview(content)
content.translatesAutoresizingMaskIntoConstraints = false
let contentWidthConstraint = content.widthAnchor.constraint(equalToConstant: contentWidth)
self.contentWidthConstraint = contentWidthConstraint
NSLayoutConstraint.activate([
content.topAnchor.constraint(equalTo: centered.topAnchor),
content.bottomAnchor.constraint(equalTo: centered.bottomAnchor),
content.centerXAnchor.constraint(equalTo: centered.centerXAnchor),
contentWidthConstraint,
])
root.addArrangedSubview(centered)
centered.widthAnchor.constraint(equalTo: root.widthAnchor).isActive = true
synchronizeHoverWithPointer()
}
func measuredHeight(for width: CGFloat) -> CGFloat {
widthConstraint?.isActive = false
let constraint = widthAnchor.constraint(equalToConstant: width)
constraint.isActive = true
widthConstraint = constraint
layoutSubtreeIfNeeded()
let value = fittingSize.height
constraint.isActive = false
widthConstraint = nil
return value
}
override func updateTrackingAreas() {
super.updateTrackingAreas()
if let tracking { removeTrackingArea(tracking) }
var options: NSTrackingArea.Options = [
.activeInKeyWindow, .inVisibleRect, .mouseEnteredAndExited, .mouseMoved,
]
if currentPointerIsInside == true { options.insert(.assumeInside) }
let area = NSTrackingArea(
rect: bounds,
options: options,
owner: self,
userInfo: nil
)
addTrackingArea(area)
tracking = area
synchronizeHoverWithPointer()
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
guard window != nil else {
resetHoverState()
return
}
synchronizeHoverWithPointer()
}
override func mouseEntered(with event: NSEvent) {
super.mouseEntered(with: event)
setHoverVisible(true)
}
override func mouseExited(with event: NSEvent) {
super.mouseExited(with: event)
setHoverVisible(false)
}
override func mouseMoved(with event: NSEvent) {
super.mouseMoved(with: event)
setHoverVisible(true)
}
private func setHoverVisible(_ visible: Bool) {
guard isHovering != visible else { return }
isHovering = visible
NSAnimationContext.runAnimationGroup { context in
context.duration = configuration?.reduceMotion == true ? 0 : 0.12
for view in hoverViews { view.animator().alphaValue = visible ? 1 : 0 }
}
}
private func synchronizeHoverWithPointer() {
guard let currentPointerIsInside else { return }
setHoverVisible(currentPointerIsInside)
}
private var currentPointerIsInside: Bool? {
guard let window else { return nil }
guard window.isKeyWindow, !isHidden else { return false }
let point = convert(window.mouseLocationOutsideOfEventStream, from: nil)
return visibleRect.contains(point)
}
private func resetHoverState() {
isHovering = false
for view in hoverViews { view.alphaValue = 0 }
}
private func presentError(title: String, message: String) {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.addButton(withTitle: String(localized: AppStrings.Common.ok))
if let window { alert.beginSheetModal(for: window) } else { alert.runModal() }
}
}
@MainActor final class TranscriptEmptyRow: TranscriptNativeRow {}
@MainActor final class TranscriptUserRow: TranscriptNativeRow {}
@MainActor final class TranscriptAssistantRow: TranscriptNativeRow {}
@MainActor final class TranscriptToolRow: TranscriptNativeRow {}
@MainActor final class TranscriptWorkRow: TranscriptNativeRow {}
@MainActor final class TranscriptCommandRow: TranscriptNativeRow {}
@MainActor final class TranscriptPlanRow: TranscriptNativeRow {}
@MainActor final class TranscriptCompactionRow: TranscriptNativeRow {}
@MainActor final class TranscriptCompactionActivityRow: TranscriptNativeRow {}
@MainActor final class TranscriptFailureRow: TranscriptNativeRow {}
@MainActor final class TranscriptInterruptedRow: TranscriptNativeRow {}
@MainActor
private struct NativeTranscriptContext {
let entryID: UUID
let contentWidth: CGFloat
let configuration: TranscriptListConfiguration
let actions: TranscriptListActions
let presentation: TranscriptPresentationState
let markdownCache: TranscriptMarkdownCache
let measurement: Bool
let invalidate: (UUID, Bool) -> Void
let registerHoverView: (NSView) -> Void
let presentError: (String, String) -> Void
}
@MainActor
private enum NativeTranscriptFactory {
private final class AssistantContent: NSView {
private let stack = NativeStack.vertical(spacing: Metrics.Chat.blockSpacing)
private let markdownView = NativeMarkdownView()
private var representedMessageID: UUID?
override init(frame: CGRect) {
super.init(frame: frame)
markdownView.identifier = NSUserInterfaceItemIdentifier(
"Transcript.AssistantMarkdown"
)
stack.translatesAutoresizingMaskIntoConstraints = false
addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: leadingAnchor),
stack.trailingAnchor.constraint(equalTo: trailingAnchor),
stack.topAnchor.constraint(equalTo: topAnchor),
stack.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
func configure(
message: ChatMessage,
canFork: Bool,
context: NativeTranscriptContext
) {
let sameMessage = representedMessageID == message.id
representedMessageID = message.id
stack.removeAllArrangedSubviews()
if !message.text.isEmpty {
markdownView.configure(
package: context.markdownCache.package(
entryID: context.entryID,
slot: "assistant",
source: message.text,
shell: true,
colorScheme: context.configuration.colorScheme
),
measurement: context.measurement,
proposedWidth: context.contentWidth,
fillsWidth: true,
actions: .init(
enabled: context.configuration.showsTerminalActions
&& context.actions.canDeliverCommand(),
deliver: context.actions.deliverCommand
),
preservesRenderedViews: sameMessage
)
stack.addArrangedSubview(markdownView)
}
if message.isStreaming, message.text.isEmpty {
let retry = message.retryProgress
let title =
retry.map {
AgentWorkPresentation.reconnectingTitle(
$0, locale: context.configuration.locale
)
} ?? String(localized: AppStrings.Assistant.workingProgress)
let activity = NativeActivityLabel(
text: title,
alternatives: retry == nil
? [
String(localized: AppStrings.Assistant.workingProgress),
String(localized: AppStrings.Assistant.thinkingProgress),
String(localized: AppStrings.Assistant.reasoningProgress),
] : [title],
color: retry == nil ? .secondaryLabelColor : .tertiaryLabelColor,
reduceMotion: context.configuration.reduceMotion,
measurement: context.measurement
)
activity.identifier = NSUserInterfaceItemIdentifier(
"Transcript.WorkingActivity"
)
stack.addArrangedSubview(activity)
} else if !message.isStreaming {
let actions = NativeStack.horizontal()
actions.identifier = NSUserInterfaceItemIdentifier("Transcript.MessageActions")
actions.addArrangedSubview(NativeCopyButton(text: message.text))
if canFork {
let fork = NativeButton(
symbol: "arrow.triangle.branch",
help: String(localized: AppStrings.Assistant.fork)
)
fork.handler = { context.actions.forkAssistantMessage(message.id) }
actions.addArrangedSubview(fork)
}
actions.addArrangedSubview(
NativeLabel(
ChatMessageTimestampFormatter.string(
for: message.createdAt,
locale: context.configuration.locale
),
font: .monospacedDigitSystemFont(
ofSize: NSFont.smallSystemFontSize,
weight: .regular
),
color: .tertiaryLabelColor
))
actions.addArrangedSubview(NativeFlexibleSpacer())
context.registerHoverView(actions)
stack.addArrangedSubview(actions)
}
for command in message.commands {
stack.addArrangedSubview(commandCard(command, context: context))
}
}
}
static func update(
_ view: NSView,
for entry: TranscriptEntry,
context: NativeTranscriptContext
) -> Bool {
guard case .assistant(let message, let canFork) = entry.content,
let view = view as? AssistantContent
else { return false }
view.configure(message: message, canFork: canFork, context: context)
return true
}
static func view(for entry: TranscriptEntry, context: NativeTranscriptContext) -> NSView {
switch entry.content {
case .empty:
return empty()
case .interrupted:
return labelRow(
symbol: "pause.circle",
text: String(localized: AppStrings.Assistant.thisRunWasInterruptedAndWasNotResumed),
color: .secondaryLabelColor
)
case .user(let message, let canEdit):
return user(message, canEdit: canEdit, context: context)
case .assistant(let message, let canFork):
return assistant(message, canFork: canFork, context: context)
case .tool(let card):
return tool(card, context: context)
case .work(let group):
return work(group, context: context)
case .command(let command):
return commandCard(command, context: context)
case .plan(let card):
return plan(card, context: context)
case .compaction(let mark):
return compaction(mark)
case .compactionActivity(let activity):
return compactionActivity(activity, context: context)
case .failure(let failure):
return failureRow(failure, context: context)
}
}
private static func empty() -> NSView {
let stack = NativeStack.vertical(spacing: Metrics.Space.m)
stack.edgeInsets = .init(
top: Metrics.Space.xl, left: 0, bottom: Metrics.Space.xl, right: 0
)
stack.addArrangedSubview(
NativeLabel(
String(localized: AppStrings.Assistant.startAConversation),
font: .boldSystemFont(ofSize: NSFont.systemFontSize)
))
stack.addArrangedSubview(
NativeLabel(
String(localized: AppStrings.Assistant.askAQuestionOrDescribeWhatYouWantToDo),
color: .secondaryLabelColor
))
return stack
}
private static func user(
_ message: ChatMessage,
canEdit: Bool,
context: NativeTranscriptContext
) -> NSView {
let outer = NativeStack.vertical(spacing: Metrics.Chat.blockSpacing)
outer.alignment = .trailing
let gutter =
context.configuration.isCompact
? Metrics.Chat.compactGutter : Metrics.Chat.gutter
if !message.images.isEmpty {
let images = NativeFlowView(
spacing: Metrics.Space.s,
horizontalAlignment: .trailing
)
images.identifier = NSUserInterfaceItemIdentifier("Transcript.UserImages")
for attachment in message.images {
guard let image = NSImage(data: attachment.data) else { continue }
let side =
context.configuration.isCompact
? Metrics.Chat.compactImageTile : Metrics.Chat.imageTile
let button = NativeImageTile(
image: image,
side: side,
label: attachment.label
)
button.handler = {
Task {
do { try await ContextAttachmentOpener.open(attachment) } catch {
context.presentError(
String(localized: AppStrings.Assistant.couldnTOpenAttachment),
error.localizedDescription
)
}
}
}
images.addItem(button)
}
if !images.isEmpty { outer.addArrangedSubview(images) }
}
if !message.contextChips.isEmpty {
let chips = NativeFlowView(
spacing: Metrics.Space.xs,
horizontalAlignment: .trailing
)
chips.identifier = NSUserInterfaceItemIdentifier("Transcript.UserChips")
for chip in message.contextChips {
let button = NativeChipButton(symbol: chip.kind.symbol, title: chip.label)
button.toolTip = String(localized: AppStrings.Assistant.open(chip.label))
button.handler = {
Task {
do {
if chip.kind == .image,
let image = message.images.first(where: { $0.id == chip.id })
?? message.images.first(where: { $0.label == chip.label })
{
try await ContextAttachmentOpener.open(image)
} else if let attachment = chip.attachment {
try await ContextAttachmentOpener.open(attachment)
}
} catch {
context.presentError(
String(localized: AppStrings.Assistant.couldnTOpenAttachment),
error.localizedDescription
)
}
}
}
chips.addItem(button)
}
outer.addArrangedSubview(chips)
}
if context.presentation.isEditingMessage(message.id) || !message.text.isEmpty {
let bubbleLine = NSView()
let bubble = NativeCard(radius: Metrics.Chat.bubbleRadius)
bubble.identifier = NSUserInterfaceItemIdentifier("Transcript.UserBubble")
bubble.fillProvider = {
nativeHierarchicalColor(.controlAccentColor, opacity: 0.10)
}
bubble.onLayout = {
guard !context.presentation.isEditingMessage(message.id), !context.measurement
else { return }
context.presentation.setMessageBubbleWidth(bubble.bounds.width, for: message.id)
}
let body = NativeStack.vertical(spacing: Metrics.Space.s)
bubble.setContent(
body,
insets: .init(
top: Metrics.Space.m,
left: Metrics.Space.l,
bottom: Metrics.Space.m,
right: Metrics.Space.l
)
)
if context.presentation.isEditingMessage(message.id) {
body.addArrangedSubview(editor(message, context: context))
} else {
let markdown = NativeMarkdownView()
markdown.identifier = NSUserInterfaceItemIdentifier("Transcript.UserMarkdown")
markdown.configure(
package: context.markdownCache.package(
entryID: context.entryID,
slot: "user",
source: message.text,
shell: false,
colorScheme: context.configuration.colorScheme
),
measurement: context.measurement,
proposedWidth: max(
1,
context.contentWidth - gutter - Metrics.Space.l * 2
),
fillsWidth: false,
actions: .init()
)
body.addArrangedSubview(markdown)
}
bubble.translatesAutoresizingMaskIntoConstraints = false
bubbleLine.addSubview(bubble)
NSLayoutConstraint.activate([
bubble.topAnchor.constraint(equalTo: bubbleLine.topAnchor),
bubble.bottomAnchor.constraint(equalTo: bubbleLine.bottomAnchor),
bubble.trailingAnchor.constraint(equalTo: bubbleLine.trailingAnchor),
bubble.leadingAnchor.constraint(
greaterThanOrEqualTo: bubbleLine.leadingAnchor,
constant: gutter
),
bubble.widthAnchor.constraint(
lessThanOrEqualTo: bubbleLine.widthAnchor,
constant: -gutter
),
])
outer.addArrangedSubview(bubbleLine)
bubbleLine.widthAnchor.constraint(equalTo: outer.widthAnchor).isActive = true
}
let actions = NativeStack.horizontal()
actions.identifier = NSUserInterfaceItemIdentifier("Transcript.MessageActions")
actions.addArrangedSubview(NativeFlexibleSpacer())
let hoverActions = NativeStack.horizontal()
hoverActions.addArrangedSubview(
NativeLabel(
ChatMessageTimestampFormatter.string(
for: message.createdAt,
locale: context.configuration.locale
),
font: .monospacedDigitSystemFont(
ofSize: NSFont.smallSystemFontSize,
weight: .regular
),
color: .tertiaryLabelColor
))
if canEdit, message.origin == nil {
let edit = NativeButton(symbol: "pencil", help: String(localized: AppStrings.Common.edit))
edit.handler = {
context.presentation.beginEditingMessage(message.id, text: message.text)
context.invalidate(context.entryID, false)
}
hoverActions.addArrangedSubview(edit)
}
hoverActions.addArrangedSubview(NativeCopyButton(text: message.text))
actions.addArrangedSubview(hoverActions)
if message.origin == nil {
context.registerHoverView(actions)
} else {
context.registerHoverView(hoverActions)
}
if let origin = message.origin {
let sourceTitle =
context.configuration.chatDestinations[origin.sourceConversationID]
?? origin.sourceTitle
let label =
sourceTitle.map {
AppLocalization.resolve(
AppStrings.Assistant.agentFrom($0),
locale: context.configuration.locale)
}
?? AppLocalization.resolve(
AppStrings.Assistant.agent,
locale: context.configuration.locale)
let agent = NativeButton(symbol: "sparkles", help: label)
agent.identifier = NSUserInterfaceItemIdentifier("Transcript.AgentOrigin")
agent.setAccessibilityLabel(label)
agent.isEnabled = context.configuration.chatDestinations[origin.sourceConversationID] != nil
agent.handler = { context.actions.navigateChat(origin.sourceConversationID) }
actions.addArrangedSubview(agent)
}
outer.addArrangedSubview(actions)
actions.widthAnchor.constraint(equalTo: outer.widthAnchor).isActive = true
return outer
}
private static func editor(
_ message: ChatMessage,
context: NativeTranscriptContext
) -> NSView {
let captured = context.presentation.messageBubbleWidth(message.id) - Metrics.Space.l * 2
let editor = NativeMessageEditor(
text: context.presentation.messageEditText(message.id),
width: max(captured, context.configuration.isCompact ? 160 : 200),
measurement: context.measurement
)
editor.onChange = { value in
context.presentation.setMessageEditText(value, for: message.id)
context.invalidate(context.entryID, false)
}
editor.onCancel = {
context.presentation.endEditingMessage(message.id)
context.invalidate(context.entryID, false)
}
editor.onCommit = { value in
let value = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return }
context.presentation.endEditingMessage(message.id)
context.actions.editUserMessage(message.id, value)
context.invalidate(context.entryID, false)
}
return editor
}
private static func assistant(
_ message: ChatMessage,
canFork: Bool,
context: NativeTranscriptContext
) -> NSView {
let view = AssistantContent()
view.configure(message: message, canFork: canFork, context: context)
return view
}
private static func tool(
_ card: AgentToolCard,
defaultExpanded: Bool = false,
context: NativeTranscriptContext
) -> NSView {
let stack = NativeStack.vertical()
let disclosureID = AssistantTranscriptDisclosureID.tool(card.id)
let expanded = context.presentation.disclosures.isExpanded(disclosureID, default: defaultExpanded)
let host = card.invocation.targetHostID.flatMap(context.actions.hostName)
let title = AgentToolPresentation.summary(
for: card,
hostName: host,
locale: context.configuration.locale
)
let header = NativeDisclosureHeader(
symbol: AgentToolPresentation.symbol(for: card),
title: title,
iconColor: toolColor(card.state),
titleColor: card.state == .running ? .secondaryLabelColor : .labelColor,
expanded: expanded,
active: card.state == .running,
reviewing: card.state == .reviewing,
reduceMotion: context.configuration.reduceMotion,
measurement: context.measurement,
lineLimit: AgentToolPresentation.usesCompactDetails(for: card.descriptor) ? 1 : (expanded ? 0 : 1)
)
header.alphaValue = card.state == .interrupted ? 0.5 : 1
header.toolTip =
expanded
? String(localized: AppStrings.Assistant.hideDetails)
: String(localized: AppStrings.Assistant.showDetails)
header.handler = {
context.presentation.disclosures.toggleDisclosure(disclosureID, default: defaultExpanded)
context.invalidate(context.entryID, false)
}
stack.addArrangedSubview(header)
if expanded {
let detail = NativeStack.vertical()
detail.edgeInsets = .init(
top: 0,
left: Metrics.Sidebar.rowIcon,
bottom: 0,
right: 0
)
if let review = card.review {
detail.addArrangedSubview(
NativeLabel(
String(
localized: AppStrings.Assistant.automaticReview(
risk: review.risk.displayName, reason: review.reason)),
font: .systemFont(ofSize: NSFont.smallSystemFontSize),
color: .secondaryLabelColor,
selectable: true
))
}
if let projected = AgentToolPresentation.detail(
for: card, presenter: TermindAgentTools.presenter(for: card.descriptor),
locale: context.configuration.locale
) {
if AgentToolPresentation.usesCompactDetails(for: card.descriptor) {
detail.addArrangedSubview(compactToolDetail(projected, card: card, context: context))
} else {
for item in projected.items {
detail.addArrangedSubview(
toolDetail(item, isError: card.result?.isError == true, context: context))
}
}
}
if AgentToolPresentation.showsOutputTruncatedNotice(for: card) {
detail.addArrangedSubview(
NativeLabel(
String(localized: AppStrings.Assistant.outputTruncated),
font: .systemFont(ofSize: NSFont.smallSystemFontSize - 1),
color: .systemOrange
))
}
stack.addArrangedSubview(detail)
}
return stack
}
private static func work(_ group: AgentWorkGroup, context: NativeTranscriptContext) -> NSView {
let stack = NativeStack.vertical(spacing: Metrics.Space.m)
let defaultExpanded = AgentWorkPresentation.isExpandedByDefault(
for: group.run.state,
appearsAfterAnswer: group.appearsAfterAnswer,
isCompact: context.configuration.isCompact
)
let expanded = context.presentation.disclosures.isExpanded(.work(group.id), default: defaultExpanded)
let titleProvider = {
if let retry = group.retryProgress {
return AgentWorkPresentation.reconnectingTitle(
retry, locale: context.configuration.locale
)
}
return AgentWorkPresentation.title(
for: group.run.state,
from: group.run.startedAt,
to: group.run.finishedAt ?? .now,
locale: context.configuration.locale
)
}
let header = NativeDisclosureHeader(
symbol: nil,
title: titleProvider(),
titleColor: group.retryProgress == nil ? .secondaryLabelColor : .tertiaryLabelColor,
expanded: expanded,
active: group.retryProgress != nil,
reduceMotion: context.configuration.reduceMotion,
measurement: context.measurement,
lineLimit: 1,
titleProvider: group.run.state.isTerminal ? nil : titleProvider
)
header.toolTip =
expanded
? String(localized: AppStrings.Assistant.hideWork) : String(localized: AppStrings.Assistant.showWork)
header.handler = {
context.presentation.disclosures.toggleDisclosure(.work(group.id), default: defaultExpanded)
context.invalidate(context.entryID, false)
}
stack.addArrangedSubview(header)
if expanded {
let workItems = NativeStack.vertical(spacing: Metrics.Chat.blockSpacing)
for item in group.items {
switch item {
case .reasoning(let block):
let reasoning = markdown(
source: block.text,
slot: "reasoning.\(block.id)",
shell: false,
tone: .secondary,
context: context
)
reasoning.setScanning(
group.activeReasoningBlockID == block.id,
reduceMotion: context.configuration.reduceMotion,
measurement: context.measurement
)
workItems.addArrangedSubview(reasoning)
case .message(let message):
if message.text.isEmpty, message.isStreaming {
let title =
group.retryProgress.map {
AgentWorkPresentation.reconnectingTitle(
$0, locale: context.configuration.locale
)
} ?? String(localized: AppStrings.Assistant.workingProgress)
workItems.addArrangedSubview(
NativeActivityLabel(
text: title,
alternatives: [title],
color: group.retryProgress == nil
? .secondaryLabelColor : .tertiaryLabelColor,
reduceMotion: context.configuration.reduceMotion,
measurement: context.measurement
))
} else if !message.text.isEmpty {
workItems.addArrangedSubview(
markdown(
source: message.text,
slot: "work-message.\(message.id)",
shell: true,
context: context
))
}
case .tool(let card):
workItems.addArrangedSubview(tool(card, context: context))
case .tools(let batch):
workItems.addArrangedSubview(toolBatch(batch, context: context))
}
}
stack.addArrangedSubview(workItems)
}
let separator = NSBox()
separator.boxType = .separator
stack.addArrangedSubview(separator)
return stack
}
private static func toolBatch(
_ batch: AgentToolBatch,
context: NativeTranscriptContext
) -> NSView {
if batch.cards.count == 1, let card = batch.cards.first {
return tool(card, context: context)
}
let stack = NativeStack.vertical(spacing: Metrics.Space.s)
let disclosureID = AssistantTranscriptDisclosureID.toolBatch(batch.id)
let expanded = context.presentation.disclosures.isExpanded(disclosureID)
let unfinished = batch.cards.filter { !$0.state.isTerminal }
let fallbackTitle: String
if unfinished.isEmpty {
fallbackTitle = AgentToolPresentation.batchSummary(
for: batch.cards,
locale: context.configuration.locale
)
} else {
fallbackTitle = unfinished.map { card in
AgentToolPresentation.summary(
for: card,
hostName: card.invocation.targetHostID.flatMap(context.actions.hostName),
locale: context.configuration.locale
)
}.joined(separator: "; ")
}
let transientReasoning = unfinished.isEmpty ? nil : batch.reasoning
let reasoningDeadline = transientReasoning.flatMap { reasoning in
context.measurement
? nil
: context.presentation.disclosures.reasoningTitleDeadline(
for: batch.id,
revision: reasoning.revision
)
}
let header = NativeDisclosureHeader(
symbol: AgentToolPresentation.batchSymbol(for: batch.cards),
title: fallbackTitle,
iconColor: batchColor(AgentToolPresentation.batchTint(for: batch.cards)),
titleColor: .secondaryLabelColor,
expanded: expanded,
active: !unfinished.isEmpty,
reduceMotion: context.configuration.reduceMotion,
measurement: context.measurement,
lineLimit: 1,
transientMarkdownTitle: transientReasoning?.markdown,
transientUntil: reasoningDeadline
)
header.toolTip =
expanded
? String(localized: AppStrings.Assistant.hideTools) : String(localized: AppStrings.Assistant.showTools)
header.handler = {
context.presentation.disclosures.toggleDisclosure(disclosureID)
context.invalidate(context.entryID, false)
}
stack.addArrangedSubview(header)
if expanded {
let cards = NativeStack.vertical(spacing: Metrics.Chat.blockSpacing)
for card in batch.cards {
cards.addArrangedSubview(tool(card, context: context))
}
stack.addArrangedSubview(cards)
}
return stack
}
private static func plan(_ card: AgentPlanCard, context: NativeTranscriptContext) -> NSView {
let container = NativeCard(radius: Metrics.Radius.large)
// 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,
left: Metrics.Space.l,
bottom: Metrics.Space.l,
right: Metrics.Space.l
)
container.setContent(stack)
let expanded = context.presentation.disclosures.isExpanded(.plan(card.id))
let symbol: String
let tint: NSColor
let outcome: String?
switch card.outcome {
case .pending:
symbol = "lightbulb.max"
tint = .controlAccentColor
outcome = nil
case .implemented:
symbol = "checkmark.circle.fill"
tint = .systemGreen
outcome = String(localized: AppStrings.Assistant.implemented)
case .discarded:
symbol = "xmark.circle"
tint = .secondaryLabelColor
outcome = String(localized: AppStrings.Assistant.discarded)
}
let header = NativeDisclosureHeader(
symbol: symbol,
title: card.plan.title,
iconColor: tint,
titleColor: .labelColor,
expanded: expanded,
reduceMotion: context.configuration.reduceMotion,
measurement: context.measurement,
lineLimit: expanded ? 0 : 1,
trailingText: outcome,
titleFont: .systemFont(ofSize: NSFont.systemFontSize, weight: .medium)
)
header.toolTip =
expanded
? String(localized: AppStrings.Assistant.hideThePlan)
: String(localized: AppStrings.Assistant.showThePlan)
header.handler = {
context.presentation.disclosures.toggleDisclosure(.plan(card.id))
context.invalidate(context.entryID, false)
}
stack.addArrangedSubview(header)
let body = markdown(
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()
indented.edgeInsets = .init(
top: 0,
left: Metrics.Sidebar.rowIcon + Metrics.Space.s,
bottom: 0,
right: 0
)
let preview = NativePlanPreview(content: body, expanded: expanded)
preview.handler = {
guard !expanded else { return }
context.presentation.disclosures.toggleDisclosure(.plan(card.id))
context.invalidate(context.entryID, false)
}
indented.addArrangedSubview(preview)
stack.addArrangedSubview(indented)
if card.outcome == .pending {
let actions = NativeStack.horizontal(spacing: Metrics.Space.m)
let discard = NativeButton(title: String(localized: AppStrings.Common.discard))
discard.contentTintColor = .systemRed
discard.handler = { context.actions.resolvePlan(card.id, .discarded) }
let implement = NativeButton(
title: String(localized: AppStrings.Assistant.implement),
bordered: true,
prominent: true
)
implement.identifier = NSUserInterfaceItemIdentifier("Transcript.PlanImplement")
implement.isEnabled = !context.configuration.isConversationStreaming
implement.handler = { context.actions.implementPlan(card) }
actions.addArrangedSubview(discard)
actions.addArrangedSubview(NativeFlexibleSpacer())
actions.addArrangedSubview(
NativeLabel(
String(localized: AppStrings.Assistant.replyToKeepPlanning),
font: .systemFont(ofSize: NSFont.smallSystemFontSize),
color: .secondaryLabelColor
))
actions.addArrangedSubview(implement)
stack.addArrangedSubview(actions)
}
return container
}
private static func compaction(_ mark: AgentCompactionMark) -> NSView {
let row = NativeStack.horizontal(spacing: Metrics.Space.s)
row.addArrangedSubview(NativeRule())
row.addArrangedSubview(
labelRow(
symbol: "doc.text",
text: String(
localized:
AppStrings.Assistant.compactionSummary(
summarized: ContextBreakdownView.compact(mark.summarizedTokens),
retained: ContextBreakdownView.compact(mark.keptTokens))
),
color: .secondaryLabelColor
))
row.addArrangedSubview(NativeRule())
row.toolTip = String(
localized: AppStrings.Assistant.compactionExplanation
)
return row
}
private static func compactionActivity(
_ activity: AgentCompactionActivity,
context: NativeTranscriptContext
) -> NSView {
let stack = NativeStack.vertical()
let failure: String? = if case .failed(let reason) = activity.state { reason } else { nil }
let title =
failure == nil
? String(localized: AppStrings.Assistant.compactingContext)
: String(localized: AppStrings.Assistant.compactionFailed)
let expanded = context.presentation.disclosures.isExpanded(.compactionActivity(activity.id))
let header = NativeDisclosureHeader(
symbol: failure == nil ? "doc.text.magnifyingglass" : "exclamationmark.triangle",
title: title,
iconColor: failure == nil ? .secondaryLabelColor : .systemRed,
titleColor: failure == nil ? .secondaryLabelColor : .labelColor,
expanded: failure == nil ? nil : expanded,
active: failure == nil,
reduceMotion: context.configuration.reduceMotion,
measurement: context.measurement,
lineLimit: 1
)
header.toolTip =
failure == nil
? String(localized: AppStrings.Assistant.compactingContext)
: String(localized: AppStrings.Assistant.showFailureDetails)
header.handler = {
guard failure != nil else { return }
context.presentation.disclosures.toggleDisclosure(.compactionActivity(activity.id))
context.invalidate(context.entryID, false)
}
stack.addArrangedSubview(header)
if let failure, expanded {
let detail = NativeStack.horizontal(spacing: Metrics.Space.m)
detail.edgeInsets = .init(
top: 0,
left: Metrics.Sidebar.rowIcon,
bottom: 0,
right: 0
)
detail.addArrangedSubview(NativeVerticalRule())
detail.addArrangedSubview(
NativeLabel(
failure,
font: .monospacedSystemFont(
ofSize: NSFont.smallSystemFontSize,
weight: .regular
),
color: .systemRed,
selectable: true
))
stack.addArrangedSubview(detail)
}
return stack
}
private static func failureRow(
_ failure: AgentRunFailureNotice,
context: NativeTranscriptContext
) -> NSView {
let stack = NativeStack.vertical()
let disclosureID = AssistantTranscriptDisclosureID.failure(failure.id)
let expanded = context.presentation.disclosures.isExpanded(disclosureID)
let header = NativeDisclosureHeader(
symbol: "exclamationmark.triangle",
title: String(localized: AppStrings.Assistant.agentError),
iconColor: .systemRed,
titleColor: .labelColor,
expanded: expanded,
reduceMotion: context.configuration.reduceMotion,
measurement: context.measurement,
lineLimit: 1
)
header.toolTip = String(localized: AppStrings.Assistant.showFailureDetails)
header.handler = {
context.presentation.disclosures.toggleDisclosure(disclosureID)
context.invalidate(context.entryID, false)
}
stack.addArrangedSubview(header)
if expanded {
let detail = NativeStack.horizontal(spacing: Metrics.Space.m)
detail.edgeInsets = .init(
top: 0, left: Metrics.Sidebar.rowIcon, bottom: 0, right: 0
)
detail.addArrangedSubview(NativeVerticalRule())
detail.addArrangedSubview(
NativeLabel(
failure.reason,
font: .monospacedSystemFont(
ofSize: NSFont.smallSystemFontSize,
weight: .regular
),
color: .systemRed,
selectable: true
))
detail.addArrangedSubview(NativeCopyButton(text: failure.reason))
stack.addArrangedSubview(detail)
}
return stack
}
private static func markdown(
source: String,
slot: String,
shell: Bool,
tone: TermindMarkdownTone = .primary,
fillsWidth: Bool = true,
horizontalInset: CGFloat = 0,
context: NativeTranscriptContext
) -> NativeMarkdownView {
let view = NativeMarkdownView()
view.configure(
package: context.markdownCache.package(
entryID: context.entryID,
slot: slot,
source: source,
shell: shell,
tone: tone,
colorScheme: context.configuration.colorScheme
),
measurement: context.measurement,
proposedWidth: max(1, context.contentWidth - horizontalInset),
fillsWidth: fillsWidth,
actions: .init(
enabled: context.configuration.showsTerminalActions
&& context.actions.canDeliverCommand(),
deliver: context.actions.deliverCommand
)
)
return view
}
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, font: font, color: color))
return row
}
private static func commandCard(
_ command: SuggestedCommand,
context: NativeTranscriptContext
) -> NSView {
let assessment = command.risk
let card = NativeCard(radius: Metrics.Radius.large)
card.identifier = NSUserInterfaceItemIdentifier("Transcript.CommandCard")
// The transcript's visual-effect hierarchy makes semantic control
// surfaces look grey. Inherit the row background instead.
card.fillProvider = { .clear }
card.strokeProvider = {
assessment.risk == .destructive
? NSColor.systemRed.withAlphaComponent(0.4)
: .separatorColor
}
card.layer?.masksToBounds = true
let stack = NativeStack.vertical(spacing: Metrics.Space.m)
stack.identifier = NSUserInterfaceItemIdentifier("Transcript.CommandCardContent")
stack.edgeInsets = .init(
top: Metrics.Space.l,
left: Metrics.Space.l,
bottom: Metrics.Space.l,
right: Metrics.Space.l
)
card.setContent(stack)
let header = NativeStack.horizontal(spacing: Metrics.Space.m)
header.identifier = NSUserInterfaceItemIdentifier("Transcript.CommandCardHeader")
header.addArrangedSubview(
labelRow(
symbol: "sparkles",
text: String(localized: AppStrings.Assistant.suggestedCommand),
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 = { .clear }
let commandLabel = NativeLabel(
command.command,
font: .monospacedSystemFont(
ofSize: NSFont.systemFontSize - 1,
weight: .regular
),
selectable: true
)
commandLabel.identifier = NSUserInterfaceItemIdentifier(
"Transcript.CommandCardCommand"
)
let commandInsets = NativeStack.vertical()
commandInsets.edgeInsets = .init(
top: Metrics.Space.m,
left: Metrics.Space.m,
bottom: Metrics.Space.m,
right: Metrics.Space.m
)
commandInsets.addArrangedSubview(commandLabel)
commandBackground.setContent(commandInsets)
stack.addArrangedSubview(commandBackground)
if !command.explanation.isEmpty {
let explanation = NativeLabel(
command.explanation,
font: .systemFont(ofSize: NSFont.smallSystemFontSize),
color: .secondaryLabelColor
)
explanation.identifier = NSUserInterfaceItemIdentifier(
"Transcript.CommandCardExplanation"
)
stack.addArrangedSubview(explanation)
}
let actions = NativeStack.horizontal(spacing: Metrics.Space.s)
actions.identifier = NSUserInterfaceItemIdentifier("Transcript.CommandCardActions")
if context.configuration.showsTerminalActions {
let insert = NativeButton(title: String(localized: AppStrings.Common.insert), bordered: true)
insert.identifier = NSUserInterfaceItemIdentifier("Transcript.CommandCardInsert")
insert.isEnabled = context.actions.canDeliverCommand()
insert.handler = { context.actions.deliverCommand(command.command, false) }
let run = NativeButton(
title: String(localized: AppStrings.Common.run),
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
let deliver = { context.actions.deliverCommand(command.command, true) }
if assessment.requiresSecondConfirmation {
presentDestructiveCommandConfirmation(
command.command,
reasons: assessment.reasons,
from: run,
action: deliver
)
} else {
deliver()
}
}
actions.addArrangedSubview(insert)
actions.addArrangedSubview(run)
}
actions.addArrangedSubview(NativeFlexibleSpacer())
actions.addArrangedSubview(NativeCopyButton(text: command.command))
stack.addArrangedSubview(actions)
return card
}
private static func compactToolDetail(
_ detail: AgentToolDetail, card: AgentToolCard, context: NativeTranscriptContext
) -> NSView {
let preview = AgentToolDetailPreview(detail)
let id = AssistantTranscriptDisclosureID.toolDetail(card.id)
let expanded = context.presentation.disclosures.isExpanded(id)
let body = NativeStack.vertical()
for item in expanded ? preview.fullItems : preview.items {
body.addArrangedSubview(
toolDetail(item, isError: card.result?.isError == true, context: context))
}
let scale = max(1, NSFont.preferredFont(forTextStyle: .caption1).pointSize / NSFont.smallSystemFontSize)
let height = 180 * scale
let width = body.widthAnchor.constraint(equalToConstant: max(1, context.contentWidth - 48))
width.isActive = true
body.layoutSubtreeIfNeeded()
let needsMore = preview.isShortened || body.fittingSize.height > height
width.isActive = false
let clipped = NativePlanPreview(content: body, expanded: expanded, previewHeight: height)
let toggle = {
context.presentation.disclosures.toggleDisclosure(id)
context.invalidate(context.entryID, false)
}
clipped.handler = needsMore ? toggle : nil
let stack = NativeStack.vertical()
stack.addArrangedSubview(clipped)
if expanded || needsMore {
let actions = NativeStack.horizontal()
let more = NativeButton(
title: AppLocalization.resolve(
expanded ? AppStrings.Assistant.showLess : AppStrings.Assistant.showMore,
locale: context.configuration.locale))
more.identifier = NSUserInterfaceItemIdentifier("AssistantTool.More")
more.handler = toggle
actions.addArrangedSubview(more)
actions.addArrangedSubview(NativeCopyButton(text: preview.fullText))
stack.addArrangedSubview(actions)
}
stack.identifier = NSUserInterfaceItemIdentifier("AssistantTool.CompactDetails")
return stack
}
private static func toolDetail(
_ item: AgentToolDetail.Item, isError: Bool, context: NativeTranscriptContext
) -> NSView {
switch item {
case .field(let field):
let row = NativeStack.horizontal(spacing: Metrics.Space.s)
let key = NativeLabel(
field.label,
font: .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium),
color: .secondaryLabelColor
)
key.widthAnchor.constraint(greaterThanOrEqualToConstant: 76).isActive = true
row.addArrangedSubview(key)
let font: NSFont =
field.isMonospaced
? .monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular)
: .systemFont(ofSize: NSFont.smallSystemFontSize)
if case .some(.chat(let id)) = field.navigation {
let link = NativeButton(title: field.value)
link.font = font
link.contentTintColor = .linkColor
link.isEnabled = context.configuration.chatDestinations[id] != nil
link.handler = { context.actions.navigateChat(id) }
row.addArrangedSubview(link)
} else if let raw = field.url, let url = URL(string: raw) {
let link = NativeButton(title: field.value)
link.font = font
link.contentTintColor = .linkColor
link.handler = { NSWorkspace.shared.open(url) }
row.addArrangedSubview(link)
} else {
row.addArrangedSubview(
NativeLabel(
field.value,
font: font,
color: isError ? .systemRed : .labelColor,
selectable: true
))
}
return row
case .message(let text, let tint):
return NativeLabel(
text,
font: .systemFont(ofSize: NSFont.smallSystemFontSize),
color: detailColor(tint),
selectable: true
)
case .text(let block):
let stack = NativeStack.vertical(spacing: Metrics.Space.xs)
if let title = block.title {
stack.addArrangedSubview(
NativeLabel(
title,
font: .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium),
color: .secondaryLabelColor
))
}
let preview = AgentToolPresentation.preview(block.text)
stack.addArrangedSubview(
NativeLabel(
preview.displayed,
font: block.style == .monospaced
? .monospacedSystemFont(
ofSize: NSFont.smallSystemFontSize,
weight: .regular
) : .systemFont(ofSize: NSFont.smallSystemFontSize),
color: isError ? .systemRed : .secondaryLabelColor,
selectable: true
))
if preview.isShortened {
let actions = NativeStack.horizontal(spacing: Metrics.Space.s)
actions.addArrangedSubview(
NativeLabel(
String(localized: AppStrings.Assistant.outputShortenedForDisplay),
font: .systemFont(ofSize: NSFont.smallSystemFontSize - 1),
color: .secondaryLabelColor
))
actions.addArrangedSubview(NativeCopyButton(text: preview.full))
stack.addArrangedSubview(actions)
}
return stack
case .list(let list):
let stack = NativeStack.vertical()
if let title = list.title {
stack.addArrangedSubview(
NativeLabel(
title,
font: .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium),
color: .secondaryLabelColor
))
}
if list.rows.isEmpty, let empty = list.emptyMessage {
stack.addArrangedSubview(
NativeLabel(
empty,
font: .systemFont(ofSize: NSFont.smallSystemFontSize),
color: .secondaryLabelColor
))
} else {
for row in list.rows { stack.addArrangedSubview(toolListRow(row, context: context)) }
}
return stack
case .groupedList(let list):
let stack = NativeStack.vertical()
stack.addArrangedSubview(
NativeLabel(
list.summary,
font: .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium),
color: .secondaryLabelColor
))
for section in list.sections {
stack.addArrangedSubview(
NativeLabel(
section.title,
font: .boldSystemFont(ofSize: NSFont.smallSystemFontSize)
))
for row in section.rows { stack.addArrangedSubview(toolListRow(row, context: context)) }
}
return stack
}
}
private static func toolListRow(
_ row: AgentToolDetail.ListRow, context: NativeTranscriptContext
) -> NSView {
let outer = NativeStack.horizontal(spacing: Metrics.Space.s)
if let symbol = row.symbol {
let icon = NSImageView(
image: NSImage(systemSymbolName: symbol, accessibilityDescription: nil) ?? NSImage()
)
icon.contentTintColor = detailColor(row.tint)
icon.widthAnchor.constraint(equalToConstant: Metrics.Sidebar.rowIcon).isActive = true
outer.addArrangedSubview(icon)
}
let body = NativeStack.vertical(spacing: Metrics.Space.xxs)
if case .some(.chat(let id)) = row.navigation {
let link = NativeButton(title: row.title)
link.font = .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium)
link.contentTintColor = .linkColor
link.isEnabled = context.configuration.chatDestinations[id] != nil
link.handler = { context.actions.navigateChat(id) }
body.addArrangedSubview(link)
} else if let raw = row.url, let url = URL(string: raw) {
let link = NativeButton(title: row.title)
link.font = .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium)
link.contentTintColor = .linkColor
link.handler = { NSWorkspace.shared.open(url) }
body.addArrangedSubview(link)
} else {
body.addArrangedSubview(
NativeLabel(
row.title,
font: .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium)
))
}
if let subtitle = row.subtitle, !subtitle.isEmpty {
body.addArrangedSubview(
NativeLabel(
subtitle,
font: .monospacedSystemFont(
ofSize: NSFont.smallSystemFontSize,
weight: .regular
),
color: .secondaryLabelColor,
selectable: true
))
}
if let detail = row.detail, !detail.isEmpty {
body.addArrangedSubview(
NativeLabel(
detail,
font: .systemFont(ofSize: NSFont.smallSystemFontSize - 1),
color: .secondaryLabelColor,
selectable: true
))
}
if !row.badges.isEmpty {
let badges = NativeFlowView(spacing: Metrics.Space.xs)
for badge in row.badges { badges.addItem(NativeBadge(text: badge)) }
body.addArrangedSubview(badges)
}
outer.addArrangedSubview(body)
return outer
}
private static func toolColor(_ state: AgentToolCard.State) -> NSColor {
batchColor(AgentToolPresentation.tint(for: state))
}
private static func batchColor(_ tint: AgentToolPresentation.BatchTint) -> NSColor {
switch tint {
case .secondary: .secondaryLabelColor
case .warning: .systemOrange
case .success: .systemGreen
case .failure: .systemRed
}
}
private static func detailColor(_ tint: AgentToolDetail.Tint) -> NSColor {
switch tint {
case .secondary: .secondaryLabelColor
case .warning: .systemOrange
case .success: .systemGreen
case .failure: .systemRed
}
}
}
@MainActor
private func nativeHierarchicalColor(_ color: NSColor, opacity: CGFloat) -> NSColor {
NSColor(name: nil) { appearance in
var resolved = color
appearance.performAsCurrentDrawingAppearance {
resolved = color.usingColorSpace(.deviceRGB) ?? color
}
return resolved.withAlphaComponent(resolved.alphaComponent * opacity)
}
}
@MainActor
private func presentDestructiveCommandConfirmation(
_ command: String,
reasons: [String],
from view: NSView?,
action: @escaping () -> Void
) {
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = String(localized: AppStrings.Assistant.runADestructiveCommandConfirmation)
alert.informativeText = reasons.joined(separator: "\n")
alert.addButton(withTitle: String(localized: AppStrings.Assistant.run(command)))
alert.addButton(withTitle: String(localized: AppStrings.Common.cancel))
if let window = view?.window {
alert.beginSheetModal(for: window) { response in
if response == .alertFirstButtonReturn { action() }
}
} else if alert.runModal() == .alertFirstButtonReturn {
action()
}
}
@MainActor
private final class NativeMarkdownView: NSView {
struct Actions {
var enabled = false
var deliver: (String, Bool) -> Void = { _, _ in }
}
private let stack = NativeStack.vertical(spacing: Metrics.Chat.blockSpacing)
private var segmentViews: [NSView] = []
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(stack)
stack.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: leadingAnchor),
stack.trailingAnchor.constraint(equalTo: trailingAnchor),
stack.topAnchor.constraint(equalTo: topAnchor),
stack.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
func configure(
package: TranscriptMarkdownCache.Package,
measurement: Bool,
proposedWidth: CGFloat,
fillsWidth: Bool,
actions: Actions,
preservesRenderedViews: Bool = false
) {
if preservesRenderedViews,
segmentViews.count == package.rendered.segments.count,
zip(package.rendered.segments, segmentViews).allSatisfy({ segment, view in
switch (segment.content, view) {
case (.markdown, is NativeMarkdownSegment): true
default: false
}
})
{
for (segment, view) in zip(package.rendered.segments, segmentViews) {
guard case .markdown(let content) = segment.content,
let view = view as? NativeMarkdownSegment
else { continue }
view.configure(
content: content,
theme: package.rendered.theme,
proposedWidth: proposedWidth,
fillsWidth: fillsWidth,
immediate: measurement
)
}
invalidateIntrinsicContentSize()
return
}
stack.removeAllArrangedSubviews()
segmentViews.removeAll()
for segment in package.rendered.segments {
switch segment.content {
case .markdown(let content):
let view = NativeMarkdownSegment()
view.configure(
content: content,
theme: package.rendered.theme,
proposedWidth: proposedWidth,
fillsWidth: fillsWidth,
immediate: true
)
stack.addArrangedSubview(view)
segmentViews.append(view)
case .shell(let language, let command):
let view = shell(language: language, command: command, actions: actions)
stack.addArrangedSubview(view)
segmentViews.append(view)
}
}
invalidateIntrinsicContentSize()
}
func setScanning(_ active: Bool, reduceMotion: Bool, measurement: Bool) {
guard active, !reduceMotion, !measurement else {
layer?.mask = nil
return
}
installNativeScanningMask(on: self)
}
override func layout() {
super.layout()
layoutNativeScanningMask(on: self)
}
override var intrinsicContentSize: NSSize {
let size = stack.fittingSize
return NSSize(width: max(1, size.width), height: max(1, size.height))
}
private func shell(language: String, command: String, actions: Actions) -> NSView {
let assessment = CommandRiskClassifier.assess(command)
let card = NativeCard(radius: Metrics.Radius.large)
card.fillProvider = { .controlBackgroundColor }
card.strokeProvider = {
assessment.risk == .destructive
? NSColor.systemRed.withAlphaComponent(0.4)
: .separatorColor
}
card.layer?.masksToBounds = true
let stack = NativeStack.vertical()
stack.edgeInsets = .init(
top: Metrics.Chat.codeInset,
left: Metrics.Chat.codeInset,
bottom: Metrics.Chat.codeInset,
right: Metrics.Chat.codeInset
)
card.setContent(stack)
let header = NativeStack.horizontal(spacing: Metrics.Space.xs)
header.addArrangedSubview(
NativeLabel(
language,
font: .systemFont(ofSize: NSFont.smallSystemFontSize),
color: .secondaryLabelColor
))
header.addArrangedSubview(NativeFlexibleSpacer())
header.addArrangedSubview(NativeRiskBadge(assessment: assessment))
stack.addArrangedSubview(header)
stack.addArrangedSubview(
NativeLabel(
command,
font: .monospacedSystemFont(
ofSize: NSFont.systemFontSize,
weight: .regular
),
selectable: true
))
let row = NativeStack.horizontal()
if !command.contains("\n") {
let insert = NativeButton(title: String(localized: AppStrings.Common.insert))
insert.isEnabled = actions.enabled
insert.handler = { actions.deliver(command, false) }
let run = NativeButton(
title: String(localized: AppStrings.Common.run),
bordered: true,
prominent: true
)
run.identifier = NSUserInterfaceItemIdentifier("Transcript.MarkdownShellRun")
run.isEnabled = actions.enabled
run.bezelColor = assessment.risk == .destructive ? .systemRed : .controlAccentColor
run.handler = { [weak run] in
let deliver = { actions.deliver(command, true) }
if assessment.requiresSecondConfirmation {
presentDestructiveCommandConfirmation(
command,
reasons: assessment.reasons,
from: run,
action: deliver
)
} else {
deliver()
}
}
row.addArrangedSubview(insert)
row.addArrangedSubview(run)
} else {
row.addArrangedSubview(
NativeLabel(
String(localized: AppStrings.Assistant.severalCommandsCopyAndReviewBeforeRunning),
font: .systemFont(ofSize: NSFont.smallSystemFontSize),
color: .secondaryLabelColor
))
}
row.addArrangedSubview(NativeFlexibleSpacer())
row.addArrangedSubview(NativeCopyButton(text: command))
stack.addArrangedSubview(row)
return card
}
}
@MainActor
final class NativeMarkdownSegment: NSView {
private let markdown = MarkdownTextView()
private var content: MarkdownContent?
private var measuredSize = NSSize(width: 1, height: 1)
private var lastProposedWidth: CGFloat?
private var lastFillsWidth: Bool?
private var heightConstraint: NSLayoutConstraint!
private var horizontalScrollViews: [NSScrollView] = []
private var horizontalTrackingAreas: [ObjectIdentifier: NSTrackingArea] = [:]
private var horizontalScrollerVisibility: [ObjectIdentifier: Bool] = [:]
private var horizontalScrollerAnimationGenerations: [ObjectIdentifier: Int] = [:]
private var pendingHorizontalScrollerFadeOuts: [ObjectIdentifier: Int] = [:]
private weak var hoveredHorizontalScrollView: NSScrollView?
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(markdown)
markdown.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
markdown.leadingAnchor.constraint(equalTo: leadingAnchor),
markdown.trailingAnchor.constraint(equalTo: trailingAnchor),
markdown.topAnchor.constraint(equalTo: topAnchor),
markdown.bottomAnchor.constraint(equalTo: bottomAnchor),
])
heightConstraint = heightAnchor.constraint(equalToConstant: 1)
heightConstraint.priority = NSLayoutConstraint.Priority(rawValue: 999)
heightConstraint.isActive = true
markdown.linkHandler = { payload, _, _ in
let url: URL? =
switch payload {
case .url(let url): url
case .string(let value): URL(string: value)
}
if let url,
TermindMarkdownParser.allowedLinkSchemes.contains(
url.scheme?.lowercased() ?? ""
)
{
NSWorkspace.shared.open(url)
}
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
func configure(
content: MarkdownContent,
theme: MarkdownTheme,
proposedWidth: CGFloat,
fillsWidth: Bool,
immediate: Bool
) {
let contentChanged = self.content !== content
let availableWidth = max(1, proposedWidth)
guard contentChanged || lastProposedWidth != availableWidth || lastFillsWidth != fillsWidth else { return }
let hadContent = self.content != nil
self.content = content
lastProposedWidth = availableWidth
lastFillsWidth = fillsWidth
frame.size.width = availableWidth
markdown.frame.size.width = availableWidth
if contentChanged {
if immediate || !hadContent {
markdown.setContentImmediately(content, theme: theme)
} else {
markdown.theme = theme
markdown.setContent(content)
}
}
let bounded = markdown.boundingSize(for: availableWidth)
let measuredWidth =
fillsWidth
? availableWidth
: min(availableWidth, max(1, ceil(bounded.width)))
let measuredHeight = max(1, ceil(markdown.boundingSize(for: measuredWidth).height))
measuredSize = NSSize(width: measuredWidth, height: measuredHeight)
heightConstraint.constant = measuredHeight
markdown.layoutSubtreeIfNeeded()
synchronizeHorizontalScrollViews()
invalidateIntrinsicContentSize()
}
override func layout() {
super.layout()
markdown.layoutSubtreeIfNeeded()
synchronizeHorizontalScrollViews()
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window == nil { setHoveredHorizontalScrollView(nil, animated: false) }
}
override func mouseEntered(with event: NSEvent) {
guard let scrollView = horizontalScrollView(for: event.trackingArea) else {
super.mouseEntered(with: event)
return
}
setHoveredHorizontalScrollView(scrollView)
}
override func mouseExited(with event: NSEvent) {
guard let scrollView = horizontalScrollView(for: event.trackingArea) else {
super.mouseExited(with: event)
return
}
if hoveredHorizontalScrollView === scrollView {
setHoveredHorizontalScrollView(nil)
}
}
override func hitTest(_ point: NSPoint) -> NSView? {
let localPoint = superview.map { convert(point, from: $0) } ?? point
if let scroller = visibleHorizontalScroller(at: localPoint) {
return scroller
}
return super.hitTest(point)
}
override var intrinsicContentSize: NSSize {
measuredSize
}
func updateHorizontalScrollerHover(at point: NSPoint?) {
setHoveredHorizontalScrollView(point.flatMap(horizontalScrollView(at:)))
}
private func setHoveredHorizontalScrollView(
_ hovered: NSScrollView?,
animated: Bool = true
) {
hoveredHorizontalScrollView = hovered
for scrollView in horizontalScrollViews {
let visible = horizontalOverflow(in: scrollView) && scrollView === hovered
updateHorizontalScrollerVisibility(
visible,
in: scrollView,
animated: animated && window != nil
)
}
}
private func updateHorizontalScrollerVisibility(
_ visible: Bool,
in scrollView: NSScrollView,
animated: Bool
) {
guard let scroller = scrollView.horizontalScroller else { return }
let identifier = ObjectIdentifier(scrollView)
guard horizontalScrollerVisibility[identifier] != visible else {
if visible, scroller.isHidden {
scroller.isHidden = false
scroller.alphaValue = 1
} else if !visible, pendingHorizontalScrollerFadeOuts[identifier] == nil {
scroller.alphaValue = 0
scroller.isHidden = true
}
return
}
horizontalScrollerVisibility[identifier] = visible
let generation = horizontalScrollerAnimationGenerations[identifier, default: 0] + 1
horizontalScrollerAnimationGenerations[identifier] = generation
if visible {
pendingHorizontalScrollerFadeOuts.removeValue(forKey: identifier)
scroller.isHidden = false
}
guard animated else {
pendingHorizontalScrollerFadeOuts.removeValue(forKey: identifier)
scroller.alphaValue = visible ? 1 : 0
scroller.isHidden = !visible
return
}
if visible {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.12
context.allowsImplicitAnimation = true
scroller.animator().alphaValue = 1
}
} else {
pendingHorizontalScrollerFadeOuts[identifier] = generation
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.16
context.allowsImplicitAnimation = true
scroller.animator().alphaValue = 0
} completionHandler: { [weak self, weak scroller] in
Task { @MainActor in
guard let self, let scroller,
self.horizontalScrollerVisibility[identifier] == false,
self.pendingHorizontalScrollerFadeOuts[identifier] == generation
else { return }
self.pendingHorizontalScrollerFadeOuts.removeValue(forKey: identifier)
scroller.isHidden = true
}
}
}
}
private func synchronizeHorizontalScrollViews() {
let previous = horizontalScrollViews
let candidates = descendantScrollViews(in: markdown).filter {
!$0.hasVerticalScroller && $0.documentView != nil
}
horizontalScrollViews = candidates
for scrollView in previous where !candidates.contains(where: { $0 === scrollView }) {
removeHorizontalTrackingArea(from: scrollView)
updateHorizontalScrollerVisibility(false, in: scrollView, animated: false)
horizontalScrollerVisibility.removeValue(forKey: ObjectIdentifier(scrollView))
horizontalScrollerAnimationGenerations.removeValue(forKey: ObjectIdentifier(scrollView))
pendingHorizontalScrollerFadeOuts.removeValue(forKey: ObjectIdentifier(scrollView))
}
for scrollView in candidates {
scrollView.scrollerStyle = .overlay
scrollView.hasHorizontalScroller = true
scrollView.autohidesScrollers = false
scrollView.horizontalScroller?.controlSize = .mini
scrollView.tile()
let overflows = horizontalOverflow(in: scrollView)
let scroller = scrollView.horizontalScroller
scroller?.scrollerStyle = .legacy
scroller?.isEnabled = overflows
if overflows {
installHorizontalTrackingAreaIfNeeded(on: scrollView)
if horizontalScrollerVisibility[ObjectIdentifier(scrollView)] == nil {
updateHorizontalScrollerVisibility(false, in: scrollView, animated: false)
}
} else {
removeHorizontalTrackingArea(from: scrollView)
updateHorizontalScrollerVisibility(false, in: scrollView, animated: false)
}
}
if let hoveredHorizontalScrollView,
!candidates.contains(where: { $0 === hoveredHorizontalScrollView })
|| !horizontalOverflow(in: hoveredHorizontalScrollView)
{
self.hoveredHorizontalScrollView = nil
}
setHoveredHorizontalScrollView(hoveredHorizontalScrollView, animated: false)
}
private func installHorizontalTrackingAreaIfNeeded(on scrollView: NSScrollView) {
let identifier = ObjectIdentifier(scrollView)
guard horizontalTrackingAreas[identifier] == nil else { return }
let area = NSTrackingArea(
rect: .zero,
options: [.activeInKeyWindow, .inVisibleRect, .mouseEnteredAndExited],
owner: self,
userInfo: nil
)
scrollView.addTrackingArea(area)
horizontalTrackingAreas[identifier] = area
}
private func removeHorizontalTrackingArea(from scrollView: NSScrollView) {
guard let area = horizontalTrackingAreas.removeValue(forKey: ObjectIdentifier(scrollView)) else {
return
}
scrollView.removeTrackingArea(area)
}
private func horizontalScrollView(for trackingArea: NSTrackingArea?) -> NSScrollView? {
guard let trackingArea else { return nil }
return horizontalScrollViews.first { scrollView in
horizontalTrackingAreas[ObjectIdentifier(scrollView)] === trackingArea
}
}
private func horizontalScrollView(at point: NSPoint) -> NSScrollView? {
horizontalScrollViews.last { scrollView in
horizontalOverflow(in: scrollView)
&& convert(scrollView.bounds, from: scrollView).contains(point)
}
}
private func visibleHorizontalScroller(at point: NSPoint) -> NSScroller? {
for scrollView in horizontalScrollViews.reversed() {
guard horizontalOverflow(in: scrollView),
let scroller = scrollView.horizontalScroller,
!scroller.isHidden,
scroller.alphaValue > 0.01
else { continue }
let scrollerPoint = scroller.convert(point, from: self)
if scroller.bounds.contains(scrollerPoint) { return scroller }
}
return nil
}
private func horizontalOverflow(in scrollView: NSScrollView) -> Bool {
guard let documentView = scrollView.documentView else { return false }
return documentView.frame.width > scrollView.contentView.bounds.width + 1
}
private func descendantScrollViews(in root: NSView) -> [NSScrollView] {
var result: [NSScrollView] = []
var pending = root.subviews
while let view = pending.popLast() {
if let scrollView = view as? NSScrollView { result.append(scrollView) }
pending.append(contentsOf: view.subviews)
}
return result
}
}
@MainActor
private final class NativeFlowView: NSView {
enum HorizontalAlignment {
case leading
case trailing
}
private let spacing: CGFloat
private let horizontalAlignment: HorizontalAlignment
private var items: [NSView] = []
private var lastWidth: CGFloat = 0
var isEmpty: Bool { items.isEmpty }
init(
spacing: CGFloat,
horizontalAlignment: HorizontalAlignment = .leading
) {
self.spacing = spacing
self.horizontalAlignment = horizontalAlignment
super.init(frame: .zero)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
func addItem(_ view: NSView) {
items.append(view)
addSubview(view)
invalidateIntrinsicContentSize()
}
override func layout() {
super.layout()
let available = max(1, bounds.width)
var y: CGFloat = 0
for row in rows(for: available) {
let rowWidth =
row.reduce(CGFloat.zero) { $0 + $1.size.width }
+ spacing * CGFloat(max(0, row.count - 1))
var x: CGFloat =
horizontalAlignment == .trailing ? max(0, available - rowWidth) : 0
let rowHeight = row.map(\.size.height).max() ?? 0
for item in row {
item.view.frame = CGRect(
x: x,
y: y,
width: min(item.size.width, available),
height: item.size.height
)
x += item.size.width + spacing
}
y += rowHeight + spacing
}
if abs(lastWidth - available) > 0.5 {
lastWidth = available
invalidateIntrinsicContentSize()
}
}
override var intrinsicContentSize: NSSize {
let available = max(1, bounds.width > 0 ? bounds.width : superview?.bounds.width ?? 1)
let rows = rows(for: available)
let height =
rows.reduce(CGFloat.zero) { total, row in
total + (row.map(\.size.height).max() ?? 0)
}
+ spacing * CGFloat(max(0, rows.count - 1))
return NSSize(width: NSView.noIntrinsicMetric, height: height)
}
private struct Item {
let view: NSView
let size: NSSize
}
private func rows(for available: CGFloat) -> [[Item]] {
var result: [[Item]] = []
var row: [Item] = []
var rowWidth: CGFloat = 0
for view in items {
let item = Item(view: view, size: view.fittingSize)
let nextWidth = rowWidth + (row.isEmpty ? 0 : spacing) + item.size.width
if !row.isEmpty, nextWidth > available {
result.append(row)
row = []
rowWidth = 0
}
rowWidth += (row.isEmpty ? 0 : spacing) + item.size.width
row.append(item)
}
if !row.isEmpty { result.append(row) }
return result
}
}
@MainActor
private final class NativeDisclosureHeader: NSControl {
var handler: (() -> Void)?
private let row = NativeStack.horizontal(spacing: Metrics.Space.s)
private var titleLabel: NativeLabel!
private var timer: Timer?
private var titleProvider: (() -> String)?
private var fallbackTitle: String?
init(
symbol: String?,
title: String,
iconColor: NSColor = .secondaryLabelColor,
titleColor: NSColor = .labelColor,
expanded: Bool? = nil,
active: Bool = false,
reviewing: Bool = false,
reduceMotion: Bool = false,
measurement: Bool = false,
lineLimit: Int = 1,
titleProvider: (() -> String)? = nil,
trailingText: String? = nil,
transientMarkdownTitle: String? = nil,
transientUntil: Date? = nil,
titleFont: NSFont = .systemFont(ofSize: NSFont.systemFontSize)
) {
super.init(frame: .zero)
if let symbol {
let icon = NSImageView(
image: NSImage(systemSymbolName: symbol, accessibilityDescription: nil) ?? NSImage()
)
icon.contentTintColor = iconColor
icon.widthAnchor.constraint(equalToConstant: Metrics.Sidebar.rowIcon).isActive = true
row.addArrangedSubview(icon)
}
if active {
titleLabel = NativeActivityLabel(
text: title,
alternatives: [title],
color: titleColor,
reduceMotion: reduceMotion,
measurement: measurement
)
} else {
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
titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
row.addArrangedSubview(titleLabel)
row.addArrangedSubview(NativeFlexibleSpacer())
if let trailingText {
row.addArrangedSubview(
NativeLabel(
trailingText,
font: .systemFont(ofSize: NSFont.smallSystemFontSize),
color: .secondaryLabelColor
))
}
if reviewing {
let shield = NSImageView(
image: NSImage(
systemSymbolName: "checkmark.shield",
accessibilityDescription: String(localized: AppStrings.Assistant.autoReviewingInProgress)
) ?? NSImage()
)
shield.contentTintColor = .systemOrange
row.addArrangedSubview(shield)
}
if let expanded {
let chevron = NSImageView(
image: NSImage(
systemSymbolName: expanded ? "chevron.down" : "chevron.right",
accessibilityDescription: nil
) ?? NSImage()
)
chevron.contentTintColor = .tertiaryLabelColor
chevron.symbolConfiguration = .init(pointSize: 8, weight: .bold)
row.addArrangedSubview(chevron)
}
row.translatesAutoresizingMaskIntoConstraints = false
addSubview(row)
NSLayoutConstraint.activate([
row.leadingAnchor.constraint(equalTo: leadingAnchor),
row.trailingAnchor.constraint(equalTo: trailingAnchor),
row.topAnchor.constraint(equalTo: topAnchor),
row.bottomAnchor.constraint(equalTo: bottomAnchor),
])
let accessibleTitle =
showsTransientTitle
? transientMarkdownTitle.map(TermindMarkdownParser.plainText) ?? title
: title
setAccessibilityLabel(accessibleTitle)
if let titleProvider, !measurement {
self.titleProvider = titleProvider
timer = Timer.scheduledTimer(
timeInterval: 1,
target: self,
selector: #selector(refreshTitle),
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)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func viewWillMove(toWindow newWindow: NSWindow?) {
super.viewWillMove(toWindow: newWindow)
if newWindow == nil { timer?.invalidate() }
}
@objc private func refreshTitle() {
guard let titleProvider else { return }
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?() }
}
}
@MainActor
private final class NativePlanPreview: NSControl {
var handler: (() -> Void)?
private let content: NSView
private let expanded: Bool
private let previewHeight: CGFloat
init(content: NSView, expanded: Bool, previewHeight: CGFloat = Metrics.Chat.planPreviewHeight) {
self.content = content
self.expanded = expanded
self.previewHeight = previewHeight
super.init(frame: .zero)
wantsLayer = true
layer?.masksToBounds = true
content.translatesAutoresizingMaskIntoConstraints = false
addSubview(content)
NSLayoutConstraint.activate([
content.leadingAnchor.constraint(equalTo: leadingAnchor),
content.trailingAnchor.constraint(equalTo: trailingAnchor),
content.topAnchor.constraint(equalTo: topAnchor),
])
if expanded {
content.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
} else {
heightAnchor.constraint(lessThanOrEqualToConstant: previewHeight).isActive = true
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func layout() {
super.layout()
guard !expanded, content.fittingSize.height > previewHeight else {
layer?.mask = nil
return
}
let mask = CAGradientLayer()
mask.colors = [NSColor.black.cgColor, NSColor.black.cgColor, NSColor.clear.cgColor]
mask.locations = [0, 0.72, 1]
// AppKit layer coordinates run upwards even though the transcript view is flipped.
mask.startPoint = CGPoint(x: 0.5, y: 1)
mask.endPoint = CGPoint(x: 0.5, y: 0)
mask.frame = bounds
layer?.mask = mask
}
override func mouseDown(with event: NSEvent) {
if !expanded { handler?() }
}
override var intrinsicContentSize: NSSize {
let size = content.fittingSize
return NSSize(
width: NSView.noIntrinsicMetric,
height: expanded ? size.height : min(size.height, previewHeight)
)
}
}
@MainActor
private final class NativeChipButton: NativeButton {
init(symbol: String, title: String) {
super.init()
identifier = NSUserInterfaceItemIdentifier("Transcript.UserChip")
let content = NativeStack.horizontal(spacing: Metrics.Space.xs)
content.identifier = NSUserInterfaceItemIdentifier("Transcript.UserChipContent")
let icon = NSImageView(
image: NSImage(systemSymbolName: symbol, accessibilityDescription: title) ?? NSImage()
)
icon.contentTintColor = .labelColor
icon.symbolConfiguration = .init(
pointSize: NSFont.smallSystemFontSize,
weight: .regular
)
NSLayoutConstraint.activate([
icon.widthAnchor.constraint(equalToConstant: 12),
icon.heightAnchor.constraint(equalToConstant: 12),
])
content.addArrangedSubview(icon)
let label = NativeLabel(
title,
font: .systemFont(ofSize: NSFont.smallSystemFontSize)
)
label.maximumNumberOfLines = 1
label.lineBreakMode = .byTruncatingTail
content.addArrangedSubview(label)
addSubview(content)
content.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
content.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Metrics.Space.s),
content.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Metrics.Space.s),
content.topAnchor.constraint(equalTo: topAnchor, constant: 3),
content.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -3),
])
wantsLayer = true
layer?.cornerRadius = 10
contentTintColor = .labelColor
translatesAutoresizingMaskIntoConstraints = true
setAccessibilityLabel(title)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func updateLayer() {
super.updateLayer()
layer?.backgroundColor =
nativeHierarchicalColor(
.quaternaryLabelColor,
opacity: 0.5
).cgColor
}
override func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
needsDisplay = true
}
override var intrinsicContentSize: NSSize {
let size = subviews.first?.fittingSize ?? NSSize(width: 0, height: 0)
return NSSize(
width: size.width + Metrics.Space.s * 2,
height: max(20, size.height + 6)
)
}
}
@MainActor
private final class NativeRiskBadge: NSView {
private let assessment: RiskAssessment
private let label: NativeLabel
init(assessment: RiskAssessment) {
self.assessment = assessment
label = NativeLabel(
assessment.risk.label,
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
let row = NativeStack.horizontal(spacing: Metrics.Space.xs)
let image = NSImageView(
image: NSImage(
systemSymbolName: assessment.risk.symbol,
accessibilityDescription: assessment.risk.label
) ?? 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)
row.translatesAutoresizingMaskIntoConstraints = false
addSubview(row)
NSLayoutConstraint.activate([
row.leadingAnchor.constraint(equalTo: leadingAnchor),
row.trailingAnchor.constraint(equalTo: trailingAnchor),
row.topAnchor.constraint(equalTo: topAnchor),
row.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func updateLayer() {
super.updateLayer()
layer?.backgroundColor =
TermindColor.resolvedNative(
Self.color(assessment.risk).withAlphaComponent(0.12),
for: effectiveAppearance
).cgColor
}
override func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
needsDisplay = true
}
private static func color(_ risk: CommandRisk) -> NSColor {
switch risk {
case .safe: .systemGreen
case .caution: .systemOrange
case .destructive: .systemRed
}
}
}
@MainActor
private final class NativeBadge: NSView {
init(text: String) {
super.init(frame: .zero)
wantsLayer = true
layer?.cornerRadius = 8
let label = NativeLabel(
text,
font: .systemFont(ofSize: NSFont.smallSystemFontSize - 1),
color: .secondaryLabelColor
)
label.maximumNumberOfLines = 1
label.lineBreakMode = .byTruncatingTail
label.setContentCompressionResistancePriority(.required, for: .horizontal)
label.translatesAutoresizingMaskIntoConstraints = false
addSubview(label)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Metrics.Space.xs),
label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Metrics.Space.xs),
label.topAnchor.constraint(equalTo: topAnchor, constant: 2),
label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -2),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func updateLayer() {
super.updateLayer()
layer?.backgroundColor =
TermindColor.resolvedNative(
NSColor.quaternaryLabelColor.withAlphaComponent(0.5),
for: effectiveAppearance
).cgColor
}
override func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
needsDisplay = true
}
}
@MainActor
private final class NativeImageTile: NativeButton {
private let tileSide: CGFloat
private let preview = NSView()
init(image: NSImage, side: CGFloat, label: String) {
tileSide = side
super.init()
identifier = NSUserInterfaceItemIdentifier("Transcript.UserImage")
toolTip = String(localized: AppStrings.Assistant.open(label))
setAccessibilityLabel(label)
preview.identifier = NSUserInterfaceItemIdentifier("Transcript.UserImagePreview")
preview.wantsLayer = true
preview.layer?.contents = image
preview.layer?.contentsGravity = .resizeAspectFill
preview.layer?.masksToBounds = true
preview.layer?.cornerRadius = Metrics.Radius.medium
preview.translatesAutoresizingMaskIntoConstraints = false
addSubview(preview)
NSLayoutConstraint.activate([
preview.leadingAnchor.constraint(equalTo: leadingAnchor),
preview.trailingAnchor.constraint(equalTo: trailingAnchor),
preview.topAnchor.constraint(equalTo: topAnchor),
preview.bottomAnchor.constraint(equalTo: bottomAnchor),
widthAnchor.constraint(equalToConstant: side),
heightAnchor.constraint(equalToConstant: side),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override var intrinsicContentSize: NSSize {
NSSize(width: tileSide, height: tileSide)
}
}
@MainActor
private final class NativeFlexibleSpacer: NSView {
override init(frame: NSRect) {
super.init(frame: frame)
setContentHuggingPriority(.init(1), for: .horizontal)
setContentCompressionResistancePriority(.init(1), for: .horizontal)
}
convenience init() { self.init(frame: .zero) }
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override var intrinsicContentSize: NSSize {
NSSize(width: 0, height: NSView.noIntrinsicMetric)
}
}
@MainActor
private final class NativeRule: NSBox {
override init(frame: NSRect) {
super.init(frame: frame)
boxType = .separator
setContentHuggingPriority(.init(1), for: .horizontal)
setContentCompressionResistancePriority(.init(1), for: .horizontal)
}
convenience init() { self.init(frame: .zero) }
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
}
@MainActor
private final class NativeVerticalRule: NSView {
override init(frame: NSRect) {
super.init(frame: frame)
wantsLayer = true
layer?.cornerRadius = Metrics.Chat.rule / 2
widthAnchor.constraint(equalToConstant: Metrics.Chat.rule).isActive = true
}
convenience init() { self.init(frame: .zero) }
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func updateLayer() {
super.updateLayer()
layer?.backgroundColor =
TermindColor.resolvedNative(
.quaternaryLabelColor,
for: effectiveAppearance
).cgColor
}
override func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
needsDisplay = true
}
}
@MainActor
private final class NativeEditorTextView: NSTextView {
var commit: (() -> Void)?
var cancel: (() -> Void)?
override func keyDown(with event: NSEvent) {
if event.keyCode == 53 {
cancel?()
return
}
if event.keyCode == 36 || event.keyCode == 76 {
let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
let disallowed: NSEvent.ModifierFlags = [.control, .option]
if !hasMarkedText(), !modifiers.contains(.shift), modifiers.intersection(disallowed).isEmpty {
commit?()
return
}
}
super.keyDown(with: event)
}
}
@MainActor
private final class NativeMessageEditor: NSView, NSTextViewDelegate {
var onChange: (String) -> Void = { _ in }
var onCommit: (String) -> Void = { _ in }
var onCancel: () -> Void = {}
private let stack = NativeStack.vertical(spacing: Metrics.Space.s)
private let scroll = NSScrollView()
private let textView = NativeEditorTextView()
private var heightConstraint: NSLayoutConstraint!
private let editorWidth: CGFloat
private let isMeasurement: Bool
init(text: String, width: CGFloat, measurement: Bool) {
editorWidth = width
isMeasurement = measurement
super.init(frame: .zero)
identifier = NSUserInterfaceItemIdentifier("Transcript.UserEditor")
scroll.identifier = NSUserInterfaceItemIdentifier("Transcript.UserEditorScroll")
textView.identifier = NSUserInterfaceItemIdentifier("Transcript.UserEditorText")
stack.translatesAutoresizingMaskIntoConstraints = false
addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: leadingAnchor),
stack.trailingAnchor.constraint(equalTo: trailingAnchor),
stack.topAnchor.constraint(equalTo: topAnchor),
stack.bottomAnchor.constraint(equalTo: bottomAnchor),
widthAnchor.constraint(equalToConstant: width),
])
textView.isRichText = false
textView.font = .systemFont(ofSize: NSFont.systemFontSize)
textView.string = text
textView.delegate = self
textView.drawsBackground = false
textView.isHorizontallyResizable = false
textView.isVerticallyResizable = true
textView.autoresizingMask = [.width]
textView.minSize = .zero
textView.maxSize = NSSize(
width: CGFloat.greatestFiniteMagnitude,
height: CGFloat.greatestFiniteMagnitude
)
textView.textContainer?.widthTracksTextView = true
textView.textContainer?.containerSize = NSSize(
width: max(1, width - 8),
height: CGFloat.greatestFiniteMagnitude
)
textView.textContainerInset = NSSize(width: 4, height: 6)
textView.commit = { [weak self] in
guard let self else { return }
onCommit(textView.string)
}
textView.cancel = { [weak self] in self?.onCancel() }
scroll.documentView = textView
scroll.hasVerticalScroller = true
scroll.autohidesScrollers = true
scroll.drawsBackground = false
scroll.borderType = .noBorder
heightConstraint = scroll.heightAnchor.constraint(
equalToConstant: Self.height(for: text, width: width)
)
heightConstraint.isActive = true
stack.addArrangedSubview(scroll)
let actions = NativeStack.horizontal(spacing: Metrics.Space.s)
actions.addArrangedSubview(NativeFlexibleSpacer())
let cancel = NativeButton(title: String(localized: AppStrings.Common.cancel))
cancel.contentTintColor = .secondaryLabelColor
cancel.handler = { [weak self] in self?.onCancel() }
let send = NativeButton(title: String(localized: AppStrings.Assistant.send), bordered: true, prominent: true)
send.identifier = NSUserInterfaceItemIdentifier("Transcript.UserEditorSend")
send.handler = { [weak self] in
guard let self else { return }
onCommit(textView.string)
}
actions.addArrangedSubview(cancel)
actions.addArrangedSubview(send)
stack.addArrangedSubview(actions)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
guard !isMeasurement, let window else { return }
DispatchQueue.main.async { [weak self, weak window] in
guard let self, let window else { return }
window.makeFirstResponder(textView)
}
}
func textDidChange(_ notification: Notification) {
heightConstraint.constant = Self.height(for: textView.string, width: editorWidth)
textView.invalidateIntrinsicContentSize()
onChange(textView.string)
}
private static func height(for text: String, width: CGFloat) -> CGFloat {
let rect = (text as NSString).boundingRect(
with: NSSize(width: max(1, width - 12), height: .greatestFiniteMagnitude),
options: [.usesLineFragmentOrigin, .usesFontLeading],
attributes: [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize)]
)
return min(160, max(44, ceil(rect.height) + 16))
}
}
@MainActor
private final class NativeActivityLabel: NativeLabel {
private let alternatives: [String]
private var timer: Timer?
private var index = 0
init(
text: String,
alternatives: [String],
color: NSColor = .secondaryLabelColor,
reduceMotion: Bool,
measurement: Bool
) {
self.alternatives = alternatives
super.init(text, color: color)
guard !measurement, !reduceMotion else { return }
if alternatives.count > 1 {
timer = Timer.scheduledTimer(
timeInterval: AssistantTranscriptStyle.activityTextDuration,
target: self,
selector: #selector(advanceText),
userInfo: nil,
repeats: true
)
}
installNativeScanningMask(on: self)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func viewWillMove(toWindow newWindow: NSWindow?) {
super.viewWillMove(toWindow: newWindow)
if newWindow == nil { timer?.invalidate() }
}
@objc private func advanceText() {
index = (index + 1) % alternatives.count
stringValue = alternatives[index]
}
override func layout() {
super.layout()
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 = AssistantTranscriptStyle.scanBandWidth(for: bounds.width)
let origin = -bandWidth + (bounds.width + bandWidth) * progress
let alphas = AssistantTranscriptStyle.scanAlphas.map { CGFloat($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 = AssistantTranscriptStyle.scanDuration
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(
_ value: String,
font: NSFont = .systemFont(ofSize: NSFont.systemFontSize),
color: NSColor = .labelColor,
selectable: Bool = false
) {
super.init(frame: .zero)
stringValue = value
self.font = font
textColor = color
isEditable = false
isSelectable = selectable
isBordered = false
drawsBackground = false
lineBreakMode = .byWordWrapping
maximumNumberOfLines = 0
setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func layout() {
preferredMaxLayoutWidth = max(1, bounds.width)
super.layout()
}
}
@MainActor
private final class NativeStack: NSStackView {
static func vertical(spacing: CGFloat = Metrics.Space.s) -> NativeStack {
let view = NativeStack()
view.orientation = .vertical
view.alignment = .leading
view.spacing = spacing
return view
}
static func horizontal(spacing: CGFloat = Metrics.Space.s) -> NativeStack {
let view = NativeStack()
view.orientation = .horizontal
view.alignment = .centerY
view.spacing = spacing
return view
}
override func addArrangedSubview(_ view: NSView) {
super.addArrangedSubview(view)
guard orientation == .vertical else { return }
view.translatesAutoresizingMaskIntoConstraints = false
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,
constant: -(edgeInsets.left + edgeInsets.right)
).isActive = true
}
func removeAllArrangedSubviews() {
for view in arrangedSubviews {
removeArrangedSubview(view)
view.removeFromSuperview()
}
}
}
@MainActor
private final class NativeSpacer: NSView {
init(height: CGFloat) {
super.init(frame: .zero)
heightAnchor.constraint(equalToConstant: height).isActive = true
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
}
@MainActor
private final class NativeCard: NSView {
var fillProvider: () -> NSColor {
didSet {
needsDisplay = true
updateLayer()
}
}
var strokeProvider: (() -> NSColor)? {
didSet {
needsDisplay = true
updateLayer()
}
}
var onLayout: (() -> Void)?
init(radius: CGFloat) {
fillProvider = { NSColor.quaternaryLabelColor.withAlphaComponent(0.2) }
strokeProvider = nil
super.init(frame: .zero)
wantsLayer = true
layer?.cornerRadius = radius
layer?.cornerCurve = .continuous
layer?.borderWidth = 0
updateLayer()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func updateLayer() {
super.updateLayer()
layer?.backgroundColor =
TermindColor.resolvedNative(
fillProvider(),
for: effectiveAppearance
).cgColor
if let strokeProvider {
layer?.borderWidth = 1
layer?.borderColor =
TermindColor.resolvedNative(
strokeProvider(),
for: effectiveAppearance
).cgColor
} else {
layer?.borderWidth = 0
layer?.borderColor = nil
}
}
override func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
needsDisplay = true
}
override func layout() {
super.layout()
onLayout?()
}
func setContent(
_ view: NSView,
insets: NSEdgeInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
) {
addSubview(view)
view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
view.leadingAnchor.constraint(equalTo: leadingAnchor, constant: insets.left),
view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -insets.right),
view.topAnchor.constraint(equalTo: topAnchor, constant: insets.top),
view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -insets.bottom),
])
}
}
@MainActor
private class NativeButton: NSButton {
var handler: (() -> Void)?
private let usesProminentStyle: Bool
init(title: String = "", bordered: Bool = false, prominent: Bool = false) {
usesProminentStyle = prominent
super.init(frame: .zero)
self.title = title
bezelStyle = bordered ? .rounded : .inline
isBordered = bordered
controlSize = .small
if prominent {
bezelColor = .controlAccentColor
updateProminentForeground()
}
target = self
action = #selector(run)
setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
}
convenience init(symbol: String, help: String) {
self.init()
image = NSImage(systemSymbolName: symbol, accessibilityDescription: help)
imagePosition = .imageOnly
toolTip = help
widthAnchor.constraint(equalToConstant: 28).isActive = true
heightAnchor.constraint(equalToConstant: 28).isActive = true
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
if usesProminentStyle { updateProminentForeground() }
}
private func updateProminentForeground() {
let foreground = TermindColor.resolvedTranscriptProminentForeground(
for: effectiveAppearance
)
let attributes: [NSAttributedString.Key: Any] = [
.font: font ?? NSFont.systemFont(ofSize: NSFont.systemFontSize),
.foregroundColor: foreground,
]
contentTintColor = foreground
attributedTitle = NSAttributedString(string: title, attributes: attributes)
attributedAlternateTitle = NSAttributedString(string: title, attributes: attributes)
}
@objc private func run() { handler?() }
}
@MainActor
private final class NativeCopyButton: NativeButton {
private let copiedImage = NSImage(
systemSymbolName: "checkmark",
accessibilityDescription: String(localized: AppStrings.Common.copied)
)
private let copyImage = NSImage(
systemSymbolName: "doc.on.doc",
accessibilityDescription: String(localized: AppStrings.Common.copy)
)
private var resetWorkItem: DispatchWorkItem?
init(text: String) {
super.init()
let help = String(localized: AppStrings.Common.copy)
image = copyImage
imagePosition = .imageOnly
toolTip = help
widthAnchor.constraint(equalToConstant: 28).isActive = true
heightAnchor.constraint(equalToConstant: 28).isActive = true
handler = { [weak self] in
guard copyToPasteboard(text), let self else { return }
image = copiedImage
toolTip = String(localized: AppStrings.Common.copied)
resetWorkItem?.cancel()
let work = DispatchWorkItem { [weak self] in
self?.image = self?.copyImage
self?.toolTip = String(localized: AppStrings.Common.copy)
}
resetWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4, execute: work)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
}