diff --git a/Termind/Core/Markdown/MarkdownBlock.swift b/Termind/Core/Markdown/MarkdownBlock.swift
new file mode 100644
index 00000000..c6cafd5b
--- /dev/null
+++ b/Termind/Core/Markdown/MarkdownBlock.swift
@@ -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 ``, 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
+}
diff --git a/Termind/Core/Markdown/MarkdownDocumentParser.swift b/Termind/Core/Markdown/MarkdownDocumentParser.swift
new file mode 100644
index 00000000..375063ce
--- /dev/null
+++ b/Termind/Core/Markdown/MarkdownDocumentParser.swift
@@ -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 = ["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 `` 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
+ }
+}
diff --git a/Termind/Core/Markdown/MarkdownSoftBreaks.swift b/Termind/Core/Markdown/MarkdownSoftBreaks.swift
new file mode 100644
index 00000000..82eb3060
--- /dev/null
+++ b/Termind/Core/Markdown/MarkdownSoftBreaks.swift
@@ -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 == " "
+ }
+}
diff --git a/Termind/Design/DesignTokens.swift b/Termind/Design/DesignTokens.swift
index cbb0d6fd..7c57ee3f 100644
--- a/Termind/Design/DesignTokens.swift
+++ b/Termind/Design/DesignTokens.swift
@@ -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
diff --git a/Termind/Features/AI/ConversationModel.swift b/Termind/Features/AI/ConversationModel.swift
index a20f2afe..6b6d9d24 100644
--- a/Termind/Features/AI/ConversationModel.swift
+++ b/Termind/Features/AI/ConversationModel.swift
@@ -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 }
diff --git a/Termind/Features/AI/ConversationTranscriptView.swift b/Termind/Features/AI/ConversationTranscriptView.swift
new file mode 100644
index 00000000..6d490bea
--- /dev/null
+++ b/Termind/Features/AI/ConversationTranscriptView.swift
@@ -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)
+ }
+}
diff --git a/Termind/Features/AI/ConversationViews.swift b/Termind/Features/AI/ConversationViews.swift
index 29549aa5..ca8c128f 100644
--- a/Termind/Features/AI/ConversationViews.swift
+++ b/Termind/Features/AI/ConversationViews.swift
@@ -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) -> 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) {
diff --git a/Termind/Features/AI/MarkdownAttachmentView.swift b/Termind/Features/AI/MarkdownAttachmentView.swift
new file mode 100644
index 00000000..2b4f4926
--- /dev/null
+++ b/Termind/Features/AI/MarkdownAttachmentView.swift
@@ -0,0 +1,112 @@
+import SwiftUI
+
+/// One attachment recovered from a composed user turn.
+///
+/// `ConversationModel.composed(_:)` folds each chip into the message body as
+/// `…` 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.." 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).. [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` is the dynamic-data case SwiftUI warns
+ /// about, and a ragged table changes this count between renders.
+ private var columns: [Int] { Array(0.. 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
+ }
+}
diff --git a/Termind/Features/AI/MarkdownCodeBlockView.swift b/Termind/Features/AI/MarkdownCodeBlockView.swift
new file mode 100644
index 00000000..5a286570
--- /dev/null
+++ b/Termind/Features/AI/MarkdownCodeBlockView.swift
@@ -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 = [
+ "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"))
+ }
+}
diff --git a/Termind/Features/AI/MessageViews.swift b/Termind/Features/AI/MessageViews.swift
new file mode 100644
index 00000000..f365a596
--- /dev/null
+++ b/Termind/Features/AI/MessageViews.swift
@@ -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"))
+ }
+}
diff --git a/Termind/Features/ContextPanel/ContextPanelView.swift b/Termind/Features/ContextPanel/ContextPanelView.swift
index 254cc9f0..37ea98b6 100644
--- a/Termind/Features/ContextPanel/ContextPanelView.swift
+++ b/Termind/Features/ContextPanel/ContextPanelView.swift
@@ -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
diff --git a/TermindTests/MarkdownParsingTests.swift b/TermindTests/MarkdownParsingTests.swift
new file mode 100644
index 00000000..891f3075
--- /dev/null
+++ b/TermindTests/MarkdownParsingTests.swift
@@ -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?
+
+
+ """
+ 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("")
+ guard case .htmlBlock(let raw)? = blocks.first else {
+ Issue.record("expected an HTML block, got \(blocks)")
+ return
+ }
+ #expect(raw.contains("