Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 23 additions & 6 deletions TablePro/Views/AIChat/AIChatCodeBlockView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,14 +29,22 @@ struct AIChatCodeBlockView: View, Equatable {
focusedActions ?? commandRegistry.current
}

private var usesLightweightContent: Bool {
prefersLightweightRendering || !isEditorReady
}

var body: some View {
GroupBox {
codeContent
} label: {
codeBlockHeader
}
.groupBoxStyle(CodeBlockGroupBoxStyle())
.task {
.task(id: prefersLightweightRendering) {
guard !prefersLightweightRendering else {
isEditorReady = false
return
}
isEditorReady = true
}
.onDisappear {
Expand Down Expand Up @@ -92,17 +103,23 @@ 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,
configuration: Self.makeConfiguration(),
state: $editorState
)
.frame(height: editorHeight)
} else {
Color(nsColor: .textBackgroundColor)
.frame(height: editorHeight)
}
}

Expand Down
12 changes: 2 additions & 10 deletions TablePro/Views/AIChat/AIChatMessageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
59 changes: 46 additions & 13 deletions TablePro/Views/AIChat/MarkdownView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,24 @@
//
// 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
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()
}
}
Expand All @@ -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 {
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading