fix(assistant): align markdown and command rendering with upstream

This commit is contained in:
Kaede Akino committed 2026-10-04 11:11:50 +08:00
1 parent d456aa90ba
commit 981106f687
17 files changed
+331 -1188

No files matched your search

@@ -181,15 +181,15 @@ struct AssistantTranscriptPresentationTests {
#expect(preview.displayed.split(separator: "\n", omittingEmptySubsequences: false).count <= 120)
}
@Test func reasoningTitlesKeepEmphasisAndDoNotRenderRemoteImagesOrUnsafeLinks() {
@Test func reasoningTitlesKeepEmphasisLinksAndImageAltText() {
let title = TermindMarkdownParser.inlineTitle(
"## **Inspect**\n\n`nginx` [logs](https://example.com) ![alt](https://example.com/p.png)")
#expect(String(title.characters) == "Inspect nginx logs alt")
#expect(title.runs.contains { $0.inlinePresentationIntent?.contains(.stronglyEmphasized) == true })
#expect(title.runs.contains { $0.inlinePresentationIntent?.contains(.code) == true })
let unsafe = TermindMarkdownParser.inlineTitle("[command](file:///tmp/local)")
#expect(String(unsafe.characters) == "command")
#expect(unsafe.runs.allSatisfy { $0.link == nil })
let localLink = TermindMarkdownParser.inlineTitle("[command](file:///tmp/local)")
#expect(String(localLink.characters) == "command")
#expect(localLink.runs.contains { $0.link == URL(string: "file:///tmp/local") })
}
private func work(id: UUID, state: AgentRunState, afterAnswer: Bool = false) -> AssistantTranscriptEntry {
@@ -8,17 +8,15 @@ import TermindDomain
nonisolated public struct SuggestedCommand: Identifiable, Hashable, Sendable {
public var id: UUID = UUID()
public var command: String
public var explanation: String = ""
public var createdAt: Date = .now
public var risk: RiskAssessment {
CommandRiskClassifier.assess(command)
}
public init(id: UUID = UUID(), command: String, explanation: String = "", createdAt: Date = .now) {
public init(id: UUID = UUID(), command: String, createdAt: Date = .now) {
self.id = id
self.command = command
self.explanation = explanation
self.createdAt = createdAt
}
}
@@ -346,7 +344,6 @@ nonisolated public struct AgentToolCard: Identifiable, Hashable, Sendable {
return SuggestedCommand(
id: id,
command: command,
explanation: arguments["explanation"]?.stringValue ?? "",
createdAt: invocation.createdAt
)
}
@@ -1,4 +1,3 @@
import MarkdownParser
import MarkdownView
import SwiftUI
@@ -13,13 +12,6 @@ public struct AssistantMessageBody: View {
}
public var body: some View {
let parsed = MarkdownParser().parse(MarkdownSoftBreaks.hardened(text))
let content = MarkdownContent(
blocks: TermindMarkdownParser.sanitize(parsed.document),
rendered: parsed.renderedContent(theme: theme),
highlightMaps: parsed.highlightMaps(theme: theme),
locale: .autoupdatingCurrent
)
MarkdownView(content, theme: theme).frame(maxWidth: .infinity, alignment: .leading)
MarkdownView(text, theme: theme).frame(maxWidth: .infinity, alignment: .leading)
}
}
@@ -50,7 +50,7 @@ public struct AssistantToolDetails: View {
.font(field.isMonospaced ? .caption.monospaced() : .caption)
.buttonStyle(.plain)
.foregroundStyle(.tint)
} else if let url = allowedURL(field.url) {
} else if let url = linkURL(field.url) {
Link(field.value, destination: url).font(.caption)
} else {
Text(field.value).font(field.isMonospaced ? .caption.monospaced() : .caption)
@@ -100,7 +100,7 @@ public struct AssistantToolDetails: View {
if !row.badges.isEmpty { Text(row.badges.joined(separator: " · ")).font(.caption) }
if let navigation = row.navigation, let navigate {
Button(AppStrings.Common.open.swiftUIKey) { navigate(navigation) }
} else if let url = allowedURL(row.url) {
} else if let url = linkURL(row.url) {
Link(AppStrings.Common.open.swiftUIKey, destination: url)
}
}
@@ -108,8 +108,8 @@ public struct AssistantToolDetails: View {
}
}
private func allowedURL(_ value: String?) -> URL? {
guard let value, TermindMarkdownParser.isAllowedLink(value) else { return nil }
private func linkURL(_ value: String?) -> URL? {
guard let value else { return nil }
return URL(string: value)
}
@@ -1,175 +0,0 @@
import Foundation
/// Turns paragraph-internal newlines into hard breaks before parsing.
///
/// CommonMark says a single newline inside a paragraph is a *soft* break and
/// renders as a space, and Foundation follows the spec — but it renders one as
/// an untagged `" "` run, indistinguishable from a typed space. So the choice
/// cannot be made after parsing; it has to be made on the source.
///
/// We make it the way GitHub's comment renderer does, because that is what
/// every chat UI does and, more to the point, because a model that wrote a
/// newline meant a line break. A reply like
///
/// ```
/// **Note:** this matters
/// Another line here
/// ```
///
/// reading as one run-on line is the bug this exists to prevent.
///
/// The mechanism is a trailing `\`, which CommonMark defines as a hard break.
/// Two trailing spaces mean the same thing but are destroyed by any whitespace
/// handling between here and the parser; a backslash is not.
///
/// Every guard below marks a place where appending a backslash would corrupt
/// something rather than break a line. The failure mode when a guard is missing
/// is benign — a stray break where a join was meant — with one exception that
/// is not: **HTML blocks carry composed attachments verbatim** in the
/// model-facing transcript. Editing a byte inside one would alter the context
/// sent to the provider, so they are skipped wholesale.
nonisolated public enum MarkdownSoftBreaks {
public static func hardened(_ source: String) -> String {
let lines = source.components(separatedBy: "\n")
guard lines.count > 1 else { return source }
var output: [String] = []
output.reserveCapacity(lines.count)
var openFence: Fence?
var inHTMLBlock = false
for (index, line) in lines.enumerated() {
// Fenced code: everything between the markers is content, including
// lines that look like prose.
if let fence = openFence {
if closes(line, fence) { openFence = nil }
output.append(line)
continue
}
// An HTML block runs from its opening `<` to the next blank line.
if inHTMLBlock {
if line.trimmingCharacters(in: .whitespaces).isEmpty { inHTMLBlock = false }
output.append(line)
continue
}
if let fence = fence(opening: line) {
openFence = fence
output.append(line)
continue
}
if opensHTMLBlock(line) {
inHTMLBlock = true
output.append(line)
continue
}
guard index + 1 < lines.count else {
output.append(line)
continue
}
output.append(needsHardBreak(line, before: lines[index + 1]) ? line + "\\" : line)
}
return output.joined(separator: "\n")
}
// MARK: - The decision
private static func needsHardBreak(_ line: String, before next: String) -> Bool {
let current = line.trimmingCharacters(in: .whitespaces)
let following = next.trimmingCharacters(in: .whitespaces)
// A paragraph break already separates these; nothing to add.
guard !current.isEmpty, !following.isEmpty else { return false }
// Already a hard break, one way or the other.
guard !line.hasSuffix("\\"), !line.hasSuffix(" ") else { return false }
// Indented code blocks are content, same as fenced ones.
guard !isIndentedCode(line), !isIndentedCode(next) else { return false }
// A trailing backslash inside a table row corrupts the row grammar, and
// one immediately before the delimiter row stops the table from being a
// table at all.
guard !current.hasPrefix("|"), !following.hasPrefix("|") else { return false }
// The next line opens a block of its own, so a break at the end of this
// one would either be swallowed or leave a dangling blank inside the
// block that just closed.
guard !following.hasPrefix("#") else { return false }
guard fence(opening: next) == nil else { return false }
guard !opensHTMLBlock(next) else { return false }
guard !isThematicBreak(following) else { return false }
guard !isSetextUnderline(following) else { return false }
guard !isListMarker(following) else { return false }
// A quote that starts here would absorb the break; a quote that
// continues one wants it.
if following.hasPrefix(">"), !current.hasPrefix(">") { return false }
return true
}
// MARK: - Line shapes
private struct Fence {
let character: Character
let length: Int
}
private static func fence(opening line: String) -> Fence? {
let body = line.drop { $0 == " " }
guard let marker = body.first, marker == "`" || marker == "~" else { return nil }
let length = body.prefix { $0 == marker }.count
guard length >= 3 else { return nil }
return Fence(character: marker, length: length)
}
private static func closes(_ line: String, _ fence: Fence) -> Bool {
let body = line.drop { $0 == " " }
let run = body.prefix { $0 == fence.character }
guard run.count >= fence.length else { return false }
// A closing fence carries nothing but the marker.
return body.dropFirst(run.count).allSatisfy { $0 == " " }
}
/// CommonMark's HTML block starts at a `<` in the leading position. We do
/// not care which of the seven kinds it is — only that we must not touch it.
private static func opensHTMLBlock(_ line: String) -> Bool {
line.drop { $0 == " " }.first == "<"
}
private static func isIndentedCode(_ line: String) -> Bool {
line.hasPrefix(" ") || line.hasPrefix("\t")
}
private static func isThematicBreak(_ trimmed: String) -> Bool {
let body = trimmed.filter { $0 != " " }
guard body.count >= 3 else { return false }
guard let marker = body.first, marker == "-" || marker == "*" || marker == "_" else {
return false
}
return body.allSatisfy { $0 == marker }
}
/// `===` under a line makes it a heading, and `---` does too — the latter
/// already fails `isThematicBreak` only when it is shorter than three, so
/// both runs are checked here regardless of length.
private static func isSetextUnderline(_ trimmed: String) -> Bool {
guard !trimmed.isEmpty else { return false }
return trimmed.allSatisfy { $0 == "=" } || trimmed.allSatisfy { $0 == "-" }
}
private static func isListMarker(_ trimmed: String) -> Bool {
if let marker = trimmed.first, marker == "-" || marker == "*" || marker == "+" {
return trimmed.dropFirst().first == " "
}
let digits = trimmed.prefix { $0.isNumber }
guard !digits.isEmpty else { return false }
let rest = trimmed.dropFirst(digits.count)
guard let separator = rest.first, separator == "." || separator == ")" else { return false }
return rest.dropFirst().first == " "
}
}
@@ -0,0 +1,38 @@
import MarkdownView
import SwiftUI
import TermindApplication
/// Uses the upstream code block, including its copy button and expanded sheet.
public struct SuggestedCommandCodeBlock: View {
private let command: String
@Environment(\.locale) private var locale
public init(_ command: String) {
self.command = command
}
public var body: some View {
MarkdownView(Self.content(command, locale: locale), theme: Self.theme)
}
/// The enclosing card paints the body once; the bar keeps the upstream header tint.
public static var theme: MarkdownTheme {
var theme = MarkdownTheme()
theme.colors.codeBlockBackground = .clear
return theme
}
public static func content(_ command: String, locale: Locale) -> MarkdownContent {
MarkdownContent(
blocks: [
.codeBlock(
fenceInfo: AppLocalization.resolve(AppStrings.Assistant.suggestedCommand, locale: locale),
content: command
)
],
rendered: [:],
highlightMaps: [:],
locale: locale
)
}
}
@@ -2,82 +2,8 @@ import Foundation
import MarkdownParser
nonisolated public enum TermindMarkdownParser {
public static let allowedLinkSchemes: Set<String> = ["http", "https", "mailto"]
public static let shellLanguages: Set<String> = [
"bash", "sh", "zsh", "shell", "console", "terminal",
]
public static func parse(_ source: String) -> [MarkdownBlockNode] {
let parsed = MarkdownParser().parse(MarkdownSoftBreaks.hardened(source))
return sanitize(parsed.document)
}
public static func sanitize(_ blocks: [MarkdownBlockNode]) -> [MarkdownBlockNode] {
blocks.rewrite { (inline: MarkdownInlineNode) -> [MarkdownInlineNode] in
switch inline {
case .link(let destination, let children):
guard isAllowedLink(destination) else { return children }
return [.link(destination: destination, children: children)]
case .image(_, let children):
// Never fetch a model-chosen URL while rendering a transcript.
// The image's alt text remains readable ordinary prose.
return children
default:
return [inline]
}
}
}
public static func isAllowedLink(_ destination: String) -> Bool {
guard let scheme = URL(string: destination)?.scheme?.lowercased() else {
return false
}
return allowedLinkSchemes.contains(scheme)
}
public static func isActionableShell(language: String?, code: String) -> Bool {
guard let language else { return false }
return shellLanguages.contains(language.lowercased())
&& !normalizedFenceCode(code).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
public static func segments(
from blocks: [MarkdownBlockNode],
allowsShellActions: Bool
) -> [TermindMarkdownBlockSegment] {
var result: [TermindMarkdownBlockSegment] = []
var markdown: [MarkdownBlockNode] = []
func flushMarkdown() {
guard !markdown.isEmpty else { return }
result.append(.markdown(markdown))
markdown.removeAll(keepingCapacity: true)
}
for block in blocks {
if allowsShellActions,
case .codeBlock(let language, let code) = block,
isActionableShell(language: language, code: code)
{
flushMarkdown()
result.append(
.shell(
language: language ?? "",
command: normalizedFenceCode(code)
))
} else {
markdown.append(block)
}
}
flushMarkdown()
return result
}
/// cmark includes the structural newline immediately before a closing
/// fence. It is not part of the command and must not make a one-line shell
/// suggestion look like a multi-line script to the execution guard.
private static func normalizedFenceCode(_ code: String) -> String {
code.hasSuffix("\n") ? String(code.dropLast()) : code
MarkdownParser().parse(source).document
}
public static func plainText(_ source: String) -> String {
@@ -88,7 +14,7 @@ nonisolated public enum TermindMarkdownParser {
.joined(separator: " ")
}
/// A portable single-line title that keeps inline emphasis and safe links.
/// A portable single-line title that keeps inline emphasis and links.
public static func inlineTitle(_ source: String) -> AttributedString {
func inlines(_ nodes: [MarkdownInlineNode]) -> AttributedString {
nodes.reduce(into: AttributedString()) { result, node in
@@ -121,7 +47,7 @@ nonisolated public enum TermindMarkdownParser {
}
case .link(let destination, let children):
value = inlines(children)
if isAllowedLink(destination) { value.link = URL(string: destination) }
value.link = URL(string: destination)
case .image(_, let children):
value = inlines(children)
}
@@ -182,8 +108,3 @@ nonisolated public enum TermindMarkdownParser {
}.joined()
}
}
nonisolated public enum TermindMarkdownBlockSegment: Equatable, Sendable {
case markdown([MarkdownBlockNode])
case shell(language: String, command: String)
}
@@ -137,8 +137,7 @@ nonisolated public struct TerminalSuggestTool: AgentToolDefinition, AgentToolSch
command yourself, use ssh_exec.
""",
properties: [
"command": Self.string(max: 32_768),
"explanation": Self.string(max: 4_096),
"command": Self.string(max: 32_768)
],
required: ["command"], target: .user, approvalPolicy: .approve,
concurrency: .parallel,
@@ -160,7 +159,6 @@ nonisolated public struct TerminalSuggestTool: AgentToolDefinition, AgentToolSch
invocation,
.object([
"command": .string(command),
"explanation": .string(arguments.optionalString("explanation") ?? ""),
"risk": .string(CommandRiskClassifier.assess(command).risk.label),
"requires_user_action": .bool(true),
]))
@@ -181,11 +179,6 @@ nonisolated extension TerminalSuggestTool {
input.arguments["command"]?.stringValue
?? object["command"]?.stringValue ?? ""
var items: [AgentToolDetail.Item] = [F.text("Command", command, locale: input.locale)]
if let explanation = F.nonempty(
input.arguments["explanation"]?.stringValue ?? object["explanation"]?.stringValue
) {
items.append(.message(explanation, .secondary))
}
let assessment = CommandRiskClassifier.assess(command)
items.append(F.field("Risk", assessment.risk.label, locale: input.locale))
items.append(contentsOf: assessment.reasons.map { .message($0, .warning) })
@@ -1,4 +1,5 @@
import AgentKit
import MarkdownView
import SwiftUI
import TermindApplication
import TermindDomain
@@ -95,36 +96,21 @@ struct MobileAssistantCommandRow: View {
let command: SuggestedCommand
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Label(AppStrings.Assistant.suggestedCommand.swiftUIKey, systemImage: "sparkles").foregroundStyle(
.secondary)
Spacer(minLength: 8)
Label(command.risk.risk.label, systemImage: command.risk.risk.symbol).foregroundStyle(riskColor)
}.font(.caption.weight(.medium))
Text(command.command).font(.callout.monospaced()).textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading).padding(10)
if !command.explanation.isEmpty {
Text(command.explanation).font(.caption).foregroundStyle(.secondary)
}
ForEach(command.risk.reasons, id: \.self) { reason in
Text(verbatim: "• \(reason)").font(.caption2).foregroundStyle(riskColor)
}
HStack {
Spacer()
Button(AppStrings.Common.copy.swiftUIKey, systemImage: "doc.on.doc") {
UIPasteboard.general.string = command.command
VStack(alignment: .leading, spacing: 0) {
SuggestedCommandCodeBlock(command.command)
VStack(alignment: .leading, spacing: 8) {
ForEach(command.risk.reasons, id: \.self) { reason in
Text(verbatim: "• \(reason)").font(.caption2).foregroundStyle(riskColor)
}
HStack {
Spacer()
Label(command.risk.risk.label, systemImage: command.risk.risk.symbol)
.font(.caption.weight(.medium)).foregroundStyle(riskColor)
}
.font(.caption).frame(minHeight: 44)
}
.padding(.top, 8).padding([.horizontal, .bottom], 12)
}
.padding(16)
.overlay {
RoundedRectangle(cornerRadius: 12)
.stroke(
command.risk.risk == .destructive ? Color.red.opacity(0.4) : Color(uiColor: .separator),
lineWidth: 1)
}
.background(Color(uiColor: MarkdownTheme().colors.codeBlockBackground), in: RoundedRectangle(cornerRadius: 8))
}
private var riskColor: Color {
@@ -28,15 +28,9 @@ struct MobileAssistantMarkdown: View {
value.colors.code = color
value.colors.highlight = UIColor(Color.accentColor).resolvedColor(with: traits)
value.colors.emphasis = UIColor(Color.accentColor).resolvedColor(with: traits)
value.colors.codeBackground = UIColor.secondaryLabel.resolvedColor(with: traits).withAlphaComponent(0.15)
value.spacings.paragraph = AssistantTranscriptStyle.blockSpacing
value.spacings.headingBefore = 4
value.spacings.list = 4
value.table.cornerRadius = 4
value.table.borderColor = UIColor.separator.resolvedColor(with: traits)
value.table.headerBackgroundColor = UIColor.quaternaryLabel.resolvedColor(with: traits).withAlphaComponent(0.12)
value.table.stripeCellBackgroundColor = UIColor.quaternaryLabel.resolvedColor(with: traits).withAlphaComponent(
0.06)
return value
}
}
-1
View File
@@ -198,7 +198,6 @@ nonisolated enum Metrics {
static let gutter: CGFloat = 56
/// The same idea at 260pt, where 56 would leave a bubble two words wide.
static let compactGutter: CGFloat = 24
static let codeInset: CGFloat = 10
/// The leading rule on a block quote and on expanded tool output.
static let rule: CGFloat = 2
/// The draft editor's floor and ceiling.
+7 -53
View File
@@ -9,12 +9,11 @@ enum TermindMarkdownTone: Hashable {
case secondary
}
// MARK: - Parsing policy
// MARK: - Transcript titles
extension TermindMarkdownParser {
/// A one-line AppKit projection for transient tool titles. It applies the
/// same sanitization as the full transcript renderer, preserves inline
/// emphasis/code/strikethrough/safe links, and flattens block structure
/// A one-line AppKit projection for transient tool titles. It preserves
/// inline emphasis/code/strikethrough/links and flattens block structure
/// without allowing it to change the disclosure header's height.
@MainActor
static func singleLineAttributedText(
@@ -78,7 +77,7 @@ extension TermindMarkdownParser {
inlines(children, attributes: struck)
case .link(let destination, let children):
var link = attributes
if let url = URL(string: destination), isAllowedLink(destination) {
if let url = URL(string: destination) {
link[.link] = url
link[.foregroundColor] = NSColor.linkColor
link[.underlineStyle] = NSUnderlineStyle.single.rawValue
@@ -152,45 +151,11 @@ extension TermindMarkdownParser {
@MainActor
struct TermindMarkdownRenderedDocument {
struct Segment: Identifiable {
enum Content {
case markdown(MarkdownContent)
case shell(language: String, command: String)
}
let id: Int
let content: Content
}
let segments: [Segment]
let content: MarkdownContent
let theme: MarkdownTheme
init(source: String, allowsShellActions: Bool, theme: TermindMarkdownTheme.Value) {
let parserResult = MarkdownParser().parse(MarkdownSoftBreaks.hardened(source))
let blocks = TermindMarkdownParser.sanitize(parserResult.document)
let rendered = parserResult.renderedContent(theme: theme.theme)
let highlights = parserResult.highlightMaps(theme: theme.theme)
segments = TermindMarkdownParser.segments(
from: blocks,
allowsShellActions: allowsShellActions
).enumerated().map { index, segment in
switch segment {
case .markdown(let segmentBlocks):
let content = MarkdownContent(
blocks: segmentBlocks,
rendered: rendered,
highlightMaps: highlights,
locale: .autoupdatingCurrent
)
return Segment(id: index, content: .markdown(content))
case .shell(let language, let command):
return Segment(
id: index,
content: .shell(language: language, command: command)
)
}
}
init(source: String, theme: TermindMarkdownTheme.Value) {
content = MarkdownContent(markdown: source, theme: theme.theme)
self.theme = theme.theme
}
}
@@ -227,9 +192,6 @@ enum TermindMarkdownTheme {
theme.colors.code = bodyColor
theme.colors.highlight = resolved(.controlAccentColor, colorScheme: colorScheme)
theme.colors.emphasis = resolved(.controlAccentColor, colorScheme: colorScheme)
theme.colors.codeBackground = resolved(
.secondaryLabelColor, colorScheme: colorScheme
).withAlphaComponent(0.15)
theme.colors.selectionBackground = resolved(
.controlAccentColor, colorScheme: colorScheme
).withAlphaComponent(0.2)
@@ -237,14 +199,6 @@ enum TermindMarkdownTheme {
theme.spacings.paragraph = Metrics.Chat.blockSpacing
theme.spacings.headingBefore = Metrics.Space.xs
theme.spacings.list = Metrics.Space.xs
theme.table.cornerRadius = Metrics.Radius.small
theme.table.borderColor = resolved(.separatorColor, colorScheme: colorScheme)
theme.table.headerBackgroundColor = resolved(
.quaternaryLabelColor, colorScheme: colorScheme
).withAlphaComponent(0.12)
theme.table.stripeCellBackgroundColor = resolved(
.quaternaryLabelColor, colorScheme: colorScheme
).withAlphaComponent(0.06)
return Value(theme: theme, colorScheme: colorScheme)
}
+42 -518
View File
@@ -16,14 +16,12 @@ final class TranscriptMarkdownCache {
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)
)
}
@@ -33,7 +31,6 @@ final class TranscriptMarkdownCache {
let entryID: UUID
let slot: String
let source: String
let shell: Bool
let tone: TermindMarkdownTone
let colorScheme: ColorScheme
}
@@ -48,7 +45,6 @@ final class TranscriptMarkdownCache {
entryID: UUID,
slot: String,
source: String,
shell: Bool,
tone: TermindMarkdownTone = .primary,
colorScheme: ColorScheme
) -> Package {
@@ -56,7 +52,6 @@ final class TranscriptMarkdownCache {
entryID: entryID,
slot: slot,
source: source,
shell: shell,
tone: tone,
colorScheme: colorScheme
)
@@ -66,7 +61,6 @@ final class TranscriptMarkdownCache {
}
let value = Package(
source: source,
allowsShellActions: shell,
tone: tone,
colorScheme: colorScheme
)
@@ -400,7 +394,6 @@ 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)
@@ -425,8 +418,6 @@ private enum NativeTranscriptFactory {
canFork: Bool,
context: NativeTranscriptContext
) {
let sameMessage = representedMessageID == message.id
representedMessageID = message.id
stack.removeAllArrangedSubviews()
if !message.text.isEmpty {
markdownView.configure(
@@ -434,18 +425,10 @@ private enum NativeTranscriptFactory {
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
fillsWidth: true
)
stack.addArrangedSubview(markdownView)
}
@@ -672,16 +655,13 @@ private enum NativeTranscriptFactory {
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()
fillsWidth: false
)
body.addArrangedSubview(markdown)
}
@@ -927,7 +907,6 @@ private enum NativeTranscriptFactory {
let reasoning = markdown(
source: block.text,
slot: "reasoning.\(block.id)",
shell: false,
tone: .secondary,
context: context
)
@@ -959,7 +938,6 @@ private enum NativeTranscriptFactory {
markdown(
source: message.text,
slot: "work-message.\(message.id)",
shell: true,
context: context
))
}
@@ -1102,7 +1080,6 @@ private enum NativeTranscriptFactory {
let body = markdown(
source: card.plan.body,
slot: "plan",
shell: true,
horizontalInset: Metrics.Space.l * 2
+ Metrics.Sidebar.rowIcon + Metrics.Space.s,
context: context
@@ -1274,7 +1251,6 @@ private enum NativeTranscriptFactory {
private static func markdown(
source: String,
slot: String,
shell: Bool,
tone: TermindMarkdownTone = .primary,
fillsWidth: Bool = true,
horizontalInset: CGFloat = 0,
@@ -1286,18 +1262,11 @@ private enum NativeTranscriptFactory {
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
)
fillsWidth: fillsWidth
)
return view
}
@@ -1330,81 +1299,26 @@ private enum NativeTranscriptFactory {
context: NativeTranscriptContext
) -> NSView {
let assessment = command.risk
let card = NativeCard(radius: Metrics.Radius.large)
let card = NativeCard(radius: 8)
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.fillProvider = { MarkdownTheme().colors.codeBlockBackground }
card.layer?.masksToBounds = true
let stack = NativeStack.vertical(spacing: Metrics.Space.m)
let stack = NativeStack.vertical(spacing: 0)
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"
let code = NativeMarkdownView()
code.identifier = NSUserInterfaceItemIdentifier("Transcript.CommandCardCommand")
code.configure(
content: SuggestedCommandCodeBlock.content(command.command, locale: context.configuration.locale),
theme: SuggestedCommandCodeBlock.theme,
proposedWidth: context.contentWidth,
fillsWidth: true
)
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)
}
stack.addArrangedSubview(code)
let details = NativeStack.vertical(spacing: Metrics.Space.m)
details.identifier = NSUserInterfaceItemIdentifier("Transcript.CommandCardDetails")
details.edgeInsets = .init(
top: Metrics.Space.s, left: Metrics.Space.m, bottom: Metrics.Space.m, right: Metrics.Space.m)
let actions = NativeStack.horizontal(spacing: Metrics.Space.s)
actions.identifier = NSUserInterfaceItemIdentifier("Transcript.CommandCardActions")
if context.configuration.showsTerminalActions {
@@ -1437,8 +1351,9 @@ private enum NativeTranscriptFactory {
actions.addArrangedSubview(run)
}
actions.addArrangedSubview(NativeFlexibleSpacer())
actions.addArrangedSubview(NativeCopyButton(text: command.command))
stack.addArrangedSubview(actions)
actions.addArrangedSubview(NativeRiskBadge(assessment: assessment))
details.addArrangedSubview(actions)
stack.addArrangedSubview(details)
return card
}
@@ -1733,197 +1648,12 @@ private func presentDestructiveCommandConfirmation(
@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)
@@ -1944,11 +1674,7 @@ final class NativeMarkdownSegment: NSView {
case .url(let url): url
case .string(let value): URL(string: value)
}
if let url,
TermindMarkdownParser.allowedLinkSchemes.contains(
url.scheme?.lowercased() ?? ""
)
{
if let url {
NSWorkspace.shared.open(url)
}
}
@@ -1957,29 +1683,35 @@ final class NativeMarkdownSegment: NSView {
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
func configure(
package: TranscriptMarkdownCache.Package,
proposedWidth: CGFloat,
fillsWidth: Bool
) {
configure(
content: package.rendered.content,
theme: package.rendered.theme,
proposedWidth: proposedWidth,
fillsWidth: fillsWidth
)
}
func configure(
content: MarkdownContent,
theme: MarkdownTheme,
proposedWidth: CGFloat,
fillsWidth: Bool,
immediate: Bool
fillsWidth: 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)
}
markdown.setContentImmediately(content, theme: theme)
}
let bounded = markdown.boundingSize(for: availableWidth)
let measuredWidth =
@@ -1990,234 +1722,26 @@ final class NativeMarkdownSegment: NSView {
measuredSize = NSSize(width: measuredWidth, height: measuredHeight)
heightConstraint.constant = measuredHeight
markdown.layoutSubtreeIfNeeded()
synchronizeHorizontalScrollViews()
invalidateIntrinsicContentSize()
}
override func layout() {
super.layout()
markdown.layoutSubtreeIfNeeded()
synchronizeHorizontalScrollViews()
layoutNativeScanningMask(on: self)
}
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)
func setScanning(_ active: Bool, reduceMotion: Bool, measurement: Bool) {
guard active, !reduceMotion, !measurement else {
layer?.mask = nil
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)
installNativeScanningMask(on: self)
}
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
+1 -2
View File
@@ -1637,7 +1637,7 @@ struct ChatComposerStateTests {
}
@Test
func completedTerminalSuggestionIsPromotedBesidePlansInsteadOfRemainingInWorked() throws {
func completedLegacyTerminalSuggestionIsPromotedWithoutItsExplanation() throws {
let conversation = ConversationModel()
let runID = UUID()
let createdAt = Date(timeIntervalSince1970: 2_000)
@@ -1679,7 +1679,6 @@ struct ChatComposerStateTests {
}
#expect(command.id == invocation.id)
#expect(command.command == "pwd")
#expect(command.explanation == "Show the current directory.")
#expect(command.createdAt == createdAt)
}
+73 -95
View File
@@ -22,14 +22,15 @@ struct MarkdownParsingTests {
}
@Test
func newlineInsideAParagraphSurvivesAsALineBreak() {
func newlineInsideAParagraphUsesUpstreamSoftBreaks() {
let blocks = TermindMarkdownParser.parse("**Note:** this matters\nAnother line here")
guard case .paragraph(let inlines)? = blocks.first else {
Issue.record("expected a paragraph, got \(blocks)")
return
}
#expect(inlines.contains(.lineBreak))
#expect(inlineText(inlines).contains("matters\nAnother"))
#expect(inlines.contains(.softBreak))
#expect(!inlines.contains(.lineBreak))
#expect(inlineText(inlines).contains("matters Another"))
}
@Test
@@ -97,34 +98,36 @@ struct MarkdownParsingTests {
#expect(code.trimmingCharacters(in: .newlines) == "ls -la")
}
@Test
func onlyOpenableSchemesRemainLinks() {
let allowed = linkedInline("[docs](https://example.com)")
guard case .link(let destination, _)? = allowed else {
Issue.record("expected an allowed link")
@Test(arguments: ["https://example.com", "file:///tmp/example.txt", "ssh://example.com", "custom:document"])
@MainActor
func renderedLinksKeepTheirOriginalScheme(destination: String) throws {
let document = TermindMarkdownRenderedDocument(
source: "[open](\(destination))",
theme: TermindMarkdownTheme.make(tone: .primary)
)
let inline = try #require(paragraphInlines(document.content.blocks.first).first)
guard case .link(let renderedDestination, let children) = inline else {
Issue.record("expected the original link")
return
}
#expect(destination == "https://example.com")
for source in ["[click](javascript:alert(1))", "[open](file:///etc/passwd)"] {
let inlines = paragraphInlines(TermindMarkdownParser.parse(source).first)
#expect(
!inlines.contains { inline in
guard case .link = inline else { return false }
return true
})
#expect(inlineText(inlines).contains(source.contains("click") ? "click" : "open"))
}
#expect(renderedDestination == destination)
#expect(inlineText(children) == "open")
}
@Test
func imagesKeepAltTextButLoseRemoteSource() {
let blocks = TermindMarkdownParser.parse(
"![alt text](https://tracker.test/p.png)"
@MainActor
func renderedImagesKeepTheirSourceAndAltText() throws {
let document = TermindMarkdownRenderedDocument(
source: "![alt text](https://example.com/p.png)",
theme: TermindMarkdownTheme.make(tone: .primary)
)
let inlines = paragraphInlines(blocks.first)
#expect(inlineText(inlines) == "alt text")
#expect(!String(describing: inlines).contains("tracker.test"))
let inline = try #require(paragraphInlines(document.content.blocks.first).first)
guard case .image(let source, let children) = inline else {
Issue.record("expected an image node")
return
}
#expect(source == "https://example.com/p.png")
#expect(inlineText(children) == "alt text")
}
@Test
@@ -164,46 +167,36 @@ struct MarkdownParsingTests {
}
@Test
func assistantShellFencesBecomeActionSegments() {
let blocks = TermindMarkdownParser.parse(
"before\n\n```bash\necho ok\n```\n\nafter"
)
let segments = TermindMarkdownParser.segments(
from: blocks,
allowsShellActions: true
)
#expect(segments.count == 3)
#expect(segments[1] == .shell(language: "bash", command: "echo ok"))
@MainActor
func renderedDocumentKeepsShellFencesAndTablesInOrder() throws {
let document = TermindMarkdownRenderedDocument(
source: """
Before the blocks.
let userSegments = TermindMarkdownParser.segments(
from: blocks,
allowsShellActions: false
```bash
echo ok
```
| Name | Value |
| --- | --- |
| result | ready |
After the blocks.
""",
theme: TermindMarkdownTheme.make(tone: .primary)
)
#expect(userSegments.count == 1)
guard case .markdown(let userBlocks) = userSegments[0] else {
Issue.record("user code should stay native Markdown")
let blocks = document.content.blocks
try #require(blocks.count == 4)
#expect(paragraphText(blocks.first) == "Before the blocks.")
guard case .codeBlock(let language, let code) = blocks[1],
case .table = blocks[2]
else {
Issue.record("expected shell code and a table in the same Markdown document")
return
}
#expect(userBlocks.count == blocks.count)
#expect(
userBlocks.contains { block in
guard case .codeBlock = block else { return false }
return true
})
}
@Test
func shellFenceExtractedFromAListStillBecomesAnActionSegment() {
let blocks = TermindMarkdownParser.parse("- inspect\n\n ```zsh\n pwd\n ```")
let segments = TermindMarkdownParser.segments(
from: blocks,
allowsShellActions: true
)
#expect(
segments.contains { segment in
guard case .shell(let language, let command) = segment else { return false }
return language == "zsh" && command.trimmingCharacters(in: .whitespaces) == "pwd"
})
#expect(language == "bash")
#expect(code == "echo ok\n")
#expect(paragraphText(blocks.last) == "After the blocks.")
}
@Test
@@ -220,7 +213,7 @@ struct MarkdownParsingTests {
@MainActor
@Test
func transientToolTitleFlattensBlocksAndPreservesSafeInlineMarkdown() throws {
func transientToolTitleFlattensBlocksAndPreservesInlineMarkdown() throws {
let title = TermindMarkdownParser.singleLineAttributedText(
"""
**Checked** *both* hosts and [`sshd`](https://example.com).
@@ -249,34 +242,26 @@ struct MarkdownParsingTests {
#expect(TermindMarkdownParser.parse("").isEmpty)
}
// MARK: - Soft-break guards
@Test(arguments: [
"```\nnot prose\nstill code\n```",
"| a | b |\n|---|---|\n| 1 | 2 |",
"<output label=\"log\">\nfirst\nsecond\n</output>",
"text\n# Heading",
"text\n- list item",
"text\n1. list item",
"Title\n===",
"text\n---",
"text\n```",
])
func hardeningLeavesStructuralLinesAlone(source: String) {
#expect(!MarkdownSoftBreaks.hardened(source).contains("\\"))
@Test(arguments: ["first\\\nsecond", "first \nsecond"])
func explicitHardBreaksRemainLineBreaks(source: String) {
let inlines = paragraphInlines(TermindMarkdownParser.parse(source).first)
#expect(inlines.contains(.lineBreak))
#expect(inlineText(inlines) == "first\nsecond")
}
@Test
func hardeningAddsOneBreakBetweenTwoProseLines() {
#expect(MarkdownSoftBreaks.hardened("first\nsecond") == "first\\\nsecond")
}
@Test
func hardeningDoesNotDoubleUpOnAnExistingHardBreak() {
#expect(
MarkdownSoftBreaks.hardened("first\\\nsecond").filter { $0 == "\\" }.count == 1
)
#expect(!MarkdownSoftBreaks.hardened("first \nsecond").contains("\\"))
@MainActor
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
func suggestedCommandKeepsLiteralCodeAndLocalizesTheUpstreamHeader(language: String) throws {
let command = "printf '%s\\n' '```'\ncat <<'EOF'\n# literal heading\nEOF"
let content = SuggestedCommandCodeBlock.content(command, locale: Locale(identifier: language))
try #require(content.blocks.count == 1)
guard case .codeBlock(let title, let code) = content.blocks[0] else {
Issue.record("expected a single code block")
return
}
let titles = ["en": "Suggested Command", "zh-Hans": "建议的命令", "zh-Hant": "建議的指令"]
#expect(title == titles[language])
#expect(code == command)
}
private func paragraphText(_ block: MarkdownBlockNode?) -> String? {
@@ -289,13 +274,6 @@ struct MarkdownParsingTests {
return inlines
}
private func linkedInline(_ source: String) -> MarkdownInlineNode? {
paragraphInlines(TermindMarkdownParser.parse(source).first).first { inline in
guard case .link = inline else { return false }
return true
}
}
private func inlineText(_ inlines: [MarkdownInlineNode]) -> String {
inlines.map { inline in
switch inline {
+53
View File
@@ -0,0 +1,53 @@
import AgentKit
import Foundation
import Testing
@testable import TermindRemoteInfrastructure
@Suite
struct TerminalSuggestToolTests {
@Test
func suggestionAcceptsOnlyACommandAndReturnsNoExplanation() async throws {
let tool = TerminalSuggestTool()
let arguments = AgentJSONValue.object(["command": .string("pwd")])
try AgentJSONSchemaValidator.validate(arguments, against: tool.descriptor.inputSchema)
#expect(
throws: (any Error).self,
performing: {
try AgentJSONSchemaValidator.validate(
.object(["command": .string("pwd"), "explanation": .string("Show the directory.")]),
against: tool.descriptor.inputSchema
)
}
)
let invocation = AgentToolInvocation(
runID: UUID(), call: .init(id: "suggestion", name: "terminal_suggest", arguments: arguments)
)
let context = AgentToolExecutionContext(
runID: invocation.runID, userIntent: "Suggest a command",
secretBroker: SecretBroker(), userInteraction: UnavailableAgentUserInteraction(),
preflightMetadata: [:], authorize: { _ in .allow }
)
let result = try await tool.execute(invocation, context: context)
let raw = try #require(result.untrustedPayload)
let payload = try #require(AgentJSONValue.decode(Data(raw.utf8)).objectValue)
#expect(payload["command"] == .string("pwd"))
#expect(payload["requires_user_action"] == .bool(true))
#expect(payload["risk"]?.stringValue?.isEmpty == false)
#expect(payload["explanation"] == nil)
}
@Test
func legacyExplanationIsNotPresented() {
let items = TerminalSuggestTool.present(
AgentToolDetailInput(
result: .object(["command": .string("pwd"), "explanation": .string("Legacy result explanation")]),
arguments: ["command": .string("pwd"), "explanation": .string("Legacy argument explanation")],
locale: Locale(identifier: "en")
)
)
#expect(items.contains(.text(.init(title: "Command", text: "pwd", style: .monospaced))))
#expect(items.contains(.message("Requires user action", .warning)))
#expect(!String(describing: items).contains("Legacy"))
}
}
+90 -200
View File
@@ -2,6 +2,7 @@ import AgentKit
import AppKit
import Foundation
import ListViewKit
import MarkdownView
import SwiftUI
import TermindApplication
import TermindDomain
@@ -351,7 +352,7 @@ struct TranscriptListViewBridgeTests {
await settleResize(listView)
let row = try #require(listView.rowView(for: id))
let markdown = try #require(descendant(in: row, identifier: "Transcript.AssistantMarkdown"))
let segment = try #require(descendant(in: row, ofType: NativeMarkdownSegment.self))
let segment = try #require(descendant(in: row, ofType: MarkdownTextView.self))
let wideHeight = listView.rectForRow(with: id).height
let wideWidth = markdown.bounds.width
@@ -359,7 +360,7 @@ struct TranscriptListViewBridgeTests {
await settleResize(listView)
#expect(listView.rowView(for: id) === row)
#expect(descendant(in: row, ofType: NativeMarkdownSegment.self) === segment)
#expect(descendant(in: row, ofType: MarkdownTextView.self) === segment)
#expect(abs(markdown.bounds.width - (360 - (isCompact ? 24 : 32))) <= 1)
#expect(listView.rectForRow(with: id).height > wideHeight)
#expect(segment.bounds.height > 1)
@@ -448,12 +449,13 @@ struct TranscriptListViewBridgeTests {
isCompact: false)
await settleResize(listView)
let row = try #require(listView.rowView(for: id))
let segment = try #require(descendant(in: row, ofType: NativeMarkdownSegment.self))
let segment = try #require(descendant(in: row, ofType: MarkdownTextView.self))
listView.frame.size.width = 360
await settleResize(listView)
let narrowScrollers = descendants(in: segment, ofType: NSScrollView.self)
#expect(narrowScrollers.count == 2)
#expect(narrowScrollers.allSatisfy { !$0.hasHorizontalScroller })
#expect(narrowScrollers.allSatisfy { $0.contentView.bounds.width <= 328 })
for scroller in narrowScrollers {
#expect((scroller.documentView?.frame.width ?? 0) > scroller.contentView.bounds.width)
@@ -461,7 +463,7 @@ struct TranscriptListViewBridgeTests {
listView.frame.size.width = 900
await settleResize(listView)
#expect(descendant(in: row, ofType: NativeMarkdownSegment.self) === segment)
#expect(descendant(in: row, ofType: MarkdownTextView.self) === segment)
#expect(
descendants(in: segment, ofType: NSScrollView.self).allSatisfy {
$0.contentView.bounds.width > 328
@@ -748,72 +750,58 @@ struct TranscriptListViewBridgeTests {
}
@Test
func terminalSuggestionUsesTheSameSurfaceAsThePlanCard() throws {
func terminalSuggestionUsesUpstreamCodeWithActionsAndRiskBelow() throws {
let coordinator = TranscriptListView.Coordinator()
let listView = coordinator.makeListView()
listView.appearance = NSAppearance(named: .aqua)
listView.frame = CGRect(x: 0, y: 0, width: 420, height: 800)
let id = UUID()
let suggestion = SuggestedCommand(
id: id,
command: "pwd",
explanation: "Show the current directory."
)
let entry = TranscriptEntry(
id: id,
content: .command(suggestion)
)
let suggestion = SuggestedCommand(command: "pwd")
var deliveries: [(String, Bool)] = []
update(
coordinator,
listView,
entries: [entry],
coordinator, listView,
entries: [.init(id: suggestion.id, content: .command(suggestion))],
showsTerminalActions: true,
actions: .init(canDeliverCommand: { true })
actions: .init(
canDeliverCommand: { true },
deliverCommand: { deliveries.append(($0, $1)) }
)
)
listView.layoutSubtreeIfNeeded()
let row = try #require(listView.rowView(for: id))
let row = try #require(listView.rowView(for: suggestion.id))
let card = try #require(descendant(in: row, identifier: "Transcript.CommandCard"))
let content = try #require(
descendant(in: card, identifier: "Transcript.CommandCardContent") as? NSStackView
)
let commandBackground = try #require(
descendant(in: card, identifier: "Transcript.CommandCardCommandBackground")
)
let command = try #require(
descendant(in: card, identifier: "Transcript.CommandCardCommand") as? NSTextField
)
let explanation = try #require(
descendant(in: card, identifier: "Transcript.CommandCardExplanation") as? NSTextField
)
let insert = try #require(
descendant(in: card, identifier: "Transcript.CommandCardInsert") as? NSButton
)
let run = try #require(
descendant(in: card, identifier: "Transcript.CommandCardRun") as? NSButton
)
let content = try #require(descendant(in: card, identifier: "Transcript.CommandCardContent") as? NSStackView)
let details = try #require(descendant(in: card, identifier: "Transcript.CommandCardDetails") as? NSStackView)
let code = try #require(descendant(in: card, identifier: "Transcript.CommandCardCommand"))
let markdown = try #require(descendant(in: code, ofType: MarkdownTextView.self))
guard case .codeBlock(let title, let command)? = markdown.content.blocks.first else {
Issue.record("expected the suggested command in an upstream code block")
return
}
#expect(title == "Suggested Command")
#expect(command == "pwd")
let codeButtons = descendants(in: markdown, ofType: NSButton.self)
for selector in ["handleCopy:", "handleExpand:"] {
let button = try #require(codeButtons.first { $0.action.map(NSStringFromSelector) == selector })
#expect(button.target != nil && button.isEnabled && !button.isHidden)
}
#expect(descendants(in: markdown, ofType: NSScrollView.self).allSatisfy { !$0.hasHorizontalScroller })
card.displayIfNeeded()
commandBackground.displayIfNeeded()
let cardColor = try #require(card.layer?.backgroundColor.flatMap(NSColor.init(cgColor:)))
let commandColor = try #require(
commandBackground.layer?.backgroundColor.flatMap(NSColor.init(cgColor:))
)
// A detached layer can resolve a named AppKit color as clear while the
// test list has no window. The mounted app resolves it through the
// owning appearance; this test owns the card hierarchy, not AppKit's
// detached dynamic-color behavior.
#expect(cardColor.alphaComponent == 0 || cardColor.alphaComponent == 1)
#expect(card.layer?.cornerRadius == Metrics.Radius.large)
#expect(card.layer?.borderWidth == 1)
#expect(commandColor.alphaComponent == 0)
#expect(content.spacing == Metrics.Space.m)
#expect(command.font?.fontDescriptor.symbolicTraits.contains(.monoSpace) == true)
#expect(command.font?.pointSize == NSFont.systemFontSize - 1)
#expect(explanation.font?.pointSize == NSFont.smallSystemFontSize)
#expect(insert.isBordered)
#expect(run.isBordered)
let footer = try #require(descendant(in: card, identifier: "Transcript.CommandCardActions") as? NSStackView)
#expect(content.arrangedSubviews.first === code)
#expect(content.arrangedSubviews.last === details)
#expect(details.arrangedSubviews.last === footer)
#expect((card.layer?.backgroundColor?.alpha ?? 0) > 0)
#expect(markdown.theme.colors.codeBlockBackground.alphaComponent == 0)
#expect(markdown.theme.colors.codeBlockBarBackground == MarkdownTheme().colors.codeBlockBarBackground)
#expect(details.arrangedSubviews.count == 1)
#expect(descendant(in: card, identifier: "Transcript.CommandCardExplanation") == nil)
#expect(descendant(in: footer, identifier: "Transcript.CommandCardRiskBadge") != nil)
let insert = try #require(descendant(in: footer, identifier: "Transcript.CommandCardInsert") as? NSButton)
let run = try #require(descendant(in: footer, identifier: "Transcript.CommandCardRun") as? NSButton)
insert.performClick(nil)
run.performClick(nil)
#expect(deliveries.map(\.0) == ["pwd", "pwd"])
#expect(deliveries.map(\.1) == [false, true])
}
@Test
@@ -828,7 +816,6 @@ struct TranscriptListViewBridgeTests {
scratchPath: "/tmp/plan.md"
)
let commandID = UUID()
let markdownID = UUID()
listView.appearance = NSAppearance(named: .aqua)
listView.frame = CGRect(x: 0, y: 0, width: 520, height: 2_000)
presentation.beginEditingMessage(userID, text: "Edited message")
@@ -847,15 +834,10 @@ struct TranscriptListViewBridgeTests {
content: .command(
SuggestedCommand(
id: commandID,
command: "pwd",
explanation: "Show the current directory."
command: "pwd"
)
)
),
assistant(
id: markdownID,
text: "```bash\nrm -rf /tmp/termind-prominent-button-test\n```"
),
],
presentation: presentation,
isCompact: false,
@@ -868,7 +850,6 @@ struct TranscriptListViewBridgeTests {
(userID, "Transcript.UserEditorSend"),
(plan.id, "Transcript.PlanImplement"),
(commandID, "Transcript.CommandCardRun"),
(markdownID, "Transcript.MarkdownShellRun"),
]
let buttons = try buttonLocations.map { rowID, identifier in
let row = try #require(listView.rowView(for: rowID))
@@ -1286,143 +1267,59 @@ struct TranscriptListViewBridgeTests {
#expect(actions.alphaValue == 0)
}
@Test
func overflowingMarkdownBlocksExposeHoverableHorizontalScrollers() throws {
@Test(arguments: [false, true], [false, true])
func markdownRendersOneDocumentWithoutAddingScrollbars(isUser: Bool, overflows: Bool) throws {
let coordinator = TranscriptListView.Coordinator()
let listView = coordinator.makeListView()
let id = UUID()
listView.frame = CGRect(x: 0, y: 0, width: 320, height: 800)
let longValue = String(repeating: "wide-content-", count: 24)
let markdown = """
listView.frame = CGRect(x: 0, y: 0, width: overflows ? 320 : 900, height: 1_200)
let value = overflows ? String(repeating: "wide-content-", count: 24) : "short"
let source = """
Before the blocks.
| Name | Value |
| --- | --- |
| table | \(longValue) |
| table | \(value) |
```swift
let value = "\(longValue)"
let value = "\(value)"
```
"""
update(coordinator, listView, entries: [assistant(id: id, text: markdown)])
listView.layoutSubtreeIfNeeded()
let row = try #require(listView.rowView(for: id))
let segment = try #require(descendant(in: row, ofType: NativeMarkdownSegment.self))
segment.layoutSubtreeIfNeeded()
let scrollViews = horizontalScrollViews(in: segment)
let allHaveHorizontalScrollers = scrollViews.allSatisfy { $0.hasHorizontalScroller }
let allScrollersAreHidden = scrollViews.allSatisfy {
$0.horizontalScroller?.alphaValue == 0
&& $0.horizontalScroller?.isHidden == true
}
#expect(scrollViews.count == 2)
#expect(allHaveHorizontalScrollers)
#expect(allScrollersAreHidden)
#expect(segment.trackingAreas.allSatisfy { !$0.options.contains(.mouseMoved) })
let trackingAreas = try scrollViews.map { scrollView in
try #require(
scrollView.trackingAreas.first { area in
area.owner === segment
&& area.options.contains(.inVisibleRect)
&& area.options.contains(.mouseEnteredAndExited)
&& !area.options.contains(.mouseMoved)
}
)
}
segment.needsLayout = true
segment.layoutSubtreeIfNeeded()
for (scrollView, trackingArea) in zip(scrollViews, trackingAreas) {
let ownedTrackingAreas = scrollView.trackingAreas.filter { $0.owner === segment }
#expect(ownedTrackingAreas.count == 1)
#expect(ownedTrackingAreas.first === trackingArea)
}
for scrollView in scrollViews {
let scroller = try #require(scrollView.horizontalScroller)
let contentHit = [
CGPoint(x: scrollView.bounds.midX, y: scrollView.bounds.minY + 2),
CGPoint(x: scrollView.bounds.midX, y: scrollView.bounds.maxY - 2),
CGPoint(x: scrollView.bounds.midX, y: scrollView.bounds.midY),
].lazy.compactMap { point -> (NSPoint, NSView)? in
guard !scroller.frame.contains(point) else { return nil }
let localPoint = segment.convert(point, from: scrollView)
guard let superview = segment.superview else { return nil }
let hitPoint = superview.convert(localPoint, from: segment)
guard let hit = segment.hitTest(hitPoint), !(hit is NSScroller) else { return nil }
return (hitPoint, hit)
}.first
let hoverPoint = segment.convert(
CGPoint(x: scrollView.bounds.midX, y: scrollView.bounds.midY),
from: scrollView
)
segment.updateHorizontalScrollerHover(at: hoverPoint)
#expect(scroller.alphaValue == 1)
#expect(!scroller.isHidden)
#expect(scroller.scrollerStyle == .legacy)
#expect(scroller.controlSize == .mini)
#expect(scroller.frame.width > 0)
#expect(scroller.frame.height > 0)
#expect(
scroller.frame.height
<= NSScroller.scrollerWidth(for: .mini, scrollerStyle: .legacy) + 1
)
if let (hitPoint, hitBeforeHover) = contentHit {
#expect(segment.hitTest(hitPoint) === hitBeforeHover)
}
let localScrollerPoint = segment.convert(
CGPoint(x: scroller.bounds.midX, y: scroller.bounds.midY),
from: scroller
)
let scrollerHitPoint = try #require(segment.superview).convert(localScrollerPoint, from: segment)
#expect(segment.hitTest(scrollerHitPoint) === scroller)
let documentView = try #require(scrollView.documentView)
let maximumX = documentView.frame.width - scrollView.contentView.bounds.width
scrollView.contentView.scroll(to: CGPoint(x: maximumX, y: 0))
scrollView.reflectScrolledClipView(scrollView.contentView)
#expect(scrollView.contentView.bounds.minX > 0)
}
segment.updateHorizontalScrollerHover(at: nil)
#expect(scrollViews.allSatisfy { $0.horizontalScroller?.alphaValue == 0 })
#expect(scrollViews.allSatisfy { $0.horizontalScroller?.isHidden == true })
}
@Test
func markdownScrollersStayHiddenWhenTheirContentFits() throws {
let coordinator = TranscriptListView.Coordinator()
let listView = coordinator.makeListView()
let id = UUID()
listView.frame = CGRect(x: 0, y: 0, width: 900, height: 800)
let markdown = """
| Name | Value |
| --- | --- |
| table | short |
```swift
let value = 1
```bash
echo "\(value)"
```
After the blocks.
"""
update(coordinator, listView, entries: [assistant(id: id, text: markdown)])
listView.layoutSubtreeIfNeeded()
let row = try #require(listView.rowView(for: id))
let segment = try #require(descendant(in: row, ofType: NativeMarkdownSegment.self))
segment.layoutSubtreeIfNeeded()
let scrollViews = descendants(in: segment, ofType: NSScrollView.self)
#expect(scrollViews.count == 2)
segment.updateHorizontalScrollerHover(
at: CGPoint(x: segment.bounds.midX, y: segment.bounds.midY)
let entry = isUser ? user(id: id, text: source) : assistant(id: id, text: source)
update(
coordinator, listView, entries: [entry], showsTerminalActions: true,
actions: .init(canDeliverCommand: { true })
)
#expect(scrollViews.allSatisfy { $0.horizontalScroller?.alphaValue == 0 })
#expect(scrollViews.allSatisfy { $0.horizontalScroller?.isHidden == true })
#expect(scrollViews.allSatisfy { $0.horizontalScroller?.isEnabled == false })
listView.layoutSubtreeIfNeeded()
let row = try #require(listView.rowView(for: id))
let renderers = descendants(in: row, ofType: MarkdownTextView.self)
#expect(renderers.count == 1)
let markdown = try #require(renderers.first)
#expect(markdown.content.blocks.count == 5)
#expect(descendant(in: row, identifier: "Transcript.MarkdownShellRun") == nil)
let scrollViews = descendants(in: markdown, ofType: NSScrollView.self)
#expect(scrollViews.count == 3)
#expect(scrollViews.allSatisfy { !$0.hasHorizontalScroller && !$0.hasVerticalScroller })
#expect(descendants(in: markdown, ofType: NSScroller.self).isEmpty)
if overflows {
for scrollView in scrollViews {
let documentView = try #require(scrollView.documentView)
let maximumX = documentView.frame.width - scrollView.contentView.bounds.width
#expect(maximumX > 0)
scrollView.contentView.scroll(to: CGPoint(x: maximumX, y: 0))
scrollView.reflectScrolledClipView(scrollView.contentView)
#expect(scrollView.contentView.bounds.minX > 0)
}
}
}
@Test
@@ -1671,13 +1568,6 @@ struct TranscriptListViewBridgeTests {
return result
}
private func horizontalScrollViews(in root: NSView) -> [NSScrollView] {
descendants(in: root, ofType: NSScrollView.self).filter { scrollView in
guard let documentView = scrollView.documentView else { return false }
return documentView.frame.width > scrollView.contentView.bounds.width + 1
}
}
private func expectProminentForeground(_ button: NSButton, component: CGFloat) throws {
let titleColor = try #require(
button.attributedTitle.attribute(