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