fix(assistant): align markdown and command rendering with upstream
This commit is contained in:
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) ")
|
||||
#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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
""
|
||||
@MainActor
|
||||
func renderedImagesKeepTheirSourceAndAltText() throws {
|
||||
let document = TermindMarkdownRenderedDocument(
|
||||
source: "",
|
||||
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 {
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user