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
8 changes: 8 additions & 0 deletions .2119/verdicts/REQ-014.1.1--6ba0c5dca4ec.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"reviewId": "REQ-014.1.1--6ba0c5dca4ec",
"requirementId": "REQ-014.1.1",
"hash": "6ba0c5dca4ec",
"verdict": "pass",
"summary": "Test parses real ChatMarkdownParser output, asserts structural roles/inline attributes for each listed construct and rejects raw delimiters in visible text; genuine behavioral verification, not mocked or tautological.",
"timestamp": "2026-09-15T18:31:46.555Z"
}
8 changes: 8 additions & 0 deletions .2119/verdicts/REQ-014.1.2--335307192b5f.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"reviewId": "REQ-014.1.2--335307192b5f",
"requirementId": "REQ-014.1.2",
"hash": "335307192b5f",
"verdict": "pass",
"summary": "Test exercises real ChatMarkdownParser fallback (plainTextDocument) across representative malformed/incomplete streaming inputs (unterminated header, bold, link, list, code fence) and asserts non-empty visible text; verified the fallback returns raw source verbatim, so the assertion genuinely verifies the requirement without mocking or keyword matching.",
"timestamp": "2026-09-15T18:32:10.343Z"
}
8 changes: 8 additions & 0 deletions .2119/verdicts/REQ-014.2.1--287abd74d88a.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"reviewId": "REQ-014.2.1--287abd74d88a",
"requirementId": "REQ-014.2.1",
"hash": "287abd74d88a",
"verdict": "fail",
"summary": "Test only asserts secondSummary.minY > firstSummary.maxY (non-overlap), which is true for both paragraph-spaced rows and tightly-adjacent lines of one paragraph; it never asserts a gap threshold distinguishing 'distinct paragraph spacing' from ordinary line spacing, so the violating case (summaries rendered as adjacent lines with minimal line-height gap) would still pass.",
"timestamp": "2026-09-15T18:31:42.636Z"
}
8 changes: 8 additions & 0 deletions .2119/verdicts/REQ-014.2.1--8e6775c48711.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"reviewId": "REQ-014.2.1--8e6775c48711",
"requirementId": "REQ-014.2.1",
"hash": "8e6775c48711",
"verdict": "pass",
"summary": "Unit test checks activitySummarySpacing(10)>activityWrappedLineSpacing(0) constants used by the VStack/lineSpacing modifiers, and UI test verifies real rendered frames of two consecutive activity summaries have a positive vertical gap via distinct accessibility identifiers, rejecting both a merged-paragraph violation (identifiers wouldn't exist) and an equal-spacing violation (strict > check); genuine, non-tautological coverage.",
"timestamp": "2026-09-15T18:34:07.296Z"
}
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## 2026-09-15

### Added

- Assistant responses now render common Markdown—including headings, emphasis, links, lists, quotes, inline code, and fenced code—with readable streaming fallbacks and clearer spacing between activity summaries.

### Fixed

