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

3245 lines
123 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: TermindMarkdownView.Tone,
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: TermindMarkdownView.Tone
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: TermindMarkdownView.Tone = .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 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()
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 {
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
root.removeAllArrangedSubviews()
if topInset > 0 { root.addArrangedSubview(NativeSpacer(height: topInset)) }
let centered = NSView()
centered.translatesAutoresizingMaskIntoConstraints = false
centered.addSubview(content)
content.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
content.topAnchor.constraint(equalTo: centered.topAnchor),
content.bottomAnchor.constraint(equalTo: centered.bottomAnchor),
content.centerXAnchor.constraint(equalTo: centered.centerXAnchor),
content.widthAnchor.constraint(equalToConstant: contentWidth),
])
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: "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: "Working…")
let activity = NativeActivityLabel(
text: title,
alternatives: retry == nil
? [
String(localized: "Working…"),
String(localized: "Thinking…"),
String(localized: "Reasoning…"),
] : [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: "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: "This run was interrupted and was not resumed."),
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: "Start a Conversation"),
font: .boldSystemFont(ofSize: NSFont.systemFontSize)
))
stack.addArrangedSubview(
NativeLabel(
String(localized: "Ask a question or describe what you want to do."),
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: "Couldn’t Open Attachment"),
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: "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: "Couldn’t Open Attachment"),
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())
actions.addArrangedSubview(
NativeLabel(
ChatMessageTimestampFormatter.string(
for: message.createdAt,
locale: context.configuration.locale
),
font: .monospacedDigitSystemFont(
ofSize: NSFont.smallSystemFontSize,
weight: .regular
),
color: .tertiaryLabelColor
))
if canEdit {
let edit = NativeButton(symbol: "pencil", help: String(localized: "Edit"))
edit.handler = {
context.presentation.beginEditingMessage(message.id, text: message.text)
context.invalidate(context.entryID, false)
}
actions.addArrangedSubview(edit)
}
actions.addArrangedSubview(NativeCopyButton(text: message.text))
context.registerHoverView(actions)
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: "Hide details") : String(localized: "Show details")
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: "Auto-reviewing: \(review.risk.displayName) — \(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))
}
}
}
if card.result?.isTruncated == true {
detail.addArrangedSubview(
NativeLabel(
String(localized: "Output truncated"),
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: "Hide work") : String(localized: "Show work")
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: "Working…")
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: "Hide tools") : String(localized: "Show tools")
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: "Implemented")
case .discarded:
symbol = "xmark.circle"
tint = .secondaryLabelColor
outcome = String(localized: "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: "Hide the plan") : String(localized: "Show the plan")
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: "Discard"))
discard.contentTintColor = .systemRed
discard.handler = { context.actions.resolvePlan(card.id, .discarded) }
let implement = NativeButton(
title: String(localized: "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: "Reply to keep planning"),
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:
"Compacted — \(ContextBreakdownView.compact(mark.summarizedTokens)) summarized to \(ContextBreakdownView.compact(mark.keptTokens))"
),
color: .secondaryLabelColor
))
row.addArrangedSubview(NativeRule())
row.toolTip = String(
localized: "Earlier turns are summarized for the model. Nothing was deleted."
)
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: "Compacting context")
: String(localized: "Couldn’t compact context")
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: "Compacting context")
: String(localized: "Show failure details")
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: "Agent error"),
iconColor: .systemRed,
titleColor: .labelColor,
expanded: expanded,
reduceMotion: context.configuration.reduceMotion,
measurement: context.measurement,
lineLimit: 1
)
header.toolTip = String(localized: "Show failure details")
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: TermindMarkdownView.Tone = .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: "Suggested Command"),
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: "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: "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))
}
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.string(
expanded ? "Show less" : "Show more", 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) -> 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 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: "Output shortened for display"),
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)) }
}
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)) }
}
return stack
}
}
private static func toolListRow(_ row: AgentToolDetail.ListRow) -> 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 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 nativeRiskColor(_ risk: CommandRisk) -> NSColor {
switch risk {
case .safe: .systemGreen
case .caution: .systemOrange
case .destructive: .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: "Run a destructive command?")
alert.informativeText = reasons.joined(separator: "\n")
alert.addButton(withTitle: String(localized: "Run \(command)"))
alert.addButton(withTitle: String(localized: "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: "Insert"))
insert.isEnabled = actions.enabled
insert.handler = { actions.deliver(command, false) }
let run = NativeButton(
title: String(localized: "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: "Several commands — copy and review before running."),
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 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
) {
guard self.content !== content else { return }
let hadContent = self.content != nil
self.content = content
let availableWidth = max(1, proposedWidth)
frame.size.width = availableWidth
markdown.frame.size.width = availableWidth
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: "Auto-reviewing in progress")
) ?? 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: "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: "Cancel"))
cancel.contentTintColor = .secondaryLabelColor
cancel.handler = { [weak self] in self?.onCancel() }
let send = NativeButton(title: String(localized: "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: "Copied")
)
private let copyImage = NSImage(
systemSymbolName: "doc.on.doc",
accessibilityDescription: String(localized: "Copy")
)
private var resetWorkItem: DispatchWorkItem?
init(text: String) {
super.init()
let help = String(localized: "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: "Copied")
resetWorkItem?.cancel()
let work = DispatchWorkItem { [weak self] in
self?.image = self?.copyImage
self?.toolTip = String(localized: "Copy")
}
resetWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4, execute: work)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
}