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:
1 parent
c1af3a189f
commit
cdea8d5448
13 files changed
+2037
-259
No files matched your search
@@ -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 == " "
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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("")
|
||||
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 }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user