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 } + 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? + + + [error] 1234#0: *5 open() failed + disk usage: 99% + + """ + 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("