Draw the blocks Markdown was already parsed into

Replies rendered their newlines away and their tables into mush. The
parser was never the problem. `AttributedString(markdown:)` is cmark-gfm
underneath and records everything — probed against a three-column table,
it returns the four characters `ab12` with every cell carrying
`[tableCell 0, tableHeaderRow, table [.left, .center, .right]]`. Column
count, alignments, header row and cell indices are all there. SwiftUI's
`Text` reads inline attributes only and ignores `presentationIntent`
entirely, so the structure was parsed and then discarded at draw time.
`a\n\nb` has the same shape: two paragraph intents, and a character
buffer that literally reads `ab`, because emitting a newline is the
renderer's job and there was no renderer.

So no new dependency. `apple/swift-markdown` would have bought a ready
tree and task-list checkboxes, at the price of a second Markdown parser
and a C target in a build that already carries three vendored ones — and
the actual labour, the SwiftUI block views, is identical either way.
What it costs instead is one pass that rebuilds nesting from the flat run
list: walk the runs, keep a stack of open containers, compare the
`identity` ints Foundation already hands out. `components` is ordered
innermost-first, which the grouper relies on and a test now pins, because
that is an observed fact about the framework rather than a contract.

Soft breaks are the one place we leave the spec. CommonMark renders a
lone newline as a space and Foundation obeys, emitting an untagged `" "`
run that is indistinguishable from a typed space — so the choice cannot
be made after parsing. It is made on the source, by appending a
backslash hard break the way GitHub's comment renderer does, because a
model that wrote a newline meant one. Every guard in `MarkdownSoftBreaks`
marks somewhere that edit would corrupt rather than break: fenced and
indented code, table rows, headings, thematic breaks, setext underlines,
list markers. One of them is not cosmetic. `composed(_:)` folds
attachments into the message as `<output label="…">…</output>` and
promises the bubble shows exactly what was uploaded; Foundation returns
that untouched as one HTML block, down to the `*` that emphasis would
otherwise eat, so editing a byte inside one would break that promise.

Two hazards found while wiring the shell fence card. `SSHSessionModel
.insert` writes raw bytes with no bracketed-paste wrapper, so an embedded
newline reaches the PTY as Return — "Insert" on a three-line script would
execute the first two while presenting itself as a preview. Multi-line
fences therefore get the risk badge and a copy button and nothing that
touches a session. And `selectedTab` is nil whenever the pinned Assistant
tab is showing, which is exactly where the full-width transcript lives:
`CommandCardView`'s Insert and Run have been dead there all along. The
new card disables them instead of looking live and doing nothing.

Rendering model output is a trust surface, and tool results are fed back
into it. Only `http`, `https` and `mailto` become clickable; a refused
scheme keeps its label as prose and names its destination on hover, which
is what stops `[click here](file:///…)` from reading as ordinary text.
Raw HTML is never interpreted. Images needed nothing — Foundation drops
the URL and keeps the alt text, closing the channel where a model-chosen
image address discloses the reader's IP and reading time the moment a
message renders.

The transcript is rebuilt around it. User turns are trailing bubbles that
size to their contents, assistant replies have no container at all, and
the rules between turns are gone because alignment and fill already say
who is speaking. Tool calls collapse to one line that opens itself while
the call is in flight or after it failed. Attachments fold to a labelled
row, unaltered and one click from open, rather than printing two hundred
lines of log into a right-aligned bubble. `ConversationView` and its
cards move out of `ContextPanelView`, where they had nothing to do with
the context panel.

