From 981106f6876b6b254d593763d589fad9ad282fee Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sun, 4 Oct 2026 11:08:59 +0800 Subject: [PATCH] fix(assistant): align markdown and command rendering with upstream --- ...AssistantTranscriptPresentationTests.swift | 8 +- .../Agent/ConversationModel.swift | 5 +- .../AssistantMessageBody.swift | 10 +- .../AssistantToolDetails.swift | 8 +- .../MarkdownSoftBreaks.swift | 175 ------ .../SuggestedCommandCodeBlock.swift | 38 ++ .../TermindMarkdownParser.swift | 85 +-- .../Agent/AgentBuiltInSSHTools.swift | 9 +- .../AI/MobileAssistantTranscriptCards.swift | 40 +- .../AI/MobileAssistantTranscriptStyle.swift | 6 - TFM/Design/DesignTokens.swift | 1 - TFM/Features/AI/MarkdownBlockListView.swift | 60 +- TFM/Features/AI/TranscriptNativeRows.swift | 560 ++---------------- TFMTests/ChatComposerStateTests.swift | 3 +- TFMTests/MarkdownParsingTests.swift | 168 +++--- TFMTests/TerminalSuggestToolTests.swift | 53 ++ TFMTests/TranscriptListViewTests.swift | 290 +++------ 17 files changed, 331 insertions(+), 1188 deletions(-) delete mode 100644 Frameworks/TermindPresentation/MarkdownSoftBreaks.swift create mode 100644 Frameworks/TermindPresentation/SuggestedCommandCodeBlock.swift create mode 100644 TFMTests/TerminalSuggestToolTests.swift diff --git a/FrameworkTests/AssistantTranscriptPresentationTests.swift b/FrameworkTests/AssistantTranscriptPresentationTests.swift index 6397b3c9..66b58989 100644 --- a/FrameworkTests/AssistantTranscriptPresentationTests.swift +++ b/FrameworkTests/AssistantTranscriptPresentationTests.swift @@ -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 { diff --git a/Frameworks/TermindApplication/Agent/ConversationModel.swift b/Frameworks/TermindApplication/Agent/ConversationModel.swift index 2f2a1ba8..7e97c737 100644 --- a/Frameworks/TermindApplication/Agent/ConversationModel.swift +++ b/Frameworks/TermindApplication/Agent/ConversationModel.swift @@ -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 ) } diff --git a/Frameworks/TermindPresentation/AssistantMessageBody.swift b/Frameworks/TermindPresentation/AssistantMessageBody.swift index 29bb8390..ea70262d 100644 --- a/Frameworks/TermindPresentation/AssistantMessageBody.swift +++ b/Frameworks/TermindPresentation/AssistantMessageBody.swift @@ -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) } } diff --git a/Frameworks/TermindPresentation/AssistantToolDetails.swift b/Frameworks/TermindPresentation/AssistantToolDetails.swift index d07ba9c8..0409b23b 100644 --- a/Frameworks/TermindPresentation/AssistantToolDetails.swift +++ b/Frameworks/TermindPresentation/AssistantToolDetails.swift @@ -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) } diff --git a/Frameworks/TermindPresentation/MarkdownSoftBreaks.swift b/Frameworks/TermindPresentation/MarkdownSoftBreaks.swift deleted file mode 100644 index e851bc98..00000000 --- a/Frameworks/TermindPresentation/MarkdownSoftBreaks.swift +++ /dev/null @@ -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 == " " - } -} diff --git a/Frameworks/TermindPresentation/SuggestedCommandCodeBlock.swift b/Frameworks/TermindPresentation/SuggestedCommandCodeBlock.swift new file mode 100644 index 00000000..888427fc --- /dev/null +++ b/Frameworks/TermindPresentation/SuggestedCommandCodeBlock.swift @@ -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 + ) + } +} diff --git a/Frameworks/TermindPresentation/TermindMarkdownParser.swift b/Frameworks/TermindPresentation/TermindMarkdownParser.swift index c5ad6563..7736847f 100644 --- a/Frameworks/TermindPresentation/TermindMarkdownParser.swift +++ b/Frameworks/TermindPresentation/TermindMarkdownParser.swift @@ -2,82 +2,8 @@ import Foundation import MarkdownParser nonisolated public enum TermindMarkdownParser { - public static let allowedLinkSchemes: Set = ["http", "https", "mailto"] - public static let shellLanguages: Set = [ - "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) -} diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSSHTools.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSSHTools.swift index 79132658..0428b86a 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSSHTools.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSSHTools.swift @@ -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) }) diff --git a/TFI/Features/AI/MobileAssistantTranscriptCards.swift b/TFI/Features/AI/MobileAssistantTranscriptCards.swift index 527efdce..7eb01bd8 100644 --- a/TFI/Features/AI/MobileAssistantTranscriptCards.swift +++ b/TFI/Features/AI/MobileAssistantTranscriptCards.swift @@ -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 { diff --git a/TFI/Features/AI/MobileAssistantTranscriptStyle.swift b/TFI/Features/AI/MobileAssistantTranscriptStyle.swift index d3616ce2..2fd4458e 100644 --- a/TFI/Features/AI/MobileAssistantTranscriptStyle.swift +++ b/TFI/Features/AI/MobileAssistantTranscriptStyle.swift @@ -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 } } diff --git a/TFM/Design/DesignTokens.swift b/TFM/Design/DesignTokens.swift index a415fbb1..dfca75d5 100644 --- a/TFM/Design/DesignTokens.swift +++ b/TFM/Design/DesignTokens.swift @@ -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. diff --git a/TFM/Features/AI/MarkdownBlockListView.swift b/TFM/Features/AI/MarkdownBlockListView.swift index d45267a9..ed070917 100644 --- a/TFM/Features/AI/MarkdownBlockListView.swift +++ b/TFM/Features/AI/MarkdownBlockListView.swift @@ -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) } diff --git a/TFM/Features/AI/TranscriptNativeRows.swift b/TFM/Features/AI/TranscriptNativeRows.swift index 91b75695..be4f1837 100644 --- a/TFM/Features/AI/TranscriptNativeRows.swift +++ b/TFM/Features/AI/TranscriptNativeRows.swift @@ -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 diff --git a/TFMTests/ChatComposerStateTests.swift b/TFMTests/ChatComposerStateTests.swift index e67e06c2..9c2c4292 100644 --- a/TFMTests/ChatComposerStateTests.swift +++ b/TFMTests/ChatComposerStateTests.swift @@ -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) } diff --git a/TFMTests/MarkdownParsingTests.swift b/TFMTests/MarkdownParsingTests.swift index 08044286..4f830c63 100644 --- a/TFMTests/MarkdownParsingTests.swift +++ b/TFMTests/MarkdownParsingTests.swift @@ -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 |", - "\nfirst\nsecond\n", - "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 { diff --git a/TFMTests/TerminalSuggestToolTests.swift b/TFMTests/TerminalSuggestToolTests.swift new file mode 100644 index 00000000..c8ee8a9e --- /dev/null +++ b/TFMTests/TerminalSuggestToolTests.swift @@ -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")) + } +} diff --git a/TFMTests/TranscriptListViewTests.swift b/TFMTests/TranscriptListViewTests.swift index bf470f34..74227775 100644 --- a/TFMTests/TranscriptListViewTests.swift +++ b/TFMTests/TranscriptListViewTests.swift @@ -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(