Expand Down
18 changes: 12 additions & 6 deletions PiNative.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions PiNative/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1507,6 +1507,37 @@ final class AppModel: ObservableObject {
if environment["PI_NATIVE_TEST_EMPTY_SEEDED_TRANSCRIPT"] == "1" {
return []
}
if environment["PI_NATIVE_TEST_CHAT_FORMATTING_FIXTURE"] == "1" {
let markdown = """
# Markdown that reads naturally

Assistant prose supports *emphasis*, **strong emphasis**, [helpful links](https://example.com), and `inline code`.

- Clear unordered item
- A second item with enough text to demonstrate comfortable wrapping in the readable transcript column

1. First ordered step
2. Second ordered step

> Quoted guidance stays visually distinct from the surrounding answer.

```swift
let greeting = "Hello from PiNative"
print(greeting)
```
"""
let tools = [
ToolTranscriptItem(id: UUID(), callID: "format-read", name: "read", args: #"{"path":"PiNative/PiConversationView.swift"}"#, output: "", status: .succeeded),
ToolTranscriptItem(id: UUID(), callID: "format-edit", name: "edit", args: #"{"path":"PiNative/ChatMarkdownParser.swift"}"#, output: "", status: .succeeded),
ToolTranscriptItem(id: UUID(), callID: "format-test", name: "bash", args: #"{"command":"swift test"}"#, output: "", status: .succeeded)
]
return [
.user(UserMessagePayload(text: title)),
.assistantText(text: markdown),
.assistantText(text: "**Streaming emphasis remains readable"),
.activity(ActivityGroup(id: UUID(), tools: tools, isRunning: false, startedAt: Date(), finishedAt: Date()))
]
}
guard environment["PI_NATIVE_TEST_LONG_TRANSCRIPT"] == "1" else {
return [.user(UserMessagePayload(text: title))]
}
Expand Down
144 changes: 144 additions & 0 deletions PiNative/ChatMarkdownParser.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import Foundation

struct ChatMarkdownDocument {
var blocks: [ChatMarkdownBlock]
}

struct ChatMarkdownBlock {
enum Role: Equatable {
case paragraph
case heading(level: Int)
case code(language: String?)
case thematicBreak
}

enum ListStyle: Equatable {
case ordered
case unordered
}

struct ListContext: Equatable {
var style: ListStyle
var ordinal: Int
var depth: Int
}

var role: Role
var content: AttributedString
var list: ListContext?
var quoteDepth: Int
}

enum ChatMarkdownParser {
static func parse(_ source: String) -> ChatMarkdownDocument {
guard !source.isEmpty else { return ChatMarkdownDocument(blocks: []) }

let parsed: AttributedString
do {
parsed = try AttributedString(
markdown: preservingSoftBreaks(in: source),
options: .init(
interpretedSyntax: .full,
failurePolicy: .returnPartiallyParsedIfPossible
)
)
} catch {
return plainTextDocument(source)
}

let visibleText = String(parsed.characters).trimmingCharacters(in: .whitespacesAndNewlines)
guard !visibleText.isEmpty || source.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return plainTextDocument(source)
}

var blocks: [ChatMarkdownBlock] = []
var currentIdentity: Int?
var currentIntent: PresentationIntent?
var currentContent = AttributedString()

func appendCurrentBlock() {
guard !currentContent.characters.isEmpty else { return }
blocks.append(block(content: currentContent, intent: currentIntent))
}

for run in parsed.runs {
let intent = run.presentationIntent
let identity = intent?.components.first?.identity
if !currentContent.characters.isEmpty, identity != currentIdentity {
appendCurrentBlock()
currentContent = AttributedString()
}
currentIdentity = identity
currentIntent = intent
currentContent.append(AttributedString(parsed[run.range]))
}
appendCurrentBlock()

if blocks.isEmpty, !source.isEmpty {
return plainTextDocument(source)
}
return ChatMarkdownDocument(blocks: blocks)
}

private static func preservingSoftBreaks(in source: String) -> String {
var isInsideFence = false
return source.components(separatedBy: .newlines).map { line in
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("```") {
isInsideFence.toggle()
return line
}
guard !isInsideFence, !trimmed.isEmpty, !line.hasSuffix(" ") else { return line }
return line + " "
}.joined(separator: "\n")
}

private static func block(content: AttributedString, intent: PresentationIntent?) -> ChatMarkdownBlock {
let components = intent?.components ?? []
var role: ChatMarkdownBlock.Role = .paragraph
var listStyle: ChatMarkdownBlock.ListStyle?
var ordinal = 1
var quoteDepth = 0

for component in components {
switch component.kind {
case .header(let level):
role = .heading(level: level)
case .codeBlock(let languageHint):
role = .code(language: languageHint)
case .thematicBreak:
role = .thematicBreak
case .orderedList:
listStyle = .ordered
case .unorderedList:
listStyle = .unordered
case .listItem(let itemOrdinal):
ordinal = itemOrdinal
case .blockQuote:
quoteDepth += 1
default:
break
}
}

let list = listStyle.map {
ChatMarkdownBlock.ListContext(
style: $0,
ordinal: ordinal,
depth: max((intent?.indentationLevel ?? 1) - 1, 0)
)
}
return ChatMarkdownBlock(role: role, content: content, list: list, quoteDepth: quoteDepth)
}

private static func plainTextDocument(_ source: String) -> ChatMarkdownDocument {
ChatMarkdownDocument(
blocks: [ChatMarkdownBlock(role: .paragraph, content: AttributedString(source), list: nil, quoteDepth: 0)]
)
}
}

enum ChatMessageLayout {
static let activityWrappedLineSpacing: CGFloat = 0
static let activitySummarySpacing: CGFloat = 10
}
Loading
Loading