diff --git a/CHANGELOG.md b/CHANGELOG.md index 8797a6681..dd583d783 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The MongoDB, Oracle, Cassandra, and Elasticsearch plugins failed to install on 0.58 with "Bundle failed to load executable". (#1917) - Plugin install and load failures now name the real cause (wrong architecture, missing dependency, or incompatibility with this version of TablePro) instead of a generic error. A plugin that fails to load on demand is now reported instead of silently disappearing. (#1915) +### Changed + +- AI Chat renders Markdown while the assistant reply is still streaming, including open fenced code blocks, instead of waiting until the reply finishes. + ## [0.58.0] - 2026-07-18 ### Added diff --git a/TablePro/Views/AIChat/AIChatCodeBlockView.swift b/TablePro/Views/AIChat/AIChatCodeBlockView.swift index 1e1d059d1..8ddb2a3d4 100644 --- a/TablePro/Views/AIChat/AIChatCodeBlockView.swift +++ b/TablePro/Views/AIChat/AIChatCodeBlockView.swift @@ -11,9 +11,12 @@ import SwiftUI struct AIChatCodeBlockView: View, Equatable { let code: String let language: String? + var prefersLightweightRendering: Bool = false static func == (lhs: AIChatCodeBlockView, rhs: AIChatCodeBlockView) -> Bool { - lhs.code == rhs.code && lhs.language == rhs.language + lhs.code == rhs.code + && lhs.language == rhs.language + && lhs.prefersLightweightRendering == rhs.prefersLightweightRendering } @State private var isCopied: Bool = false @@ -26,6 +29,10 @@ struct AIChatCodeBlockView: View, Equatable { focusedActions ?? commandRegistry.current } + private var usesLightweightContent: Bool { + prefersLightweightRendering || !isEditorReady + } + var body: some View { GroupBox { codeContent @@ -33,7 +40,11 @@ struct AIChatCodeBlockView: View, Equatable { codeBlockHeader } .groupBoxStyle(CodeBlockGroupBoxStyle()) - .task { + .task(id: prefersLightweightRendering) { + guard !prefersLightweightRendering else { + isEditorReady = false + return + } isEditorReady = true } .onDisappear { @@ -92,7 +103,16 @@ struct AIChatCodeBlockView: View, Equatable { @ViewBuilder private var codeContent: some View { - if isEditorReady { + if usesLightweightContent { + Text(code.isEmpty ? " " : code) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .frame(minHeight: 32, alignment: .topLeading) + .background(Color(nsColor: .textBackgroundColor)) + } else { SourceEditor( .constant(code), language: treeSitterLanguage, @@ -100,9 +120,6 @@ struct AIChatCodeBlockView: View, Equatable { state: $editorState ) .frame(height: editorHeight) - } else { - Color(nsColor: .textBackgroundColor) - .frame(height: editorHeight) } } diff --git a/TablePro/Views/AIChat/AIChatMessageView.swift b/TablePro/Views/AIChat/AIChatMessageView.swift index 4707cebd1..bd7a792c3 100644 --- a/TablePro/Views/AIChat/AIChatMessageView.swift +++ b/TablePro/Views/AIChat/AIChatMessageView.swift @@ -165,16 +165,8 @@ private struct AIChatBlockView: View { var body: some View { switch block.kind { case .text(let text): - if block.isStreaming { - Text(text) - .font(.body) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 8) - } else { - MarkdownView(source: text) - .padding(.horizontal, 8) - } + MarkdownView(source: text, isStreaming: block.isStreaming) + .padding(.horizontal, 8) case .toolUse(let useBlock): AIChatToolUseBlockView(block: useBlock) case .toolResult(let resultBlock): diff --git a/TablePro/Views/AIChat/MarkdownView.swift b/TablePro/Views/AIChat/MarkdownView.swift index f702266ba..15927b02c 100644 --- a/TablePro/Views/AIChat/MarkdownView.swift +++ b/TablePro/Views/AIChat/MarkdownView.swift @@ -4,6 +4,8 @@ // // Block-level markdown renderer backed by Foundation's AttributedString(markdown:) // for inline formatting and native SwiftUI views for block layout. +// Supports progressive (streaming) sources: an open fenced code block renders as +// lightweight code until its closing fence arrives, then switches to the editor. // import AppKit @@ -11,13 +13,15 @@ import SwiftUI struct MarkdownView: View { let source: String + var isStreaming: Bool = false + @State private var cache = MarkdownDocumentCache() var body: some View { let blocks = cache.blocks(for: source) VStack(alignment: .leading, spacing: 6) { ForEach(blocks) { block in - MarkdownBlockView(block: block) + MarkdownBlockView(block: block, prefersLightweightCode: isStreaming) .equatable() } } @@ -39,9 +43,10 @@ final class MarkdownDocumentCache { private struct MarkdownBlockView: View, Equatable { let block: MarkdownBlock + let prefersLightweightCode: Bool static func == (lhs: Self, rhs: Self) -> Bool { - lhs.block == rhs.block + lhs.block == rhs.block && lhs.prefersLightweightCode == rhs.prefersLightweightCode } var body: some View { @@ -58,13 +63,21 @@ private struct MarkdownBlockView: View, Equatable { .padding(.bottom, 2) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) - case .codeBlock(let code, let language): - AIChatCodeBlockView(code: code, language: language) - .equatable() + case .codeBlock(let code, let language, let isClosed): + AIChatCodeBlockView( + code: code, + language: language, + prefersLightweightRendering: prefersLightweightCode && !isClosed + ) + .equatable() case .unorderedList(let items): - MarkdownListView(items: items, style: .unordered) + MarkdownListView(items: items, style: .unordered, prefersLightweightCode: prefersLightweightCode) case .orderedList(let start, let items): - MarkdownListView(items: items, style: .ordered(start: start)) + MarkdownListView( + items: items, + style: .ordered(start: start), + prefersLightweightCode: prefersLightweightCode + ) case .blockquote(let lines): MarkdownBlockquoteView(lines: lines) case .table(let headers, let alignments, let rows): @@ -94,6 +107,7 @@ private struct MarkdownBlockView: View, Equatable { private struct MarkdownListView: View { let items: [MarkdownListItem] let style: ListStyle + let prefersLightweightCode: Bool enum ListStyle: Equatable { case unordered @@ -113,7 +127,7 @@ private struct MarkdownListView: View { .frame(maxWidth: .infinity, alignment: .leading) if !item.children.isEmpty { ForEach(item.children) { child in - MarkdownBlockView(block: child) + MarkdownBlockView(block: child, prefersLightweightCode: prefersLightweightCode) .equatable() } .padding(.leading, 4) @@ -238,7 +252,7 @@ struct MarkdownBlock: Identifiable, Equatable { enum Kind: Equatable { case paragraph(String) case header(level: Int, text: String) - case codeBlock(code: String, language: String?) + case codeBlock(code: String, language: String?, isClosed: Bool) case unorderedList([MarkdownListItem]) case orderedList(start: Int, items: [MarkdownListItem]) case blockquote(String) @@ -359,21 +373,40 @@ enum MarkdownBlockParser { private static func parseFencedCodeBlock(_ lines: inout [String], index: Int) -> MarkdownBlock { let opener = lines.removeFirst() let trimmedOpener = opener.trimmingCharacters(in: .whitespaces) - let fence = trimmedOpener.hasPrefix("```") ? "```" : "~~~" + let fenceChar: Character = trimmedOpener.hasPrefix("`") ? "`" : "~" + let fenceLength = trimmedOpener.prefix(while: { $0 == fenceChar }).count let language: String? = { - let info = String(trimmedOpener.dropFirst(3)).trimmingCharacters(in: .whitespaces) + let info = String(trimmedOpener.dropFirst(fenceLength)).trimmingCharacters(in: .whitespaces) return info.isEmpty ? nil : info }() var bodyLines: [String] = [] + var isClosed = false while let line = lines.first { - if line.trimmingCharacters(in: .whitespaces).hasPrefix(fence) { + let trimmedLine = line.trimmingCharacters(in: .whitespaces) + if isFencedCodeClose(trimmedLine, fenceChar: fenceChar, fenceLength: fenceLength) { lines.removeFirst() + isClosed = true break } bodyLines.append(line) lines.removeFirst() } - return MarkdownBlock(id: index, kind: .codeBlock(code: bodyLines.joined(separator: "\n"), language: language)) + return MarkdownBlock( + id: index, + kind: .codeBlock( + code: bodyLines.joined(separator: "\n"), + language: language, + isClosed: isClosed + ) + ) + } + + private static func isFencedCodeClose(_ trimmed: String, fenceChar: Character, fenceLength: Int) -> Bool { + guard !trimmed.isEmpty else { return false } + let run = trimmed.prefix(while: { $0 == fenceChar }) + guard run.count >= fenceLength else { return false } + let rest = trimmed.dropFirst(run.count) + return rest.allSatisfy { $0.isWhitespace } } private static func parseBlockquote(_ lines: inout [String], index: Int) -> MarkdownBlock { diff --git a/TableProTests/Views/AIChat/MarkdownBlockParserTests.swift b/TableProTests/Views/AIChat/MarkdownBlockParserTests.swift new file mode 100644 index 000000000..77689223b --- /dev/null +++ b/TableProTests/Views/AIChat/MarkdownBlockParserTests.swift @@ -0,0 +1,244 @@ +// +// MarkdownBlockParserTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("MarkdownBlockParser") +struct MarkdownBlockParserTests { + @Test("Closed fenced code block is marked closed") + func closedFence() { + let source = """ + ```sql + SELECT 1 + ``` + """ + let blocks = MarkdownBlockParser.parse(source) + #expect(blocks.count == 1) + guard case .codeBlock(let code, let language, let isClosed) = blocks[0].kind else { + Issue.record("Expected code block") + return + } + #expect(code == "SELECT 1") + #expect(language == "sql") + #expect(isClosed == true) + } + + @Test("Unclosed fenced code block stays a code block while streaming") + func unclosedFence() { + let source = """ + ```sql + SELECT * FROM users + WHERE id = 1 + """ + let blocks = MarkdownBlockParser.parse(source) + #expect(blocks.count == 1) + guard case .codeBlock(let code, let language, let isClosed) = blocks[0].kind else { + Issue.record("Expected code block") + return + } + #expect(code.contains("SELECT * FROM users")) + #expect(code.contains("WHERE id = 1")) + #expect(language == "sql") + #expect(isClosed == false) + } + + @Test("Unclosed fence with only opener yields empty open code block") + func unclosedFenceOpenerOnly() { + let blocks = MarkdownBlockParser.parse("```sql") + #expect(blocks.count == 1) + guard case .codeBlock(let code, let language, let isClosed) = blocks[0].kind else { + Issue.record("Expected code block") + return + } + #expect(code.isEmpty) + #expect(language == "sql") + #expect(isClosed == false) + } + + @Test("Tilde fences support unclosed streaming") + func unclosedTildeFence() { + let source = """ + ~~~javascript + db.users.find({}) + """ + let blocks = MarkdownBlockParser.parse(source) + #expect(blocks.count == 1) + guard case .codeBlock(let code, let language, let isClosed) = blocks[0].kind else { + Issue.record("Expected code block") + return + } + #expect(code == "db.users.find({})") + #expect(language == "javascript") + #expect(isClosed == false) + } + + @Test("Paragraph before open fence remains a separate block") + func paragraphThenOpenFence() { + let source = """ + Here is the query: + + ```sql + SELECT 1 + """ + let blocks = MarkdownBlockParser.parse(source) + #expect(blocks.count == 2) + guard case .paragraph(let text) = blocks[0].kind else { + Issue.record("Expected paragraph") + return + } + #expect(text.contains("Here is the query")) + guard case .codeBlock(let code, let language, let isClosed) = blocks[1].kind else { + Issue.record("Expected code block") + return + } + #expect(code == "SELECT 1") + #expect(language == "sql") + #expect(isClosed == false) + } + + @Test("Incomplete table header without separator stays a paragraph") + func incompleteTableAsParagraph() { + let source = "| name | age |" + let blocks = MarkdownBlockParser.parse(source) + #expect(blocks.count == 1) + guard case .paragraph(let text) = blocks[0].kind else { + Issue.record("Expected paragraph for incomplete table") + return + } + #expect(text == "| name | age |") + } + + @Test("Complete table still parses with alignments") + func completeTable() { + let source = """ + | name | age | + | :--- | --: | + | Ada | 36 | + """ + let blocks = MarkdownBlockParser.parse(source) + #expect(blocks.count == 1) + guard case .table(let headers, let alignments, let rows) = blocks[0].kind else { + Issue.record("Expected table") + return + } + #expect(headers == ["name", "age"]) + #expect(alignments == [.left, .right]) + #expect(rows.count == 1) + #expect(rows[0] == ["Ada", "36"]) + } + + @Test("Headers and lists parse during partial stream") + func headersAndLists() { + let source = """ + ## Plan + + 1. Index the foreign key + 2. Rewrite the join + - also check nulls + """ + let blocks = MarkdownBlockParser.parse(source) + #expect(blocks.count >= 3) + guard case .header(let level, let text) = blocks[0].kind else { + Issue.record("Expected header") + return + } + #expect(level == 2) + #expect(text == "Plan") + guard case .orderedList(let start, let orderedItems) = blocks[1].kind else { + Issue.record("Expected ordered list") + return + } + #expect(start == 1) + #expect(orderedItems.count == 2) + guard case .unorderedList(let unorderedItems) = blocks[2].kind else { + Issue.record("Expected unordered list") + return + } + #expect(unorderedItems.count == 1) + } + + @Test("Closing a previously open fence marks the block closed") + func fenceClosesOnFinalBackticks() { + let open = MarkdownBlockParser.parse("```\nSELECT 1\n") + guard case .codeBlock(_, _, let openClosed) = open[0].kind else { + Issue.record("Expected open code block") + return + } + #expect(openClosed == false) + + let closed = MarkdownBlockParser.parse("```\nSELECT 1\n```") + guard case .codeBlock(let code, _, let isClosed) = closed[0].kind else { + Issue.record("Expected closed code block") + return + } + #expect(code == "SELECT 1") + #expect(isClosed == true) + } + + @Test("Longer closing fence matches CommonMark minimum length rule") + func longerClosingFence() { + let source = """ + ````sql + SELECT 1 + ```` + """ + let blocks = MarkdownBlockParser.parse(source) + #expect(blocks.count == 1) + guard case .codeBlock(let code, let language, let isClosed) = blocks[0].kind else { + Issue.record("Expected code block") + return + } + #expect(code == "SELECT 1") + #expect(language == "sql") + #expect(isClosed == true) + } + + @Test("Inner shorter fence does not close a longer opener") + func innerFenceDoesNotClose() { + let source = """ + ```` + ``` + still code + ```` + """ + let blocks = MarkdownBlockParser.parse(source) + #expect(blocks.count == 1) + guard case .codeBlock(let code, _, let isClosed) = blocks[0].kind else { + Issue.record("Expected code block") + return + } + #expect(code.contains("```")) + #expect(code.contains("still code")) + #expect(isClosed == true) + } + + @Test("A closed code block stays closed while a later fence streams open") + func closedBlockThenOpenBlockDuringStream() { + let source = """ + ```sql + SELECT 1 + ``` + + Then run: + + ```sql + SELECT 2 + """ + let blocks = MarkdownBlockParser.parse(source) + guard case .codeBlock(_, _, let firstClosed) = blocks.first?.kind else { + Issue.record("Expected a leading code block") + return + } + #expect(firstClosed == true) + guard case .codeBlock(let lastCode, _, let lastClosed) = blocks.last?.kind else { + Issue.record("Expected a trailing code block") + return + } + #expect(lastCode == "SELECT 2") + #expect(lastClosed == false) + } +}