Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 70 additions & 14 deletions packages/HostUI/Sources/HostUI/HostUIMarkdown.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,49 @@ import SwiftUI

/// Shared markdown + `$profile` token coloring for HostUI message bodies.
public enum HostUIMarkdown {
/// Dark green for `$handle` and `[Derrick:handle]`. Darker than system green on white bubbles.
public static let profileTokenColor = Color(red: 0.0, green: 0.42, blue: 0.18)

public static func attributed(_ text: String) -> AttributedString {
var options = AttributedString.MarkdownParsingOptions()
options.interpretedSyntax = .inlineOnlyPreservingWhitespace
var attributed = (try? AttributedString(markdown: text, options: options))
?? AttributedString(text)
for range in AgentProfileTokenHighlight.ranges(in: text) {
let snippet = String(text[range])
if let match = attributed.range(of: snippet) {
attributed[match].foregroundColor = Color(red: 0.0, green: 0.42, blue: 0.18)
}
colorProfileTokens(&attributed)
return attributed
}

/// Paints `$handle` and `[Derrick:handle]` on already-rendered text. Later copies of the same token are included.
public static func colorProfileTokens(_ attributed: inout AttributedString) {
let source = String(attributed.characters)
var search = attributed.startIndex
for range in AgentProfileTokenHighlight.ranges(in: source) {
let snippet = String(source[range])
guard !snippet.isEmpty, let match = attributed[search...].range(of: snippet) else { continue }
attributed[match].foregroundColor = profileTokenColor
search = match.upperBound
}
}
}

/// Plain text with `$handle` and `[Derrick:handle]` in `HostUIMarkdown.profileTokenColor`.
public struct HostUIProfileTokenText: View {
private let text: String
private let base: Color

public init(_ text: String, base: Color) {
self.text = text
self.base = base
}

public var body: some View {
Text(Self.attributed(text, base: base))
}

public static func attributed(_ text: String, base: Color) -> AttributedString {
var attributed = AttributedString(text)
attributed.foregroundColor = base
HostUIMarkdown.colorProfileTokens(&attributed)
return attributed
}
}
Expand All @@ -25,30 +57,44 @@ extension AttributedString {
}
}

/// How rendered markdown measures itself. Bubbles hug short copy. Documents fill the proposed width.
public enum HostUIMarkdownSizing: Sendable {
case bubble
case document(maxIdealWidth: CGFloat)
}

public struct HostUIMarkdownText: View {
private let text: String
private let fontSize: CGFloat
private let maxIdealWidth: CGFloat
private let sizing: HostUIMarkdownSizing

public init(_ text: String, font: Font = .system(size: 13), maxIdealWidth: CGFloat = HostUIMessagingLayout.maxBubbleWidth) {
self.text = text
// HostUI bubbles size via AppKit measurement; keep a numeric size.
self.fontSize = 13
self.maxIdealWidth = maxIdealWidth
self.sizing = .bubble
_ = font
_ = maxIdealWidth
}

public init(_ text: String, fontSize: CGFloat, maxIdealWidth: CGFloat = HostUIMessagingLayout.maxBubbleWidth) {
self.text = text
self.fontSize = fontSize
self.maxIdealWidth = maxIdealWidth
self.sizing = .bubble
_ = maxIdealWidth
}

public init(_ text: String, fontSize: CGFloat, sizing: HostUIMarkdownSizing) {
self.text = text
self.fontSize = fontSize
self.sizing = sizing
}

public var body: some View {
HostUIMeasuringMarkdownText(
attributedString: HostUIMarkdown.attributed(text),
fontSize: fontSize,
maxIdealWidth: maxIdealWidth
sizing: sizing
)
.tint(Color(red: 0.176, green: 0.286, blue: 0.576))
}
Expand All @@ -58,7 +104,7 @@ public struct HostUIMarkdownText: View {
private struct HostUIMeasuringMarkdownText: NSViewRepresentable {
let attributedString: AttributedString
var fontSize: CGFloat = 13
var maxIdealWidth: CGFloat = 420
var sizing: HostUIMarkdownSizing = .bubble

func makeCoordinator() -> Coordinator {
Coordinator()
Expand Down Expand Up @@ -102,10 +148,7 @@ private struct HostUIMeasuringMarkdownText: NSViewRepresentable {
) -> CGSize? {
apply(to: nsView)
let proposed = proposal.width.flatMap { $0.isFinite && $0 > 1 ? $0 : nil }
let width = HostUIMessagingLayout.cappedBubbleWidth(
ideal: nsView.idealWidth(),
containerWidth: proposed
)
let width = measuredWidth(ideal: nsView.idealWidth(), proposed: proposed)
let height = nsView.height(forWidth: width)
return CGSize(width: width, height: height)
}
Expand All @@ -115,6 +158,7 @@ private struct HostUIMeasuringMarkdownText: NSViewRepresentable {
var styled = attributedString
styled.font = Font(nsFont)
styled.foregroundColor = Color(nsColor: .labelColor)
HostUIMarkdown.colorProfileTokens(&styled)

let next = NSMutableAttributedString(attributedString: NSAttributedString(styled))
next.enumerateAttribute(.link, in: NSRange(location: 0, length: next.length)) { value, range, _ in
Expand All @@ -130,6 +174,18 @@ private struct HostUIMeasuringMarkdownText: NSViewRepresentable {
}
}

private func measuredWidth(ideal: CGFloat, proposed: CGFloat?) -> CGFloat {
switch sizing {
case .bubble:
return HostUIMessagingLayout.cappedBubbleWidth(ideal: ideal, containerWidth: proposed)
case .document(let maxIdealWidth):
if let proposed {
return proposed
}
return min(max(ideal, 1), maxIdealWidth)
}
}

final class Coordinator: NSObject, NSTextViewDelegate {
func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool {
let url: URL?
Expand Down
16 changes: 9 additions & 7 deletions packages/HostUI/Sources/HostUI/HostUIMessagingControls.swift
Original file line number Diff line number Diff line change
Expand Up @@ -157,16 +157,19 @@ public struct HostUIMessage: View {
HStack(alignment: .center, spacing: 8) {
Image(systemName: "bubble.left.and.bubble.right")
.font(.caption.weight(.semibold))
.foregroundStyle(row.replyCount > 0 ? HostUIMessagingLayout.navy : .primary.opacity(0.75))
VStack(alignment: .leading, spacing: 2) {
Text(replyTitle)
.font(.caption.weight(.semibold))
.foregroundStyle(row.replyCount > 0 ? HostUIMessagingLayout.navy : .primary.opacity(0.75))
if row.replyCount > 0, let preview = row.replyPreview, !preview.isEmpty {
// Plain one-line preview only — never expand the full thread body here.
Text(HostUIMessagingLayout.collapsedReplyPreview(preview))
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
HostUIProfileTokenText(
HostUIMessagingLayout.collapsedReplyPreview(preview),
base: .secondary
)
.font(.caption2)
.lineLimit(1)
.truncationMode(.tail)
}
}
}
Expand All @@ -179,7 +182,6 @@ public struct HostUIMessage: View {
)
}
.buttonStyle(.plain)
.foregroundStyle(row.replyCount > 0 ? HostUIMessagingLayout.navy : .primary.opacity(0.75))
.fixedSize(horizontal: true, vertical: false)
}

Expand Down
2 changes: 1 addition & 1 deletion packages/HostUI/Sources/HostUI/HostUINodeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ public struct HostUINodeView: View {
HostUIPaneWidthReader {
VStack(spacing: 0) {
HStack {
Text(bindings.replyThreadTitle)
HostUIProfileTokenText(bindings.replyThreadTitle, base: .primary)
.font(.headline)
.lineLimit(1)
Spacer()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import HostUI
import Testing

@MainActor
@Suite struct HostUIProfileTokenColorTests {
@Test func dollarHandleAndSenderPrefixShareDarkGreen() {
let text = "$orchestrator then [Derrick:developer] done"
let attributed = HostUIMarkdown.attributed(text)
let snippets = attributed.runs.compactMap { run -> String? in
guard attributed[run.range].foregroundColor == HostUIMarkdown.profileTokenColor else {
return nil
}
return String(attributed[run.range].characters)
}
#expect(snippets == ["$orchestrator", "[Derrick:developer]"])
}

@Test func plainPreviewKeepsTheSameGreenOnTheSenderPrefix() {
let attributed = HostUIProfileTokenText.attributed(
"[Derrick:orchestrator] Searching the web…",
base: .secondary
)
let green = attributed.runs.compactMap { run -> String? in
guard attributed[run.range].foregroundColor == HostUIMarkdown.profileTokenColor else {
return nil
}
return String(attributed[run.range].characters)
}
#expect(green == ["[Derrick:orchestrator]"])
}
}
5 changes: 3 additions & 2 deletions packages/Structure/Sources/AgentRuntime/AgentProfile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ public enum AgentProfileTokenParser {
}
}

/// Ranges of `$shortName` tokens (and the profile name inside `[Derrick:handle]`) for UI highlighting.
/// Ranges of `$shortName` tokens and the full `[Derrick:handle]` sender prefix for UI highlighting.
public enum AgentProfileTokenHighlight {
public static func ranges(
in text: String,
Expand Down Expand Up @@ -197,7 +197,8 @@ public enum AgentProfileTokenHighlight {
guard let close = text[handleStart...].firstIndex(of: "]") else { break }
let handle = String(text[handleStart..<close])
if isHighlightableHandle(handle) {
ranges.append(handleStart..<close)
let tokenEnd = text.index(after: close)
ranges.append(prefix.lowerBound..<tokenEnd)
}
searchFrom = close
if searchFrom < text.endIndex {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ import Testing

let outbound = "[Derrick:developer] Fixed the build."
let outboundTokens = AgentProfileTokenHighlight.ranges(in: outbound).map { String(outbound[$0]) }
#expect(outboundTokens == ["developer"])
#expect(outboundTokens == ["[Derrick:developer]"])

let ignored = AgentProfileTokenHighlight.ranges(in: "price is $100")
#expect(ignored.isEmpty)
Expand Down
123 changes: 0 additions & 123 deletions ui/ui/Components/SelectableLinkTextView.swift

This file was deleted.

Loading
Loading