Two fixes fall out of measuring the result. `TextEditor` has no
content-based ideal height — given `minHeight: 46, maxHeight: 120` it
takes 120, so an empty composer stood 185pt tall with the placeholder
marooned at the top; its height now comes from a hidden twin of the draft
and starts at 40. And the table rendered blank: a horizontal `ScrollView`
proposes unbounded width, the header `Divider` is greedy and took it, and
every centred or trailing column was aligned to an edge no one can reach.
Cells wrap instead, which also reads better at 260pt than a scrolling
region nested inside a scrolling one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kaede AkinoandClaude Opus 5 committed 2026-08-26 08:35:27 +08:00
1 parent c1af3a189f
commit cdea8d5448
13 files changed
+2037 -259

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
import Foundation
/// One run of inline text carrying the styles that apply to all of it.
///
/// Deliberately not an `AttributedString`: the SwiftUI attribute scope that
/// paints these — `.font`, `.backgroundColor`, `.link` — lives in SwiftUI, and
/// `Core` must not import it. The view builds the attributed string from these
/// values, which also keeps the parser testable without a rendering pass.
nonisolated struct MarkdownInline: Hashable, Sendable {
nonisolated struct Style: OptionSet, Hashable, Sendable {
let rawValue: Int
static let emphasis = Style(rawValue: 1 << 0)
static let strong = Style(rawValue: 1 << 1)
static let code = Style(rawValue: 1 << 2)
static let strikethrough = Style(rawValue: 1 << 3)
}
var text: String
var style: Style = []
/// Set only for schemes the renderer will actually open — see
/// `MarkdownDocumentParser.allowedLinkSchemes`.
var link: URL?
/// The destination of a link whose scheme was refused. The view shows the
/// label as plain text and reveals this on hover, so a `[click here](…)`
/// pointing at `file://` or a custom handler cannot masquerade as prose.
var rejectedLinkTarget: String?
}
nonisolated struct MarkdownList: Hashable, Sendable {
nonisolated struct Item: Hashable, Sendable {
/// `nil` for an ordinary bullet. GFM task syntax is not parsed by
/// Foundation, so this is recovered from the leading `[x] ` / `[ ] `
/// that survives into the item's first paragraph.
var checkbox: Bool?
var blocks: [MarkdownBlock] = []
}
var isOrdered: Bool
/// The ordinal the list actually starts at, so `3. 4. 5.` is not renumbered
/// to `1. 2. 3.`.
var start: Int = 1
var items: [Item] = []
}
nonisolated struct MarkdownTable: Hashable, Sendable {
nonisolated enum Alignment: Hashable, Sendable {
case leading, center, trailing
}
/// One entry per column. A GFM delimiter row may leave a column
/// unspecified, which becomes `.leading`.
var alignments: [Alignment] = []
var header: [[MarkdownInline]] = []
var rows: [[[MarkdownInline]]] = []
}
/// One block of rendered Markdown.
///
/// Recursion runs through arrays — a list item and a block quote both hold
/// `[MarkdownBlock]` — so the enum needs no `indirect`.
///
/// There are deliberately no identifiers on any of this. `ForEach` keys on
/// array offset instead, because a streaming message is re-parsed from scratch
/// on every burst of tokens: freshly minted `UUID`s would destroy view identity
/// several times a second and take text selection and animation with them.
nonisolated enum MarkdownBlock: Hashable, Sendable {
case paragraph([MarkdownInline])
case heading(level: Int, [MarkdownInline])
case codeBlock(language: String?, code: String)
case list(MarkdownList)
case table(MarkdownTable)
case blockQuote([MarkdownBlock])
/// Raw HTML, kept verbatim and never interpreted.
///
/// This is also how a composed attachment arrives — `ConversationModel`
/// wraps chip payloads in `<output label="…">…</output>`, and Foundation
/// hands the whole thing back as one untouched HTML block. Recognising that
/// grammar is the view layer's job: the tag vocabulary belongs to
/// `ContextChip.Kind`, which `Core` must not depend on.
case htmlBlock(String)
case thematicBreak
}
@@ -0,0 +1,297 @@
import Foundation
/// Turns Markdown source into blocks a view can actually draw.
///
/// The parsing is Foundation's — `AttributedString(markdown:)` is cmark-gfm
/// underneath and gets tables, column alignments, nested lists, fenced code and
/// inline styles right. What it does *not* do is render: it records block
/// structure as `presentationIntent` attribute runs and writes no newline
/// between blocks, and SwiftUI's `Text` ignores that attribute entirely. So a
/// table arrives as the four characters `abcd` with the structure hanging off
/// them, and everything a reader would recognise is discarded at draw time.
///
/// This type closes that gap. It takes the flat run list Foundation produces
/// and rebuilds the nesting SwiftUI's view tree needs — nothing is re-parsed.
nonisolated enum MarkdownDocumentParser {
/// Schemes a rendered link may carry.
///
/// Model output is untrusted and tool results are fed back into it, so a
/// reply can contain `[click here](file:///…)` or a URL that hands a custom
/// handler its argument. Anything outside this set keeps its label as prose
/// and surfaces its target on hover instead.
static let allowedLinkSchemes: Set<String> = ["http", "https", "mailto"]
private static let parsingOptions = AttributedString.MarkdownParsingOptions(
allowsExtendedAttributes: false,
interpretedSyntax: .full,
// A message is re-parsed on every burst of streamed tokens, so most
// parses run against a half-written document. Failing the whole thing
// would blank the reply mid-sentence.
failurePolicy: .returnPartiallyParsedIfPossible
)
static func parse(_ text: String) -> [MarkdownBlock] {
guard !text.isEmpty else { return [] }
guard
let attributed = try? AttributedString(
markdown: MarkdownSoftBreaks.hardened(text),
options: parsingOptions
)
else {
// Never swallow the model's reply because its Markdown was strange.
return [.paragraph([MarkdownInline(text: text)])]
}
var stack: [OpenBlock] = []
var roots: [MarkdownBlock] = []
for run in attributed.runs {
let text = String(attributed[run.range].characters)
// `components` is ordered innermost-first, so this reversal is what
// turns a run's intent stack into a root-to-leaf path. The order is
// load-bearing and is locked by a test.
let path = Array(run.presentationIntent?.components.reversed() ?? [])
let shared = sharedPrefixLength(stack, path)
while stack.count > shared { closeTop(&stack, into: &roots) }
for component in path[shared...] { stack.append(OpenBlock(component)) }
guard !text.isEmpty else { continue }
guard let innermost = stack.indices.last else {
appendUnparented(text, intent: run.inlinePresentationIntent, into: &roots)
continue
}
switch stack[innermost].role {
case .codeBlock:
stack[innermost].code += text
case .thematicBreak:
// Foundation gives a horizontal rule a placeholder glyph
// (U+2E3B). The rule is the block; the character is noise.
break
default:
stack[innermost].inlines.append(
inline(text, intent: run.inlinePresentationIntent, link: run.link)
)
}
}
while !stack.isEmpty { closeTop(&stack, into: &roots) }
return roots
}
// MARK: - Stack
/// A block that has been entered but not yet finished.
///
/// One struct for every role rather than an enum with associated values:
/// the accumulators are mutated in place through `stack[i].field`, which an
/// enum payload cannot be. Only the fields a given role uses are ever read.
private struct OpenBlock {
enum Role {
case paragraph
case heading(Int)
case codeBlock(String?)
case blockQuote
case list(ordered: Bool)
case listItem(ordinal: Int)
case table([MarkdownTable.Alignment])
case tableRow(isHeader: Bool)
case tableCell
case thematicBreak
case unknown
}
let identity: Int
let role: Role
var inlines: [MarkdownInline] = []
var blocks: [MarkdownBlock] = []
var code = ""
var cells: [[MarkdownInline]] = []
var rows: [[[MarkdownInline]]] = []
var header: [[MarkdownInline]] = []
var items: [MarkdownList.Item] = []
var listStart: Int?
init(_ component: PresentationIntent.IntentType) {
identity = component.identity
switch component.kind {
case .paragraph: role = .paragraph
case .header(let level): role = .heading(level)
case .codeBlock(let languageHint): role = .codeBlock(languageHint)
case .blockQuote: role = .blockQuote
case .orderedList: role = .list(ordered: true)
case .unorderedList: role = .list(ordered: false)
case .listItem(let ordinal): role = .listItem(ordinal: ordinal)
case .table(let columns): role = .table(columns.map(MarkdownDocumentParser.alignment))
case .tableHeaderRow: role = .tableRow(isHeader: true)
case .tableRow: role = .tableRow(isHeader: false)
case .tableCell: role = .tableCell
case .thematicBreak: role = .thematicBreak
@unknown default: role = .unknown
}
}
}
private static func sharedPrefixLength(
_ stack: [OpenBlock], _ path: [PresentationIntent.IntentType]
) -> Int {
var shared = 0
while shared < stack.count, shared < path.count,
stack[shared].identity == path[shared].identity
{
shared += 1
}
return shared
}
/// Finishes the innermost block and folds it into whatever contains it —
/// the block below on the stack, or the document.
private static func closeTop(_ stack: inout [OpenBlock], into roots: inout [MarkdownBlock]) {
let closed = stack.removeLast()
let parent = stack.indices.last
switch closed.role {
case .tableCell:
guard let parent else { return }
stack[parent].cells.append(closed.inlines)
case .tableRow(let isHeader):
guard let parent else { return }
if isHeader {
stack[parent].header = closed.cells
} else {
stack[parent].rows.append(closed.cells)
}
case .listItem(let ordinal):
guard let parent else { return }
var blocks = closed.blocks
let checkbox = takeCheckbox(from: &blocks)
stack[parent].items.append(MarkdownList.Item(checkbox: checkbox, blocks: blocks))
// Whatever the first item was numbered is where the list starts, so
// `3. 4. 5.` is not silently renumbered from one.
if stack[parent].listStart == nil { stack[parent].listStart = ordinal }
default:
guard let block = block(from: closed) else { return }
if let parent {
stack[parent].blocks.append(block)
} else {
roots.append(block)
}
}
}
private static func block(from open: OpenBlock) -> MarkdownBlock? {
switch open.role {
case .paragraph, .unknown:
return open.inlines.isEmpty ? nil : .paragraph(open.inlines)
case .heading(let level):
return open.inlines.isEmpty ? nil : .heading(level: level, open.inlines)
case .codeBlock(let language):
var code = open.code
// The fence's own closing newline is structure, not content.
if code.hasSuffix("\n") { code.removeLast() }
let hint = language?.trimmingCharacters(in: .whitespaces)
return .codeBlock(language: (hint?.isEmpty ?? true) ? nil : hint, code: code)
case .blockQuote:
return open.blocks.isEmpty ? nil : .blockQuote(open.blocks)
case .list(let ordered):
guard !open.items.isEmpty else { return nil }
return .list(
MarkdownList(isOrdered: ordered, start: open.listStart ?? 1, items: open.items)
)
case .table(let alignments):
return .table(
MarkdownTable(alignments: alignments, header: open.header, rows: open.rows)
)
case .thematicBreak:
return .thematicBreak
case .listItem, .tableRow, .tableCell:
return nil // Folded into their container by `closeTop`.
}
}
// MARK: - Runs with no block of their own
/// A run can carry no `presentationIntent` at all, which in practice means
/// raw HTML — and in this app that is how a composed attachment arrives.
/// Consecutive runs are merged so one `<output>…</output>` stays one block.
private static func appendUnparented(
_ text: String, intent: InlinePresentationIntent?, into roots: inout [MarkdownBlock]
) {
guard intent?.contains(.blockHTML) == true || intent?.contains(.inlineHTML) == true else {
roots.append(.paragraph([MarkdownInline(text: text)]))
return
}
if case .htmlBlock(let existing)? = roots.last {
roots[roots.count - 1] = .htmlBlock(existing + text)
} else {
roots.append(.htmlBlock(text))
}
}
// MARK: - Inline
private static func inline(
_ text: String, intent: InlinePresentationIntent?, link: URL?
) -> MarkdownInline {
var style: MarkdownInline.Style = []
if let intent {
if intent.contains(.emphasized) { style.insert(.emphasis) }
if intent.contains(.stronglyEmphasized) { style.insert(.strong) }
if intent.contains(.code) { style.insert(.code) }
if intent.contains(.strikethrough) { style.insert(.strikethrough) }
}
var result = MarkdownInline(text: text, style: style)
guard let link else { return result }
if allowedLinkSchemes.contains(link.scheme?.lowercased() ?? "") {
result.link = link
} else {
result.rejectedLinkTarget = link.absoluteString
}
return result
}
private static func alignment(_ column: PresentationIntent.TableColumn) -> MarkdownTable.Alignment
{
switch column.alignment {
case .left: .leading
case .center: .center
case .right: .trailing
@unknown default: .leading
}
}
// MARK: - Task lists
/// Foundation does not parse GFM task syntax, so `- [x] done` reaches us as
/// a list item whose first paragraph literally starts with `[x] `. Strip it
/// back off and report what it said.
private static func takeCheckbox(from blocks: inout [MarkdownBlock]) -> Bool? {
guard case .paragraph(var inlines)? = blocks.first,
let first = inlines.first,
first.style.isEmpty, first.link == nil
else { return nil }
let checked: Bool
if first.text.hasPrefix("[x] ") || first.text.hasPrefix("[X] ") {
checked = true
} else if first.text.hasPrefix("[ ] ") {
checked = false
} else {
return nil
}
var stripped = first
stripped.text = String(first.text.dropFirst(4))
inlines[0] = stripped
blocks[0] = .paragraph(inlines)
return checked
}
}
@@ -0,0 +1,176 @@
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**, and
/// `ConversationModel.composed(_:)` promises the bubble shows exactly what was
/// uploaded. Editing a byte inside one would break that promise, so they are
/// skipped wholesale.
nonisolated enum MarkdownSoftBreaks {
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 == " "
}
}
+40
View File
@@ -138,6 +138,46 @@ nonisolated enum Metrics {
static let height: CGFloat = 22
}
/// The assistant transcript: message bubbles and the Markdown inside them.
///
/// Turns are separated by space rather than by rules, so `turnSpacing` is
/// doing the work a `Divider` used to. That is the whole reason it needs to
/// be noticeably larger than `Space.xl` — at ordinary padding the gap reads
/// as a wrap, not as a change of speaker.
enum Chat {
static let turnSpacing: CGFloat = 20
static let blockSpacing: CGFloat = 10
static let bubbleRadius: CGFloat = 14
/// The narrowest the empty column beside a user bubble may get.
///
/// A fixed gutter rather than a percentage width: it needs no
/// `GeometryReader`, and the bubble still hugs its content instead of
/// being stretched to a share of the column.
static let gutter: CGFloat = 56
/// The same idea at 260pt, where 56 would leave a bubble two words wide.
static let compactGutter: CGFloat = 24
/// The column a list marker sits in, so a wrapped second line aligns
/// under the text rather than under the bullet.
static let markerColumn: CGFloat = 20
static let codeInset: CGFloat = 10
static let tableCellPadding: CGFloat = 6
/// The leading rule on a block quote and on expanded tool output.
static let rule: CGFloat = 2
/// The caret shown while a reply is still arriving.
static let caretWidth: CGFloat = 2
static let caretHeight: CGFloat = 16
/// The draft editor's floor and ceiling.
///
/// `TextEditor` has no content-based ideal height — left alone it takes
/// every point it is offered, so a `maxHeight` alone made an empty
/// composer stand at its maximum with the caret marooned at the top and
/// a hand's width of nothing under it. The height is measured from the
/// draft instead; these two bound the result.
static let composerMinHeight: CGFloat = 40
static let composerMaxHeight: CGFloat = 132
}
enum TransferCenter {
static let width: CGFloat = 360
static let maxListHeight: CGFloat = 420
+31 -1
View File
@@ -25,7 +25,10 @@ nonisolated struct SuggestedCommand: Identifiable, Hashable, Sendable {
/// its data travels by some other route — as the SSH-failure attachment did, by
/// pasting the log into the draft — discloses nothing.
nonisolated struct ContextChip: Identifiable, Hashable, Sendable {
nonisolated enum Kind: Hashable, Sendable {
/// `CaseIterable` so `blockName` can be read backwards: a composed message
/// carries these tags into the transcript, and rendering one as an
/// attachment row means recognising the tag it came from.
nonisolated enum Kind: Hashable, Sendable, CaseIterable {
case host, directory, output, selection, file
var symbol: String {
@@ -80,6 +83,33 @@ nonisolated struct ChatMessage: Identifiable, Hashable, Sendable {
nonisolated struct AgentToolCard: Identifiable, Hashable, Sendable {
nonisolated enum State: String, Hashable, Sendable {
case proposed, waitingForApproval, reviewing, running, completed, denied, failed
/// The transcript used to show `rawValue.capitalized`, which put the
/// case name itself on screen — `WaitingForApproval`. The raw value is
/// a storage key; this is the label.
var displayName: String {
switch self {
case .proposed: String(localized: "Proposed")
case .waitingForApproval: String(localized: "Waiting for approval")
case .reviewing: String(localized: "Reviewing")
case .running: String(localized: "Running")
case .completed: String(localized: "Completed")
case .denied: String(localized: "Denied")
case .failed: String(localized: "Failed")
}
}
/// Whether a card in this state opens itself.
///
/// Anything still in flight, or anything that went wrong, is what the
/// reader is actually looking for. A call that simply worked is noise
/// once it has worked.
var revealsDetailByDefault: Bool {
switch self {
case .waitingForApproval, .reviewing, .running, .denied, .failed: true
case .proposed, .completed: false
}
}
}
var id: UUID { invocation.id }
@@ -0,0 +1,270 @@
import SwiftUI
/// A conversation rendered as chips + transcript + composer.
///
/// Shared by the session Assistant sidebar and full Assistant tab so the two can never drift; only
/// the padding and the provider header differ.
struct ConversationView: View {
let conversation: ConversationModel
var isCompact: Bool = false
/// Forwarded to the composer. Only the sidebar has a focused pane to
/// describe, so only the sidebar passes one.
var sessionContext: SessionContext = .none
var body: some View {
VStack(spacing: 0) {
transcript
if let approval = conversation.pendingApproval {
AgentApprovalCard(request: approval, conversation: conversation)
Divider()
}
if let input = conversation.pendingUserInput {
AgentUserInputCard(request: input, conversation: conversation)
Divider()
}
Divider()
ComposerView(conversation: conversation, sessionContext: sessionContext)
}
}
private var transcript: some View {
ScrollViewReader { proxy in
ScrollView {
// No rules between turns. A bubble on one side and unframed
// prose on the other already separate the speakers, and a line
// through every gap turns a conversation into a ledger.
LazyVStack(alignment: .leading, spacing: Metrics.Chat.turnSpacing) {
if conversation.isEmpty {
emptyState
} else {
ForEach(conversation.timeline) { item in
row(for: item).id(item.id)
}
if conversation.runState == .interrupted {
Label(
"This run was interrupted and was not resumed.",
systemImage: "pause.circle"
)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
// Horizontal padding belongs to the column, not to each turn:
// it is what lines a bubble's trailing edge up with the left
// edge of unframed assistant text.
.padding(.horizontal, isCompact ? Metrics.Space.l : Metrics.Space.xl)
.padding(.vertical, Metrics.Space.xl)
.frame(maxWidth: isCompact ? .infinity : 760)
.frame(maxWidth: .infinity)
}
// Keyed on the last row's revision, not on a count. Streaming text
// grows one message without adding a row, so a count never changed
// and the transcript stopped following the reply it was showing.
.onChange(of: conversation.timeline.last?.revision) {
guard let last = conversation.timeline.last else { return }
withAnimation(Motion.tabSwitch) { proxy.scrollTo(last.id, anchor: .bottom) }
}
}
}
@ViewBuilder
private func row(for item: TimelineItem) -> some View {
switch item {
case .message(let message) where message.role == .user:
UserMessageView(message: message, isCompact: isCompact)
case .message(let message):
AssistantMessageView(message: message, isCompact: isCompact)
case .tool(let card):
AgentToolRowView(card: card)
}
}
private var emptyState: some View {
VStack(alignment: .leading, spacing: Metrics.Space.m) {
Text("Start a Conversation")
.font(.headline)
Text("Ask a question or describe what you want to do.")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, Metrics.Space.xl)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
// MARK: - Tool calls
/// One tool call, as a line the reader can open.
///
/// A filled card per call turns a reply that used four tools into four grey
/// slabs around one paragraph of prose. The state is what people scan for; the
/// arguments and output are what they open when the state surprises them.
private struct AgentToolRowView: View {
let card: AgentToolCard
/// `nil` until the reader has an opinion, after which theirs wins. Before
/// that the row follows its state, so a call that starts running opens and
/// a call that finishes does not slam shut under the pointer.
@State private var userExpanded: Bool?
private var isExpanded: Bool {
userExpanded ?? card.state.revealsDetailByDefault
}
var body: some View {
VStack(alignment: .leading, spacing: Metrics.Space.s) {
Button {
userExpanded = !isExpanded
} label: {
HStack(spacing: Metrics.Space.s) {
Image(systemName: symbol)
.foregroundStyle(tint)
.frame(width: Metrics.Sidebar.rowIcon)
Text(card.descriptor.name)
.font(MarkdownTypography.body)
Spacer(minLength: Metrics.Space.s)
Text(card.state.displayName)
.font(.caption)
.foregroundStyle(.secondary)
Image(systemName: "chevron.right")
.font(.system(size: 8, weight: .bold))
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.foregroundStyle(.tertiary)
}
.contentShape(.rect)
}
.buttonStyle(.plain)
.help(Text(isExpanded ? "Hide details" : "Show details"))
if isExpanded { detail }
}
}
private var detail: some View {
HStack(alignment: .top, spacing: Metrics.Space.m) {
RoundedRectangle(cornerRadius: Metrics.Chat.rule / 2)
.fill(.quaternary)
.frame(width: Metrics.Chat.rule)
VStack(alignment: .leading, spacing: Metrics.Space.s) {
Text(
AgentSensitiveDataRedactor
.redactedJSON(card.invocation.call.arguments).encodedString
)
.font(.caption.monospaced())
.textSelection(.enabled)
.lineLimit(8)
if let review = card.review {
Label(
"Security Review: \(review.risk.rawValue.capitalized) — \(review.reason)",
systemImage: "checkmark.shield"
)
.font(.caption)
.foregroundStyle(.secondary)
}
if let result = card.result {
Text(result.content)
.font(.caption.monospaced())
.foregroundStyle(result.isError ? .red : .secondary)
.textSelection(.enabled)
.lineLimit(12)
if result.isTruncated {
Text("Output truncated")
.font(.caption2)
.foregroundStyle(.orange)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.leading, Metrics.Sidebar.rowIcon)
}
private var symbol: String {
switch card.state {
case .reviewing: "checkmark.shield"
case .running: "gearshape.2"
case .completed: "checkmark.circle.fill"
case .denied, .failed: "xmark.octagon"
default: "wrench.and.screwdriver"
}
}
private var tint: Color {
switch card.state {
case .denied, .failed: .red
case .reviewing, .waitingForApproval: .orange
case .completed: .green
default: .secondary
}
}
}
// MARK: - Decisions
/// Kept as an emphasised card on purpose. This is the one place in the
/// transcript that is waiting on a person, and the de-emphasis the rest of this
/// view got would make it read like history.
private struct AgentApprovalCard: View {
let request: AgentApprovalRequest
let conversation: ConversationModel
var body: some View {
VStack(alignment: .leading, spacing: Metrics.Space.s) {
Label("Approval required", systemImage: "hand.raised.fill").font(.headline)
Text(request.descriptor.name).font(.callout.weight(.semibold))
if let host = request.invocation.targetHostID {
Text("Host: \(host.uuidString)").font(.caption).foregroundStyle(.secondary)
}
Text(AgentSensitiveDataRedactor.redactedJSON(request.invocation.call.arguments).encodedString)
.font(.caption.monospaced()).textSelection(.enabled)
ForEach(request.localReasons, id: \.self) {
Text($0).font(.caption).foregroundStyle(.orange)
}
HStack {
Button("Deny", role: .destructive) { conversation.respondToApproval(allow: false) }
Spacer()
Button("Allow Once") { conversation.respondToApproval(allow: true) }
.buttonStyle(.borderedProminent)
}
}
.padding(Metrics.Space.l)
.background(.orange.opacity(0.08))
}
}
private struct AgentUserInputCard: View {
let request: AgentUserInputRequest
let conversation: ConversationModel
var body: some View {
@Bindable var conversation = conversation
VStack(alignment: .leading, spacing: Metrics.Space.s) {
Text(request.prompt).font(.callout.weight(.medium))
switch request.kind {
case .secret:
SecureField("Secret", text: $conversation.secretInputDraft)
.textFieldStyle(.roundedBorder)
case .choice(let choices):
Picker("Answer", selection: $conversation.userInputDraft) {
Text("Choose…").tag("")
ForEach(choices, id: \.self) { Text($0).tag($0) }
}
case .text:
TextField("Answer", text: $conversation.userInputDraft)
.textFieldStyle(.roundedBorder)
}
HStack {
Button("Cancel") { conversation.cancelUserInput() }
Spacer()
Button("Continue") { conversation.submitUserInput() }
.buttonStyle(.borderedProminent)
}
}
.padding(Metrics.Space.l)
}
}
+56 -62
View File
@@ -115,47 +115,6 @@ struct FlowLayout: Layout {
}
}
// MARK: - Messages
struct MessageView: View {
let message: ChatMessage
var body: some View {
VStack(alignment: .leading, spacing: Metrics.Space.m) {
HStack(spacing: Metrics.Space.s) {
Image(systemName: message.role == .user ? "person.crop.circle" : "sparkles")
.foregroundStyle(message.role == .user ? AnyShapeStyle(.secondary) : AnyShapeStyle(.tint))
Text(message.role == .user ? "You" : "Assistant")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
if message.isStreaming {
ProgressView().controlSize(.mini)
}
}
Text(markdown(message.text))
.font(.callout)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
ForEach(message.commands) { command in
CommandCardView(command: command)
}
}
.padding(.horizontal, Metrics.Space.l)
.padding(.vertical, Metrics.Space.m)
.frame(maxWidth: .infinity, alignment: .leading)
.background(message.role == .user ? AnyShapeStyle(.quaternary.opacity(0.25)) : AnyShapeStyle(.clear))
}
/// Inline markdown only — full block rendering (lists, tables, fenced code)
/// arrives with P3, where streaming makes partial-parse handling necessary
/// anyway.
private func markdown(_ text: String) -> AttributedString {
(try? AttributedString(markdown: text)) ?? AttributedString(text)
}
}
// MARK: - Composer
struct ComposerView: View {
@@ -172,6 +131,7 @@ struct ComposerView: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@FocusState private var isFocused: Bool
@State private var draftBeforeTranscription = ""
@State private var draftHeight = Metrics.Chat.composerMinHeight
private var resolved: (provider: ModelProvider, model: AIModel)? {
app.ai.resolve(conversation: conversation.model)
@@ -223,27 +183,7 @@ struct ComposerView: View {
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
TextEditor(text: $conversation.draft)
.font(.callout)
.scrollContentBackground(.hidden)
.frame(minHeight: 46, maxHeight: 120)
.focused($isFocused)
.overlay(alignment: .topLeading) {
if conversation.draft.isEmpty {
Text(prompt)
.font(.callout)
.foregroundStyle(.tertiary)
.padding(.leading, 5)
.allowsHitTesting(false)
}
}
.onKeyPress(.return, phases: .down) { press in
guard !press.modifiers.contains(.shift) else { return .ignored }
let disallowed: EventModifiers = [.control, .option]
guard press.modifiers.intersection(disallowed).isEmpty else { return .ignored }
sendDraft()
return .handled
}
draftEditor($conversation.draft)
ZStack {
if showsTranscriptionControls {
@@ -283,6 +223,60 @@ struct ComposerView: View {
}
}
/// The draft field, sized by what is in it.
///
/// The height comes from a hidden twin of the draft laid out at its own
/// ideal size, because `TextEditor` will not report one: given a range it
/// takes the top of the range, which is why an empty composer used to be as
/// tall as a five-line one. Measuring a `Text` instead means the box starts
/// at one comfortable line and grows only as the draft does.
private func draftEditor(_ draft: Binding<String>) -> some View {
ZStack(alignment: .topLeading) {
Text(draft.wrappedValue.isEmpty ? " " : draft.wrappedValue)
.font(.body)
.padding(.leading, Self.editorTextInset)
.padding(.vertical, Metrics.Space.xs)
.frame(maxWidth: .infinity, alignment: .leading)
// Ignores the height it is offered and reports its own, which
// is what keeps this out of a measurement loop.
.fixedSize(horizontal: false, vertical: true)
.hidden()
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { draftHeight = $0 }
TextEditor(text: draft)
.font(.body)
.scrollContentBackground(.hidden)
.focused($isFocused)
.overlay(alignment: .topLeading) {
if draft.wrappedValue.isEmpty {
Text(prompt)
.font(.body)
.foregroundStyle(.tertiary)
.padding(.leading, Self.editorTextInset)
.allowsHitTesting(false)
}
}
.onKeyPress(.return, phases: .down) { press in
guard !press.modifiers.contains(.shift) else { return .ignored }
let disallowed: EventModifiers = [.control, .option]
guard press.modifiers.intersection(disallowed).isEmpty else { return .ignored }
sendDraft()
return .handled
}
}
.frame(
height: min(
max(draftHeight, Metrics.Chat.composerMinHeight),
Metrics.Chat.composerMaxHeight
)
)
}
/// `TextEditor`'s own leading text-container inset, which the placeholder
/// and the measuring twin both have to match or the text jumps sideways
/// when the field gains content.
private static let editorTextInset: CGFloat = 5
private var standardControls: some View {
HStack(spacing: Metrics.Space.s) {
ViewThatFits(in: .horizontal) {
@@ -0,0 +1,112 @@
import SwiftUI
/// One attachment recovered from a composed user turn.
///
/// `ConversationModel.composed(_:)` folds each chip into the message body as
/// `<kind label="…">…</kind>` so that what was uploaded is literally what the
/// transcript holds. Foundation's Markdown parser hands that back as a single
/// untouched HTML block — verbatim, down to characters like `*` that emphasis
/// would otherwise eat — which is what makes recognising it here safe.
///
/// The grammar lives on this side of the fence on purpose: the tag vocabulary
/// is `ContextChip.Kind`'s, and `Core/Markdown` must not depend on it.
struct AttachmentBlock: Hashable {
var kind: ContextChip.Kind
var label: String
var content: String
static func parse(_ raw: String) -> AttachmentBlock? {
let lines = raw.components(separatedBy: "\n")
guard
let openIndex = lines.firstIndex(where: { !$0.trimmed.isEmpty }),
let closeIndex = lines.lastIndex(where: { !$0.trimmed.isEmpty }),
openIndex < closeIndex
else { return nil }
let open = lines[openIndex].trimmed
guard open.hasPrefix("<"), open.hasSuffix(">"), !open.hasPrefix("</") else { return nil }
let body = open.dropFirst().dropLast()
guard let separator = body.firstIndex(of: " ") else { return nil }
let tag = String(body[body.startIndex..<separator])
guard lines[closeIndex].trimmed == "</\(tag)>" else { return nil }
guard let kind = ContextChip.Kind.allCases.first(where: { $0.blockName == tag }) else {
return nil
}
let attributes = String(body[body.index(after: separator)...]).trimmed
guard attributes.hasPrefix(#"label=""#), attributes.hasSuffix("\"") else { return nil }
let label = String(attributes.dropFirst(7).dropLast())
return AttachmentBlock(
kind: kind,
label: label,
content: lines[(openIndex + 1)..<closeIndex].joined(separator: "\n")
)
}
}
extension String {
fileprivate var trimmed: String { trimmingCharacters(in: .whitespaces) }
}
/// The folded form of an attachment inside a user bubble.
///
/// Folded rather than laid out in full: a two-hundred-line log printed into a
/// right-aligned bubble is not readable by anyone. The payload is one click
/// away and unaltered, so `composed(_:)`'s promise that the bubble shows what
/// was uploaded still holds.
struct MarkdownAttachmentView: View {
let attachment: AttachmentBlock
@State private var isExpanded = false
@Environment(\.markdownFillsWidth) private var fillsWidth
private var lineCount: Int {
attachment.content.isEmpty ? 0 : attachment.content.components(separatedBy: "\n").count
}
private var sizeLabel: String {
lineCount == 1 ? String(localized: "1 line") : String(localized: "\(lineCount) lines")
}
var body: some View {
VStack(alignment: .leading, spacing: Metrics.Space.s) {
Button {
isExpanded.toggle()
} label: {
HStack(spacing: Metrics.Space.xs) {
Image(systemName: "chevron.right")
.font(.system(size: 8, weight: .bold))
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.foregroundStyle(.tertiary)
Image(systemName: attachment.kind.symbol)
.font(.caption)
Text(attachment.label)
.font(.callout)
// No `textSelection` here, deliberately: a click that
// starts a selection in truncated text drags it out of
// the bubble it belongs to.
.lineLimit(1)
Text(sizeLabel)
.font(.caption)
.foregroundStyle(.secondary)
}
// No trailing `Spacer` — inside a bubble it would be greedy and
// stretch the bubble to the full column for a one-word label.
.markdownWidth(fills: fillsWidth)
.contentShape(.rect)
}
.buttonStyle(.plain)
.help(Text(isExpanded ? "Hide attached content" : "Show attached content"))
if isExpanded {
Text(attachment.content)
.font(MarkdownTypography.monospaced)
.textSelection(.enabled)
.markdownWidth(fills: fillsWidth)
}
}
}
}
@@ -0,0 +1,375 @@
import SwiftUI
/// Parsed blocks for text that has stopped changing.
///
/// Settled messages are the overwhelming majority of a transcript and their
/// text never changes again, so parsing belongs behind a cache rather than in a
/// `body` that a `LazyVStack` re-evaluates on every scroll frame.
///
/// Main-actor isolated by the project's default, which is what makes an
/// unsynchronised dictionary safe here.
enum MarkdownParseCache {
private static var storage: [String: [MarkdownBlock]] = [:]
private static var insertionOrder: [String] = []
/// Enough for any transcript a reader can see at once, and bounded so a
/// long conversation cannot retain every message it ever showed.
private static let limit = 64
static func blocks(for text: String) -> [MarkdownBlock] {
if let cached = storage[text] { return cached }
let parsed = MarkdownDocumentParser.parse(text)
storage[text] = parsed
insertionOrder.append(text)
if insertionOrder.count > limit {
storage.removeValue(forKey: insertionOrder.removeFirst())
}
return parsed
}
}
/// Renders Markdown source.
///
/// Two paths, because a message has two lives. Settled text is parsed
/// synchronously through `MarkdownParseCache`, so a row scrolling back into
/// view draws its blocks on the first frame instead of flashing empty. Text
/// still streaming is parsed on a debounce: `.task(id:)` cancels the previous
/// parse whenever the text changes, so a reply arriving forty tokens a second
/// is parsed when it pauses rather than forty times — which for a growing
/// message would be quadratic. Streamed strings are deliberately not cached;
/// there are hundreds of them and every one is garbage a keystroke later.
///
/// Parsing stays on the main actor: at chat-message sizes it is well under a
/// millisecond, and a detached hop would cost more than it saves.
/// `MarkdownDocumentParser` is `nonisolated` and returns only `Sendable`
/// values, so moving it off is a one-line change if that stops being true.
struct MarkdownView: View {
let text: String
var isStreaming = false
/// Assistant output may offer to run the shell it wrote; text the user
/// typed may not.
var isAssistant = true
var isCompact = false
@State private var streamedBlocks: [MarkdownBlock] = []
private var blocks: [MarkdownBlock] {
isStreaming ? streamedBlocks : MarkdownParseCache.blocks(for: text)
}
var body: some View {
MarkdownBlockListView(blocks: blocks, isAssistant: isAssistant, isCompact: isCompact)
.task(id: text) {
guard isStreaming else { return }
try? await Task.sleep(for: .milliseconds(80))
guard !Task.isCancelled else { return }
streamedBlocks = MarkdownDocumentParser.parse(text)
}
}
}
private struct MarkdownFillsWidthKey: EnvironmentKey {
nonisolated static let defaultValue = true
}
extension EnvironmentValues {
/// Whether Markdown blocks claim all the width they are offered.
///
/// True on the page, where assistant prose *is* the column. False inside a
/// user bubble, which has to shrink to fit a three-word question rather
/// than stretch it across the whole transcript.
///
/// An environment value rather than a parameter because the blocks that
/// need to know are several levels down and not all of them are reached
/// through `MarkdownBlockListView` — code blocks and attachment rows are
/// their own types.
var markdownFillsWidth: Bool {
get { self[MarkdownFillsWidthKey.self] }
set { self[MarkdownFillsWidthKey.self] = newValue }
}
}
extension View {
/// `nil` rather than a branch: a conditional `.frame` would change view
/// identity between the two cases and throw away scroll and selection
/// state, while a nil `maxWidth` simply imposes no constraint.
func markdownWidth(fills: Bool, alignment: Alignment = .leading) -> some View {
frame(maxWidth: fills ? .infinity : nil, alignment: alignment)
}
}
struct MarkdownBlockListView: View {
let blocks: [MarkdownBlock]
var isAssistant = true
var isCompact = false
@Environment(\.markdownFillsWidth) private var fillsWidth
var body: some View {
VStack(alignment: .leading, spacing: Metrics.Chat.blockSpacing) {
// Offset identity, not a stored id. A streaming message is re-parsed
// from scratch, so any minted identifier would change every burst.
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
MarkdownBlockView(block: block, isAssistant: isAssistant, isCompact: isCompact)
}
}
// Set once on the container so every block inherits it and only
// headings and code have to say otherwise.
.font(MarkdownTypography.body)
.markdownWidth(fills: fillsWidth)
}
}
/// The transcript's type scale, in one place because it has to stay monotonic:
/// `.subheadline` is *smaller* than `.body` on macOS, so a heading ladder built
/// by eye ends up with an h3 that reads as a caption.
enum MarkdownTypography {
/// Reading size for prose, tables and list content.
static let body = Font.body
static let monospaced = Font.system(.body, design: .monospaced)
static func heading(level: Int) -> Font {
switch level {
case 1: .title2.weight(.semibold)
case 2: .title3.weight(.semibold)
case 3: .headline
default: body.weight(.semibold)
}
}
}
// MARK: - One block
private struct MarkdownBlockView: View {
let block: MarkdownBlock
let isAssistant: Bool
let isCompact: Bool
@Environment(\.markdownFillsWidth) private var fillsWidth
var body: some View {
switch block {
case .paragraph(let inlines):
MarkdownParagraphView(inlines: inlines)
case .heading(let level, let inlines):
MarkdownParagraphView(inlines: inlines)
.font(MarkdownTypography.heading(level: level))
.padding(.top, Metrics.Space.xs)
case .codeBlock(let language, let code):
MarkdownCodeBlockView(language: language, code: code, isActionable: isAssistant)
case .list(let list):
MarkdownListView(list: list, isAssistant: isAssistant, isCompact: isCompact)
case .table(let table):
MarkdownTableView(table: table)
case .blockQuote(let children):
HStack(alignment: .top, spacing: Metrics.Space.m) {
RoundedRectangle(cornerRadius: Metrics.Chat.rule / 2)
.fill(.quaternary)
.frame(width: Metrics.Chat.rule)
MarkdownBlockListView(
blocks: children, isAssistant: isAssistant, isCompact: isCompact
)
}
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
case .htmlBlock(let raw):
if let attachment = AttachmentBlock.parse(raw) {
MarkdownAttachmentView(attachment: attachment)
} else {
Text(raw)
.font(MarkdownTypography.monospaced)
.foregroundStyle(.secondary)
.textSelection(.enabled)
.markdownWidth(fills: fillsWidth)
}
case .thematicBreak:
Divider().opacity(0.5)
}
}
}
// MARK: - Paragraph
private struct MarkdownParagraphView: View {
let inlines: [MarkdownInline]
@Environment(\.markdownFillsWidth) private var fillsWidth
var body: some View {
Text(MarkdownInlineText.attributed(inlines))
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
.markdownWidth(fills: fillsWidth)
.help(Text(blockedLinkWarning ?? ""))
}
/// A link whose scheme was refused keeps its label as ordinary prose, which
/// is safe but silent. Naming the destinations on hover is what turns
/// "click here" back into something a reader can judge.
private var blockedLinkWarning: String? {
let blocked = inlines.compactMap(\.rejectedLinkTarget)
guard !blocked.isEmpty else { return nil }
return String(localized: "Blocked link: \(blocked.joined(separator: ", "))")
}
}
/// Builds the attributed string a `Text` draws.
///
/// Styles travel as `inlinePresentationIntent` rather than as concrete fonts,
/// because SwiftUI resolves that against whatever font the environment supplies
/// — so a bold run inside a heading stays heading-sized. Setting `.font`
/// directly would pin every span to one size and break inheritance.
enum MarkdownInlineText {
static func attributed(_ inlines: [MarkdownInline]) -> AttributedString {
var result = AttributedString()
for inline in inlines {
var piece = AttributedString(inline.text)
var intent: InlinePresentationIntent = []
if inline.style.contains(.emphasis) { intent.insert(.emphasized) }
if inline.style.contains(.strong) { intent.insert(.stronglyEmphasized) }
if inline.style.contains(.code) { intent.insert(.code) }
if inline.style.contains(.strikethrough) { intent.insert(.strikethrough) }
if !intent.isEmpty { piece.inlinePresentationIntent = intent }
if inline.style.contains(.code) {
piece.backgroundColor = .secondary.opacity(0.15)
}
if let link = inline.link {
piece.link = link
piece.underlineStyle = .single
}
result.append(piece)
}
return result
}
}
// MARK: - Lists
private struct MarkdownListView: View {
let list: MarkdownList
let isAssistant: Bool
let isCompact: Bool
var body: some View {
VStack(alignment: .leading, spacing: Metrics.Space.xs) {
ForEach(Array(list.items.enumerated()), id: \.offset) { index, item in
HStack(alignment: .firstTextBaseline, spacing: Metrics.Space.xs) {
marker(at: index, item: item)
// A fixed column is what makes a wrapped second line sit
// under the text instead of under the bullet — and what
// gives a nested list its indent for free, since the
// nested blocks start after this column.
.frame(width: Metrics.Chat.markerColumn, alignment: .trailing)
MarkdownBlockListView(
blocks: item.blocks, isAssistant: isAssistant, isCompact: isCompact
)
}
}
}
}
@ViewBuilder
private func marker(at index: Int, item: MarkdownList.Item) -> some View {
if let checkbox = item.checkbox {
// Rendered, not interactive: this is a transcript of what was said,
// not a to-do list the reader owns.
Image(systemName: checkbox ? "checkmark.square.fill" : "square")
.font(.caption)
.foregroundStyle(checkbox ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
} else if list.isOrdered {
Text("\(list.start + index).")
.font(MarkdownTypography.body.monospacedDigit())
.foregroundStyle(.secondary)
} else {
Text("•")
.font(MarkdownTypography.body)
.foregroundStyle(.secondary)
}
}
}
// MARK: - Tables
private struct MarkdownTableView: View {
let table: MarkdownTable
private var columnCount: Int {
max(table.alignments.count, table.header.count, table.rows.map(\.count).max() ?? 0)
}
var body: some View {
// No horizontal `ScrollView` around this, deliberately. A scroll view
// proposes unbounded width to its content, and the header `Divider` is
// greedy in width — so the grid grew to that unbounded width and every
// centered or trailing column was aligned to an edge no one can reach.
// The table renders blank. Letting the grid negotiate the real width and
// wrapping the cells is both correct and easier to read than a
// horizontally scrolling region nested inside a vertical one.
Grid(alignment: .topLeading, horizontalSpacing: 0, verticalSpacing: 0) {
if !table.header.isEmpty {
GridRow {
ForEach(columns, id: \.self) { column in
// Set on the header cell, which is what governs the
// whole column for the rows beneath it.
cell(table.header[safe: column])
.font(MarkdownTypography.body.weight(.semibold))
.gridColumnAlignment(alignment(for: column))
}
}
Divider()
}
// No zebra striping. A per-cell background only paints the cell's
// own area, and a centered column's cell is narrower than its
// column — so the stripe came out broken into pieces. The header
// rule is enough separation for a table this size.
ForEach(Array(table.rows.enumerated()), id: \.offset) { _, row in
GridRow {
ForEach(columns, id: \.self) { column in
cell(row[safe: column])
}
}
}
}
.padding(.vertical, Metrics.Chat.tableCellPadding)
.background(.quaternary.opacity(0.12), in: .rect(cornerRadius: Metrics.Radius.small))
}
/// `ForEach` over a live `Range<Int>` is the dynamic-data case SwiftUI warns
/// about, and a ragged table changes this count between renders.
private var columns: [Int] { Array(0..<columnCount) }
private func cell(_ inlines: [MarkdownInline]?) -> some View {
Text(MarkdownInlineText.attributed(inlines ?? []))
.textSelection(.enabled)
// Wrap rather than truncate: with no scroll to escape into, a
// narrow column has to grow downwards or the cell is unreadable.
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, Metrics.Space.m)
.padding(.vertical, Metrics.Chat.tableCellPadding)
}
private func alignment(for column: Int) -> HorizontalAlignment {
switch table.alignments[safe: column] ?? .leading {
case .leading: .leading
case .center: .center
case .trailing: .trailing
}
}
}
extension Array {
/// Tables from a model are frequently ragged — a row with one cell missing
/// is common enough that indexing has to tolerate it rather than trap.
fileprivate subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
@@ -0,0 +1,191 @@
import SwiftUI
/// A fenced code block inside a message.
///
/// A shell fence in assistant output is the one thing this app's transcript can
/// do that a general Markdown renderer cannot: classify it locally and offer to
/// put it at a prompt. Every other language is just code, and gets a copy
/// button.
struct MarkdownCodeBlockView: View {
let language: String?
let code: String
/// Only assistant output offers to run the shell it wrote. A fence the user
/// pasted is their own text; "suggested command" is not what it means.
var isActionable = false
private static let shellLanguages: Set<String> = [
"bash", "sh", "zsh", "shell", "console", "terminal",
]
private var isShell: Bool {
guard let language else { return false }
return Self.shellLanguages.contains(language.lowercased())
}
private var isEmpty: Bool {
code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
@Environment(\.markdownFillsWidth) private var fillsWidth
var body: some View {
if isActionable, isShell, !isEmpty {
ShellCodeBlockView(language: language ?? "", command: code)
} else {
plain
}
}
private var plain: some View {
VStack(alignment: .leading, spacing: Metrics.Space.xs) {
HStack(spacing: Metrics.Space.xs) {
if let language, !language.isEmpty {
Text(language)
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
CopyButton(text: code)
}
Text(code)
.font(MarkdownTypography.monospaced)
.textSelection(.enabled)
.markdownWidth(fills: fillsWidth)
}
.padding(Metrics.Chat.codeInset)
.background(.quaternary.opacity(0.4), in: .rect(cornerRadius: Metrics.Radius.small))
}
}
// MARK: - Shell
private struct ShellCodeBlockView: View {
let language: String
let command: String
@Environment(WindowModel.self) private var window
@State private var isConfirming = false
private var assessment: RiskAssessment { CommandRiskClassifier.assess(command) }
/// Whether this fence may be delivered to a prompt at all.
///
/// `SSHSessionModel.insert` writes raw bytes with no bracketed-paste
/// wrapper, so an embedded newline reaches the PTY as Return. "Insert" on a
/// three-line script would execute the first two lines while presenting
/// itself as a preview — so multi-line fences get the badge and a copy
/// button and nothing that touches the session.
private var isSingleLine: Bool { !command.contains("\n") }
/// The shell this card would reach.
///
/// `selectedTab` is `nil` whenever the pinned Assistant tab is showing, and
/// that is exactly where the full-width transcript lives — so the buttons
/// disable there instead of looking live and doing nothing.
private var target: SSHSessionModel? {
guard let tab = window.selectedTab else { return nil }
return tab.focusedPane.sshSession ?? tab.broadcastTargets.first
}
var body: some View {
VStack(alignment: .leading, spacing: Metrics.Space.m) {
HStack(spacing: Metrics.Space.xs) {
Text(language)
.font(.caption2)
.foregroundStyle(.secondary)
Spacer(minLength: Metrics.Space.s)
RiskBadge(assessment: assessment)
}
Text(command)
.font(MarkdownTypography.monospaced)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
actions
}
.padding(Metrics.Chat.codeInset)
.background(.quaternary.opacity(0.3), in: .rect(cornerRadius: Metrics.Radius.medium))
.overlay {
RoundedRectangle(cornerRadius: Metrics.Radius.medium)
.strokeBorder(assessment.risk == .destructive ? Color.red.opacity(0.4) : .clear)
}
.confirmationDialog(
Text("Run a destructive command?"),
isPresented: $isConfirming,
titleVisibility: .visible
) {
Button("Run \(command)", role: .destructive) { deliver(submit: true) }
Button("Cancel", role: .cancel) {}
} message: {
Text(assessment.reasons.joined(separator: "\n"))
}
}
@ViewBuilder
private var actions: some View {
HStack(spacing: Metrics.Space.s) {
if isSingleLine {
Button("Insert") { deliver(submit: false) }
.disabled(target == nil)
Button("Run") { run() }
.buttonStyle(.borderedProminent)
.tint(assessment.risk == .destructive ? .red : .accentColor)
.disabled(target == nil)
} else {
Text("Several commands — copy and review before running.")
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer(minLength: Metrics.Space.s)
CopyButton(text: command)
}
.controlSize(.small)
.help(Text(target == nil ? "No shell in the foreground" : ""))
}
private func run() {
if assessment.requiresSecondConfirmation {
isConfirming = true
} else {
deliver(submit: true)
}
}
/// Through `Workspace.deliver`, so a card obeys the same broadcast rule as
/// every other programmatic route to a prompt. `submit: false` writes
/// without the newline; the user's own Return is what runs it.
private func deliver(submit: Bool) {
guard let tab = window.selectedTab, let origin = target else { return }
tab.deliver(command, from: origin, submit: submit)
}
}
// MARK: - Copy
/// A copy button that confirms itself for a moment, so a click on a glyph that
/// produces no visible change still reads as having worked.
struct CopyButton: View {
let text: String
@State private var hasCopied = false
var body: some View {
Button {
copyToPasteboard(text)
hasCopied = true
Task {
try? await Task.sleep(for: .seconds(1.4))
hasCopied = false
}
} label: {
Image(systemName: hasCopied ? "checkmark" : "doc.on.doc")
.contentTransition(.symbolEffect(.replace))
}
.buttonStyle(.plain)
.font(.caption)
.foregroundStyle(.secondary)
.help(Text("Copy"))
.accessibilityLabel(Text("Copy"))
}
}
+111
View File
@@ -0,0 +1,111 @@
import SwiftUI
/// A turn the user sent, drawn as a bubble against the trailing edge.
///
/// No avatar and no "You": alignment and fill already say who is speaking, and
/// a label on every turn is a line of furniture repeated down the whole
/// transcript.
struct UserMessageView: View {
let message: ChatMessage
var isCompact = false
var body: some View {
HStack(alignment: .top, spacing: 0) {
// A fixed gutter rather than a percentage of the column: no
// `GeometryReader`, and the bubble hugs its content instead of
// being stretched to a fraction of the width.
Spacer(minLength: isCompact ? Metrics.Chat.compactGutter : Metrics.Chat.gutter)
MarkdownView(text: message.text, isAssistant: false, isCompact: isCompact)
// The bubble is sized by what is in it. Without this the block
// views claim every point they are offered, and "ok" gets a
// bubble as wide as an essay.
.environment(\.markdownFillsWidth, false)
.padding(.horizontal, Metrics.Space.l)
.padding(.vertical, Metrics.Space.m)
.background(
.quaternary.opacity(0.5),
in: .rect(cornerRadius: Metrics.Chat.bubbleRadius)
)
}
.accessibilityElement(children: .contain)
.accessibilityLabel(Text("You said"))
}
}
/// A turn the assistant produced, drawn with no container at all.
///
/// The reply is the page. A card around it would compete with the tool rows and
/// command cards that genuinely are objects, and those need the emphasis more.
struct AssistantMessageView: View {
let message: ChatMessage
var isCompact = false
@State private var isHovering = false
var body: some View {
VStack(alignment: .leading, spacing: Metrics.Chat.blockSpacing) {
if !message.text.isEmpty {
MarkdownView(
text: message.text,
isStreaming: message.isStreaming,
isAssistant: true,
isCompact: isCompact
)
}
if message.isStreaming {
if message.text.isEmpty {
ProgressView()
.controlSize(.mini)
.accessibilityLabel(Text("Thinking"))
} else {
StreamingCaret()
}
} else {
actions
}
ForEach(message.commands) { command in
CommandCardView(command: command)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.onHover { isHovering = $0 }
.accessibilityElement(children: .contain)
.accessibilityLabel(Text("Assistant said"))
}
/// Copying the whole reply, which a stack of block views cannot be framed
/// and dragged over the way a single `Text` could. Paying that back with a
/// button is deliberate — losing it quietly would not be.
private var actions: some View {
HStack(spacing: Metrics.Space.s) {
CopyButton(text: message.text)
Spacer(minLength: 0)
}
.opacity(isHovering ? 1 : 0)
.animation(.easeOut(duration: 0.12), value: isHovering)
}
}
/// The caret that marks a reply still being written.
///
/// Its own view so the blink state stays local and does not invalidate the
/// message body on every pulse.
private struct StreamingCaret: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var isDim = false
var body: some View {
RoundedRectangle(cornerRadius: Metrics.Chat.caretWidth / 2)
.fill(.primary)
.frame(width: Metrics.Chat.caretWidth, height: Metrics.Chat.caretHeight)
.opacity(isDim ? 0.15 : 1)
.onAppear {
guard !reduceMotion else { return }
withAnimation(Motion.statusPulse) { isDim = true }
}
.accessibilityLabel(Text("Still writing"))
}
}
@@ -596,202 +596,6 @@ private struct SSHSessionSnippetRow: View {
}
}
/// A conversation rendered as chips + transcript + composer.
///
/// Shared by the session Assistant sidebar and full Assistant tab so the two can never drift; only
/// the padding and the provider header differ.
struct ConversationView: View {
let conversation: ConversationModel
var isCompact: Bool = false
/// Forwarded to the composer. Only the sidebar has a focused pane to
/// describe, so only the sidebar passes one.
var sessionContext: SessionContext = .none
var body: some View {
VStack(spacing: 0) {
transcript
if let approval = conversation.pendingApproval {
AgentApprovalCard(request: approval, conversation: conversation)
Divider()
}
if let input = conversation.pendingUserInput {
AgentUserInputCard(request: input, conversation: conversation)
Divider()
}
Divider()
ComposerView(conversation: conversation, sessionContext: sessionContext)
}
}
private var transcript: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
if conversation.isEmpty {
emptyState
} else {
ForEach(conversation.timeline) { item in
Group {
switch item {
case .message(let message): MessageView(message: message)
case .tool(let card): AgentToolCardView(card: card)
}
}
.id(item.id)
Divider().opacity(0.4)
}
if conversation.runState == .interrupted {
Label("This run was interrupted and was not resumed.", systemImage: "pause.circle")
.font(.caption)
.foregroundStyle(.secondary)
.padding()
}
}
}
.frame(maxWidth: isCompact ? .infinity : 760)
.frame(maxWidth: .infinity)
}
// Keyed on the last row's revision, not on a count. Streaming text
// grows one message without adding a row, so a count never changed
// and the transcript stopped following the reply it was showing.
.onChange(of: conversation.timeline.last?.revision) {
guard let last = conversation.timeline.last else { return }
withAnimation(Motion.tabSwitch) { proxy.scrollTo(last.id, anchor: .bottom) }
}
}
}
private var emptyState: some View {
VStack(alignment: .leading, spacing: Metrics.Space.m) {
Text("Start a Conversation")
.font(.headline)
Text("Ask a question or describe what you want to do.")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(Metrics.Space.xl)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private struct AgentToolCardView: View {
let card: AgentToolCard
var body: some View {
VStack(alignment: .leading, spacing: Metrics.Space.s) {
HStack {
Image(systemName: symbol).foregroundStyle(tint)
Text(card.descriptor.name).font(.callout.weight(.semibold))
Spacer()
Text(card.state.rawValue.capitalized)
.font(.caption).foregroundStyle(.secondary)
}
Text(AgentSensitiveDataRedactor.redactedJSON(card.invocation.call.arguments).encodedString)
.font(.caption.monospaced()).textSelection(.enabled).lineLimit(8)
if let review = card.review {
Label(
"Security Review: \(review.risk.rawValue.capitalized) — \(review.reason)",
systemImage: "checkmark.shield"
)
.font(.caption).foregroundStyle(.secondary)
}
if let result = card.result {
Text(result.content)
.font(.caption.monospaced())
.foregroundStyle(result.isError ? .red : .secondary)
.textSelection(.enabled).lineLimit(12)
if result.isTruncated {
Text("Output truncated").font(.caption2).foregroundStyle(.orange)
}
}
}
.padding(Metrics.Space.m)
.background(.quaternary.opacity(0.25), in: RoundedRectangle(cornerRadius: Metrics.Radius.medium))
.padding(.horizontal, Metrics.Space.l)
.padding(.vertical, Metrics.Space.s)
}
private var symbol: String {
switch card.state {
case .reviewing: "checkmark.shield"
case .running: "gearshape.2"
case .completed: "checkmark.circle.fill"
case .denied, .failed: "xmark.octagon"
default: "wrench.and.screwdriver"
}
}
private var tint: Color {
switch card.state {
case .denied, .failed: .red
case .reviewing, .waitingForApproval: .orange
case .completed: .green
default: .secondary
}
}
}
private struct AgentApprovalCard: View {
let request: AgentApprovalRequest
let conversation: ConversationModel
var body: some View {
VStack(alignment: .leading, spacing: Metrics.Space.s) {
Label("Approval required", systemImage: "hand.raised.fill").font(.headline)
Text(request.descriptor.name).font(.callout.weight(.semibold))
if let host = request.invocation.targetHostID {
Text("Host: \(host.uuidString)").font(.caption).foregroundStyle(.secondary)
}
Text(AgentSensitiveDataRedactor.redactedJSON(request.invocation.call.arguments).encodedString)
.font(.caption.monospaced()).textSelection(.enabled)
ForEach(request.localReasons, id: \.self) {
Text($0).font(.caption).foregroundStyle(.orange)
}
HStack {
Button("Deny", role: .destructive) { conversation.respondToApproval(allow: false) }
Spacer()
Button("Allow Once") { conversation.respondToApproval(allow: true) }
.buttonStyle(.borderedProminent)
}
}
.padding(Metrics.Space.l)
.background(.orange.opacity(0.08))
}
}
private struct AgentUserInputCard: View {
let request: AgentUserInputRequest
let conversation: ConversationModel
var body: some View {
@Bindable var conversation = conversation
VStack(alignment: .leading, spacing: Metrics.Space.s) {
Text(request.prompt).font(.callout.weight(.medium))
switch request.kind {
case .secret:
SecureField("Secret", text: $conversation.secretInputDraft)
.textFieldStyle(.roundedBorder)
case .choice(let choices):
Picker("Answer", selection: $conversation.userInputDraft) {
Text("Choose…").tag("")
ForEach(choices, id: \.self) { Text($0).tag($0) }
}
case .text:
TextField("Answer", text: $conversation.userInputDraft)
.textFieldStyle(.roundedBorder)
}
HStack {
Button("Cancel") { conversation.cancelUserInput() }
Spacer()
Button("Continue") { conversation.submitUserInput() }
.buttonStyle(.borderedProminent)
}
}
.padding(Metrics.Space.l)
}
}
// MARK: - Details
/// The Sidebar's Details mode describes every selected object without inventing
+295
View File
@@ -0,0 +1,295 @@
import Foundation
import Testing
@testable import Termind
@Suite
struct MarkdownParsingTests {
// MARK: - The reported failure
@Test
func blankLineSeparatedTextBecomesTwoParagraphs() {
let blocks = MarkdownDocumentParser.parse("first\n\nsecond")
#expect(blocks.count == 2)
#expect(paragraphText(blocks.first) == "first")
#expect(paragraphText(blocks.last) == "second")
}
/// Foundation follows CommonMark and turns a single newline into an
/// untagged space, which is indistinguishable from a typed one — so the
/// choice is made on the source, before parsing.
@Test
func newlineInsideAParagraphSurvivesAsALineBreak() {
let blocks = MarkdownDocumentParser.parse("**Note:** this matters\nAnother line here")
#expect(blocks.count == 1)
let text = paragraphText(blocks.first) ?? ""
#expect(text.contains("\n"))
#expect(!text.contains("matters Another"))
}
@Test
func tableCarriesItsColumnAlignments() {
let blocks = MarkdownDocumentParser.parse("| a | b | c |\n|:--|:-:|--:|\n| 1 | 2 | 3 |")
guard case .table(let table)? = blocks.first else {
Issue.record("expected a table, got \(blocks)")
return
}
#expect(table.alignments == [.leading, .center, .trailing])
#expect(table.header.map { $0.map(\.text).joined() } == ["a", "b", "c"])
#expect(table.rows.count == 1)
#expect(table.rows.first?.map { $0.map(\.text).joined() } == ["1", "2", "3"])
}
// MARK: - The premise the grouping algorithm rests on
/// `MarkdownDocumentParser` reverses `components` to turn a run's intent
/// stack into a root-to-leaf path. That reversal is only correct because
/// Foundation orders them innermost-first, which is an observed fact about
/// the framework rather than a guarantee we control.
@Test
func presentationIntentComponentsAreOrderedInnermostFirst() throws {
let attributed = try AttributedString(
markdown: "- item", options: .init(interpretedSyntax: .full)
)
let components = try #require(attributed.runs.first?.presentationIntent?.components)
#expect(components.count == 3)
guard case .paragraph = components[0].kind else {
Issue.record("expected the paragraph innermost, got \(components[0].kind)")
return
}
guard case .listItem = components[1].kind else {
Issue.record("expected the list item next, got \(components[1].kind)")
return
}
guard case .unorderedList = components[2].kind else {
Issue.record("expected the list outermost, got \(components[2].kind)")
return
}
}
// MARK: - Lists
@Test
func orderedListKeepsTheNumberItStartedAt() {
let blocks = MarkdownDocumentParser.parse("3. first\n4. second")
guard case .list(let list)? = blocks.first else {
Issue.record("expected a list, got \(blocks)")
return
}
#expect(list.isOrdered)
#expect(list.start == 3)
#expect(list.items.count == 2)
}
@Test
func nestedListIsABlockInsideItsItem() {
let blocks = MarkdownDocumentParser.parse("1. first\n2. second\n - nested")
guard case .list(let outer)? = blocks.first else {
Issue.record("expected a list, got \(blocks)")
return
}
let second = outer.items[1]
let nested = second.blocks.compactMap { block -> MarkdownList? in
guard case .list(let list) = block else { return nil }
return list
}
#expect(nested.count == 1)
#expect(nested.first?.isOrdered == false)
#expect(paragraphText(nested.first?.items.first?.blocks.first) == "nested")
}
/// Foundation does not parse GFM task syntax, so the marker arrives as
/// literal text at the head of the item and has to be taken back off.
@Test
func taskListMarkersBecomeCheckboxes() {
let blocks = MarkdownDocumentParser.parse("- [x] done\n- [ ] todo")
guard case .list(let list)? = blocks.first else {
Issue.record("expected a list, got \(blocks)")
return
}
#expect(list.items.map(\.checkbox) == [true, false])
#expect(paragraphText(list.items[0].blocks.first) == "done")
#expect(paragraphText(list.items[1].blocks.first) == "todo")
}
// MARK: - Code
@Test
func fencedCodeKeepsItsLanguageAndItsNewlines() {
let blocks = MarkdownDocumentParser.parse("```bash\nls -la\ncd /tmp\n```")
guard case .codeBlock(let language, let code)? = blocks.first else {
Issue.record("expected a code block, got \(blocks)")
return
}
#expect(language == "bash")
#expect(code == "ls -la\ncd /tmp")
}
/// The streaming case: every parse before the last one sees a fence that
/// has been opened and not yet closed.
@Test
func unterminatedFenceStillProducesACodeBlock() {
let blocks = MarkdownDocumentParser.parse("```bash\nls -la")
guard case .codeBlock(let language, let code)? = blocks.first else {
Issue.record("expected a code block, got \(blocks)")
return
}
#expect(language == "bash")
#expect(code == "ls -la")
}
// MARK: - Attachments
/// `ConversationModel.composed(_:)` promises the transcript holds exactly
/// what was uploaded. `*5` is the test: emphasis would eat it.
@Test
func composedAttachmentSurvivesVerbatim() {
let source = """
Why is disk full?
<output label="nginx.log">
[error] 1234#0: *5 open() failed
disk usage: 99%
</output>
"""
let blocks = MarkdownDocumentParser.parse(source)
#expect(blocks.count == 2)
guard case .htmlBlock(let raw)? = blocks.last else {
Issue.record("expected an HTML block, got \(blocks)")
return
}
#expect(raw.contains("*5 open() failed"))
#expect(raw.contains("disk usage: 99%"))
let attachment = AttachmentBlock.parse(raw)
#expect(attachment?.kind == .output)
#expect(attachment?.label == "nginx.log")
#expect(attachment?.content == "[error] 1234#0: *5 open() failed\ndisk usage: 99%")
}
@Test
func htmlThatIsNotAnAttachmentIsLeftAsText() {
let blocks = MarkdownDocumentParser.parse("<script>alert(1)</script>")
guard case .htmlBlock(let raw)? = blocks.first else {
Issue.record("expected an HTML block, got \(blocks)")
return
}
#expect(raw.contains("<script>"))
#expect(AttachmentBlock.parse(raw) == nil)
}
// MARK: - Links and images
@Test
func onlyOpenableSchemesBecomeLinks() {
let allowed = linkedInline("[docs](https://example.com)")
#expect(allowed?.link?.absoluteString == "https://example.com")
#expect(allowed?.rejectedLinkTarget == nil)
}
@Test
func refusedSchemesKeepTheirLabelAsProse() {
for source in ["[click](javascript:alert(1))", "[open](file:///etc/passwd)"] {
let inline = linkedInline(source)
#expect(inline?.link == nil, "\(source) should not be openable")
#expect(inline?.rejectedLinkTarget != nil, "\(source) should report its target")
}
}
/// Foundation drops the URL and keeps the alt text, which closes the
/// channel where a model-chosen image URL discloses the reader's IP and
/// reading time the moment a message renders.
@Test
func imagesLoseTheirURL() {
let blocks = MarkdownDocumentParser.parse("![alt text](https://tracker.test/p.png)")
let rendered = blocks.compactMap(paragraphText).joined()
#expect(rendered.contains("alt text"))
#expect(!rendered.contains("tracker.test"))
}
// MARK: - Other blocks
@Test
func thematicBreakDropsItsPlaceholderGlyph() {
let blocks = MarkdownDocumentParser.parse("above\n\n---\n\nbelow")
#expect(blocks.count == 3)
guard case .thematicBreak = blocks[1] else {
Issue.record("expected a thematic break, got \(blocks[1])")
return
}
}
@Test
func headingAndQuoteKeepTheirShape() {
guard case .heading(let level, let inlines)? = MarkdownDocumentParser.parse("## Title").first
else {
Issue.record("expected a heading")
return
}
#expect(level == 2)
#expect(inlines.map(\.text).joined() == "Title")
guard case .blockQuote(let children)? = MarkdownDocumentParser.parse("> quoted").first else {
Issue.record("expected a block quote")
return
}
#expect(paragraphText(children.first) == "quoted")
}
@Test
func emptySourceProducesNothing() {
#expect(MarkdownDocumentParser.parse("").isEmpty)
}
// MARK: - Soft-break guards
/// Each of these marks a place where appending a hard-break backslash would
/// corrupt the source rather than break a line.
@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
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("\\"))
}
@Test
func hardeningLeavesASingleLineUntouched() {
#expect(MarkdownSoftBreaks.hardened("just one line") == "just one line")
}
// MARK: - Helpers
private func paragraphText(_ block: MarkdownBlock?) -> String? {
guard case .paragraph(let inlines)? = block else { return nil }
return inlines.map(\.text).joined()
}
/// The one inline in `source` that carried a link, allowed or refused.
private func linkedInline(_ source: String) -> MarkdownInline? {
guard case .paragraph(let inlines)? = MarkdownDocumentParser.parse(source).first else {
return nil
}
return inlines.first { $0.link != nil || $0.rejectedLinkTarget != nil }
}
}