diff --git a/.2119/verdicts/REQ-014.1.1--6ba0c5dca4ec.json b/.2119/verdicts/REQ-014.1.1--6ba0c5dca4ec.json new file mode 100644 index 0000000..90055bc --- /dev/null +++ b/.2119/verdicts/REQ-014.1.1--6ba0c5dca4ec.json @@ -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" +} diff --git a/.2119/verdicts/REQ-014.1.2--335307192b5f.json b/.2119/verdicts/REQ-014.1.2--335307192b5f.json new file mode 100644 index 0000000..202c692 --- /dev/null +++ b/.2119/verdicts/REQ-014.1.2--335307192b5f.json @@ -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" +} diff --git a/.2119/verdicts/REQ-014.2.1--287abd74d88a.json b/.2119/verdicts/REQ-014.2.1--287abd74d88a.json new file mode 100644 index 0000000..0eba867 --- /dev/null +++ b/.2119/verdicts/REQ-014.2.1--287abd74d88a.json @@ -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" +} diff --git a/.2119/verdicts/REQ-014.2.1--8e6775c48711.json b/.2119/verdicts/REQ-014.2.1--8e6775c48711.json new file mode 100644 index 0000000..8b41171 --- /dev/null +++ b/.2119/verdicts/REQ-014.2.1--8e6775c48711.json @@ -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" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d59cd5..e4e19de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/PiNative.xcodeproj/project.pbxproj b/PiNative.xcodeproj/project.pbxproj index 22b6fed..806a6cd 100644 --- a/PiNative.xcodeproj/project.pbxproj +++ b/PiNative.xcodeproj/project.pbxproj @@ -40,6 +40,9 @@ 235685A27DDE01B5D736BB3C /* ExtensionsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39174CAEDA0A24BA7426D974 /* ExtensionsModel.swift */; }; A11111111111111111111111 /* PiConversationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A22222222222222222222222 /* PiConversationModel.swift */; }; A33333333333333333333333 /* PiConversationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A44444444444444444444444 /* PiConversationView.swift */; }; + D14000000000000000000001 /* ChatMarkdownParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = D14000000000000000000002 /* ChatMarkdownParser.swift */; }; + D14000000000000000000003 /* ChatMessageFormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D14000000000000000000004 /* ChatMessageFormattingTests.swift */; }; + D14000000000000000000005 /* ChatMessageFormattingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D14000000000000000000006 /* ChatMessageFormattingUITests.swift */; }; 4A910114936B41909D5C475F /* ResizableDividerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B6CCA67BBC5465080FDEDA3 /* ResizableDividerView.swift */; }; B2CF10EA35FC4681BB02C530 /* ComingSoonPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E66A9D075FC494B888EB403 /* ComingSoonPane.swift */; }; 8DAC23A1E9A942EFB74071DB /* RightPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83244A51E66C4FB7B47C044E /* RightPaneView.swift */; }; @@ -104,6 +107,9 @@ 39174CAEDA0A24BA7426D974 /* ExtensionsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionsModel.swift; sourceTree = ""; }; A22222222222222222222222 /* PiConversationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiConversationModel.swift; sourceTree = ""; }; A44444444444444444444444 /* PiConversationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiConversationView.swift; sourceTree = ""; }; + D14000000000000000000002 /* ChatMarkdownParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMarkdownParser.swift; sourceTree = ""; }; + D14000000000000000000004 /* ChatMessageFormattingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageFormattingTests.swift; sourceTree = ""; }; + D14000000000000000000006 /* ChatMessageFormattingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageFormattingUITests.swift; sourceTree = ""; }; 7B6CCA67BBC5465080FDEDA3 /* ResizableDividerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResizableDividerView.swift; sourceTree = ""; }; 2E66A9D075FC494B888EB403 /* ComingSoonPane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComingSoonPane.swift; sourceTree = ""; }; 83244A51E66C4FB7B47C044E /* RightPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightPaneView.swift; sourceTree = ""; }; @@ -144,9 +150,9 @@ /* Begin PBXGroup section */ E089F906CDE66AC4DE0B4F15 = {isa = PBXGroup; children = (D0E626B11EE2E26B8A53ED03 /* PiNative */, B20000000000000000000005 /* PiNativeTests */, B10000000000000000000005 /* PiNativeUITests */, BD9F9D28A4CE3701DBFE1F16 /* Products */,); sourceTree = ""; }; - D0E626B11EE2E26B8A53ED03 /* PiNative */ = {isa = PBXGroup; children = (A4F4E099E55872B7CD055B17 /* Info.plist */, C84606F68DFF82AAD6188CA7, 5CBE4FCD8A524D79E75AB937, T10000000000000000000002 /* ChatTitleService.swift */, AC1000000000000000000002, P20000000000000000000002 /* PromoteToProject.swift */, M00000000000000000000002 /* ModelSettingsModel.swift */, M00000000000000000000004 /* ModelSettingsView.swift */, M00000000000000000000006 /* SettingsSection.swift */, 39174CAEDA0A24BA7426D974, 639F8CF536299A8279E3BDF3, 4CB86D6D5E6DF695BBFC4648, A22222222222222222222222, A44444444444444444444444, 37BDA846F2D5F231576CC31A, H10000000000000000000002 /* PiHealth.swift */, 6D3854512F688EC89284C386, 2E0FAB1DF804CEE1B91D9B4C, 83244A51E66C4FB7B47C044E, EF1D997813F7495191EA5BAA, 2D3E4F5A6B7C8D9E0F112233, F0A000000000000000000020 /* Resources */, 0AAD256F0C3440739EFB395D /* Components */,); path = PiNative; sourceTree = ""; }; - B20000000000000000000005 /* PiNativeTests */ = {isa = PBXGroup; children = (B20000000000000000000002 /* AttachmentSupportTests.swift */, B20000000000000000000052 /* ComposerPromptHistoryUnitTests.swift */, B20000000000000000000054 /* ComposerPromptHistoryMountedTests.swift */, B20000000000000000000021 /* StopButtonTests.swift */, S70000000000000000000002 /* SteeringDeliveryDeduplicationTests.swift */, S70000000000000000000004 /* SteeringReplayBoundaryTests.swift */, B20000000000000000000031 /* ChatReadinessTests.swift */, H20000000000000000000002 /* PiStartupHealthTests.swift */, B20000000000000000000047 /* ConversationRestorationTests.swift */, B20000000000000000000045 /* AppModelConversationTests.swift */, R1300000000000000000002 /* ArchivedChatsRenderingTests.swift */, B20000000000000000000041 /* PromoteToProjectTests.swift */, B20000000000000000000043 /* ParallelRuntimeTests.swift */, M00000000000000000000008 /* ModelSettingsTests.swift */, T20000000000000000000002 /* ChatTitleTests.swift */,); path = PiNativeTests; sourceTree = ""; }; - B10000000000000000000005 /* PiNativeUITests */ = {isa = PBXGroup; children = (B10000000000000000000002 /* PiNativeUITestCase.swift */, C10000000000000000000002 /* ProjectUITests.swift */, C10000000000000000000004 /* ShellChromeUITests.swift */, C10000000000000000000006 /* NewChatUITests.swift */, C10000000000000000000008 /* PromoteToProjectUITests.swift */, C10000000000000000000010 /* ConversationNavigationUITests.swift */, C10000000000000000000012 /* ChatReadinessUITests.swift */, H30000000000000000000002 /* PiStartupHealthUITests.swift */, C10000000000000000000014 /* ActiveWorkUITests.swift */, C10000000000000000000016 /* ModelSettingsUITests.swift */,); path = PiNativeUITests; sourceTree = ""; }; + D0E626B11EE2E26B8A53ED03 /* PiNative */ = {isa = PBXGroup; children = (A4F4E099E55872B7CD055B17 /* Info.plist */, C84606F68DFF82AAD6188CA7, 5CBE4FCD8A524D79E75AB937, T10000000000000000000002 /* ChatTitleService.swift */, AC1000000000000000000002, P20000000000000000000002 /* PromoteToProject.swift */, M00000000000000000000002 /* ModelSettingsModel.swift */, M00000000000000000000004 /* ModelSettingsView.swift */, M00000000000000000000006 /* SettingsSection.swift */, 39174CAEDA0A24BA7426D974, 639F8CF536299A8279E3BDF3, 4CB86D6D5E6DF695BBFC4648, A22222222222222222222222, A44444444444444444444444, D14000000000000000000002 /* ChatMarkdownParser.swift */, 37BDA846F2D5F231576CC31A, H10000000000000000000002 /* PiHealth.swift */, 6D3854512F688EC89284C386, 2E0FAB1DF804CEE1B91D9B4C, 83244A51E66C4FB7B47C044E, EF1D997813F7495191EA5BAA, 2D3E4F5A6B7C8D9E0F112233, F0A000000000000000000020 /* Resources */, 0AAD256F0C3440739EFB395D /* Components */,); path = PiNative; sourceTree = ""; }; + B20000000000000000000005 /* PiNativeTests */ = {isa = PBXGroup; children = (B20000000000000000000002 /* AttachmentSupportTests.swift */, B20000000000000000000052 /* ComposerPromptHistoryUnitTests.swift */, B20000000000000000000054 /* ComposerPromptHistoryMountedTests.swift */, B20000000000000000000021 /* StopButtonTests.swift */, S70000000000000000000002 /* SteeringDeliveryDeduplicationTests.swift */, S70000000000000000000004 /* SteeringReplayBoundaryTests.swift */, B20000000000000000000031 /* ChatReadinessTests.swift */, H20000000000000000000002 /* PiStartupHealthTests.swift */, B20000000000000000000047 /* ConversationRestorationTests.swift */, B20000000000000000000045 /* AppModelConversationTests.swift */, R1300000000000000000002 /* ArchivedChatsRenderingTests.swift */, B20000000000000000000041 /* PromoteToProjectTests.swift */, B20000000000000000000043 /* ParallelRuntimeTests.swift */, M00000000000000000000008 /* ModelSettingsTests.swift */, T20000000000000000000002 /* ChatTitleTests.swift */, D14000000000000000000004 /* ChatMessageFormattingTests.swift */,); path = PiNativeTests; sourceTree = ""; }; + B10000000000000000000005 /* PiNativeUITests */ = {isa = PBXGroup; children = (B10000000000000000000002 /* PiNativeUITestCase.swift */, C10000000000000000000002 /* ProjectUITests.swift */, C10000000000000000000004 /* ShellChromeUITests.swift */, C10000000000000000000006 /* NewChatUITests.swift */, C10000000000000000000008 /* PromoteToProjectUITests.swift */, C10000000000000000000010 /* ConversationNavigationUITests.swift */, C10000000000000000000012 /* ChatReadinessUITests.swift */, H30000000000000000000002 /* PiStartupHealthUITests.swift */, C10000000000000000000014 /* ActiveWorkUITests.swift */, C10000000000000000000016 /* ModelSettingsUITests.swift */, D14000000000000000000006 /* ChatMessageFormattingUITests.swift */,); path = PiNativeUITests; sourceTree = ""; }; 0AAD256F0C3440739EFB395D /* Components */ = {isa = PBXGroup; children = (7B6CCA67BBC5465080FDEDA3 /* ResizableDividerView.swift */, 2E66A9D075FC494B888EB403 /* ComingSoonPane.swift */, 9A2222222222222222222222 /* NewChatStartView.swift */, 9A4444444444444444444444 /* WindowChromeConfigurator.swift */, 9A6666666666666666666666 /* MapleFont.swift */,); path = Components; sourceTree = ""; }; F0A000000000000000000020 /* Resources */ = {isa = PBXGroup; children = (AA1000000000000000000002 /* Assets.xcassets */, F0A000000000000000000021 /* Fonts */,); path = Resources; sourceTree = ""; }; F0A000000000000000000021 /* Fonts */ = {isa = PBXGroup; children = (F0A000000000000000000011 /* MapleMono-NF-Regular.ttf */, F0A000000000000000000012 /* MapleMono-NF-Bold.ttf */, F0A000000000000000000013 /* MapleMono-NF-Italic.ttf */, F0A000000000000000000014 /* MapleMono-NF-BoldItalic.ttf */,); path = Fonts; sourceTree = ""; }; @@ -168,9 +174,9 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - B20000000000000000000009 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B20000000000000000000001 /* AttachmentSupportTests.swift in Sources */, PH20000000000000000000001 /* AnalyticsTests.swift in Sources */, B20000000000000000000051 /* ComposerPromptHistoryUnitTests.swift in Sources */, B20000000000000000000053 /* ComposerPromptHistoryMountedTests.swift in Sources */, B20000000000000000000020 /* StopButtonTests.swift in Sources */, S70000000000000000000001 /* SteeringDeliveryDeduplicationTests.swift in Sources */, S70000000000000000000003 /* SteeringReplayBoundaryTests.swift in Sources */, B20000000000000000000030 /* ChatReadinessTests.swift in Sources */, H20000000000000000000001 /* PiStartupHealthTests.swift in Sources */, B20000000000000000000046 /* ConversationRestorationTests.swift in Sources */, B20000000000000000000044 /* AppModelConversationTests.swift in Sources */, R1300000000000000000001 /* ArchivedChatsRenderingTests.swift in Sources */, B20000000000000000000040 /* PromoteToProjectTests.swift in Sources */, B20000000000000000000042 /* ParallelRuntimeTests.swift in Sources */, M00000000000000000000007 /* ModelSettingsTests.swift in Sources */, T20000000000000000000001 /* ChatTitleTests.swift in Sources */,); runOnlyForDeploymentPostprocessing = 0; }; - B10000000000000000000009 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B10000000000000000000001 /* PiNativeUITestCase.swift in Sources */, C10000000000000000000001 /* ProjectUITests.swift in Sources */, C10000000000000000000003 /* ShellChromeUITests.swift in Sources */, C10000000000000000000005 /* NewChatUITests.swift in Sources */, C10000000000000000000007 /* PromoteToProjectUITests.swift in Sources */, C10000000000000000000009 /* ConversationNavigationUITests.swift in Sources */, C10000000000000000000011 /* ChatReadinessUITests.swift in Sources */, H30000000000000000000001 /* PiStartupHealthUITests.swift in Sources */, C10000000000000000000013 /* ActiveWorkUITests.swift in Sources */, C10000000000000000000015 /* ModelSettingsUITests.swift in Sources */,); runOnlyForDeploymentPostprocessing = 0; }; - 3B7F5E0625A26093DFD7689A /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (C91A821BBD8FC19135EFCBCA, PH10000000000000000000001 /* Analytics.swift in Sources */, 94A8D39EFF7FB95895DA2429, AC1000000000000000000001, P20000000000000000000001 /* PromoteToProject.swift in Sources */, 235685A27DDE01B5D736BB3C, B6B0755D2911C382985AF503, 0A05B88A079DBE985586E196, A11111111111111111111111, A33333333333333333333333, F9929FC807AAF9F82196B366, H10000000000000000000001 /* PiHealth.swift in Sources */, 48688F523F9A83A0E9442735, 5276C82553DEF1BA0318E7F2, 4A910114936B41909D5C475F, B2CF10EA35FC4681BB02C530, 8DAC23A1E9A942EFB74071DB, C8BCB535833E41F6AD37358B, 9A1111111111111111111111, 9A3333333333333333333333, 9A5555555555555555555555, 1D2E3F4A5B6C7D8E9F001122, M00000000000000000000001 /* ModelSettingsModel.swift in Sources */, M00000000000000000000003 /* ModelSettingsView.swift in Sources */, M00000000000000000000005 /* SettingsSection.swift in Sources */, T10000000000000000000001 /* ChatTitleService.swift in Sources */,); runOnlyForDeploymentPostprocessing = 0; }; + B20000000000000000000009 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B20000000000000000000001 /* AttachmentSupportTests.swift in Sources */, PH20000000000000000000001 /* AnalyticsTests.swift in Sources */, B20000000000000000000051 /* ComposerPromptHistoryUnitTests.swift in Sources */, B20000000000000000000053 /* ComposerPromptHistoryMountedTests.swift in Sources */, B20000000000000000000020 /* StopButtonTests.swift in Sources */, S70000000000000000000001 /* SteeringDeliveryDeduplicationTests.swift in Sources */, S70000000000000000000003 /* SteeringReplayBoundaryTests.swift in Sources */, B20000000000000000000030 /* ChatReadinessTests.swift in Sources */, H20000000000000000000001 /* PiStartupHealthTests.swift in Sources */, B20000000000000000000046 /* ConversationRestorationTests.swift in Sources */, B20000000000000000000044 /* AppModelConversationTests.swift in Sources */, R1300000000000000000001 /* ArchivedChatsRenderingTests.swift in Sources */, B20000000000000000000040 /* PromoteToProjectTests.swift in Sources */, B20000000000000000000042 /* ParallelRuntimeTests.swift in Sources */, M00000000000000000000007 /* ModelSettingsTests.swift in Sources */, T20000000000000000000001 /* ChatTitleTests.swift in Sources */, D14000000000000000000003 /* ChatMessageFormattingTests.swift in Sources */,); runOnlyForDeploymentPostprocessing = 0; }; + B10000000000000000000009 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B10000000000000000000001 /* PiNativeUITestCase.swift in Sources */, C10000000000000000000001 /* ProjectUITests.swift in Sources */, C10000000000000000000003 /* ShellChromeUITests.swift in Sources */, C10000000000000000000005 /* NewChatUITests.swift in Sources */, C10000000000000000000007 /* PromoteToProjectUITests.swift in Sources */, C10000000000000000000009 /* ConversationNavigationUITests.swift in Sources */, C10000000000000000000011 /* ChatReadinessUITests.swift in Sources */, H30000000000000000000001 /* PiStartupHealthUITests.swift in Sources */, C10000000000000000000013 /* ActiveWorkUITests.swift in Sources */, C10000000000000000000015 /* ModelSettingsUITests.swift in Sources */, D14000000000000000000005 /* ChatMessageFormattingUITests.swift in Sources */,); runOnlyForDeploymentPostprocessing = 0; }; + 3B7F5E0625A26093DFD7689A /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (C91A821BBD8FC19135EFCBCA, PH10000000000000000000001 /* Analytics.swift in Sources */, 94A8D39EFF7FB95895DA2429, AC1000000000000000000001, P20000000000000000000001 /* PromoteToProject.swift in Sources */, 235685A27DDE01B5D736BB3C, B6B0755D2911C382985AF503, 0A05B88A079DBE985586E196, A11111111111111111111111, A33333333333333333333333, F9929FC807AAF9F82196B366, H10000000000000000000001 /* PiHealth.swift in Sources */, 48688F523F9A83A0E9442735, 5276C82553DEF1BA0318E7F2, 4A910114936B41909D5C475F, B2CF10EA35FC4681BB02C530, 8DAC23A1E9A942EFB74071DB, C8BCB535833E41F6AD37358B, 9A1111111111111111111111, 9A3333333333333333333333, 9A5555555555555555555555, 1D2E3F4A5B6C7D8E9F001122, M00000000000000000000001 /* ModelSettingsModel.swift in Sources */, M00000000000000000000003 /* ModelSettingsView.swift in Sources */, M00000000000000000000005 /* SettingsSection.swift in Sources */, T10000000000000000000001 /* ChatTitleService.swift in Sources */, D14000000000000000000001 /* ChatMarkdownParser.swift in Sources */,); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ diff --git a/PiNative/AppModel.swift b/PiNative/AppModel.swift index 03f4fb8..e81974b 100644 --- a/PiNative/AppModel.swift +++ b/PiNative/AppModel.swift @@ -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))] } diff --git a/PiNative/ChatMarkdownParser.swift b/PiNative/ChatMarkdownParser.swift new file mode 100644 index 0000000..904c45f --- /dev/null +++ b/PiNative/ChatMarkdownParser.swift @@ -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 +} diff --git a/PiNative/PiConversationView.swift b/PiNative/PiConversationView.swift index 7db77fe..5ede55c 100644 --- a/PiNative/PiConversationView.swift +++ b/PiNative/PiConversationView.swift @@ -11,11 +11,15 @@ private enum ChatTypography { static let subheadlineSize: CGFloat = 15 static let title3Size: CGFloat = 20 - static func serifBody(weight: Font.Weight = .regular, italic: Bool = false) -> Font { - let base = Font.custom("Georgia", size: bodySize) + static func serif(size: CGFloat, weight: Font.Weight = .regular, italic: Bool = false) -> Font { + let base = Font.custom("Georgia", size: size) return italic ? base.weight(weight).italic() : base.weight(weight) } + static func serifBody(weight: Font.Weight = .regular, italic: Bool = false) -> Font { + serif(size: bodySize, weight: weight, italic: italic) + } + static func serifNSFont(size: CGFloat = bodySize) -> NSFont { NSFont(name: "Georgia", size: size) ?? NSFont.systemFont(ofSize: size) } @@ -86,6 +90,11 @@ private enum ChatPalette { light: NSColor(calibratedWhite: 0.14, alpha: 1), dark: NSColor(calibratedWhite: 1.0, alpha: 0.82) ) + static let quoteRule = AppTheme.dynamicColor( + light: NSColor.separatorColor, + dark: NSColor.separatorColor + ) + static let linkText = Color.accentColor static let nsPrimaryText = AppTheme.dynamicNSColor( light: NSColor(calibratedWhite: 0.12, alpha: 1), dark: NSColor(calibratedWhite: 0.84, alpha: 1) @@ -955,7 +964,7 @@ private struct AssistantTextBlock: View { let onCopy: () -> Void var body: some View { - MarkdownishAssistantText(text: text) + MarkdownAssistantText(text: text) .accessibilityIdentifier("transcript.assistantMessage") .frame(maxWidth: 760, alignment: .leading) } @@ -971,16 +980,18 @@ private struct ActivityGroupRow: View { let onToggle: () -> Void var body: some View { - VStack(alignment: .leading, spacing: 3) { - ForEach(summaryLines, id: \.self) { line in + VStack(alignment: .leading, spacing: ChatMessageLayout.activitySummarySpacing) { + ForEach(Array(summaryLines.enumerated()), id: \.offset) { offset, line in Text(line) .font(MapleFont.swiftUIFont(size: 13, weight: .semibold, italic: true)) .foregroundStyle(.secondary) + .lineSpacing(ChatMessageLayout.activityWrappedLineSpacing) .lineLimit(2) + .accessibilityIdentifier("transcript.activitySummary.\(offset)") } } .frame(maxWidth: 760, alignment: .leading) - .accessibilityElement(children: .combine) + .accessibilityElement(children: .contain) } private var summaryLines: [String] { @@ -1143,105 +1154,107 @@ private struct SpinningGearIcon: View { } } -private struct MarkdownishAssistantText: View { +private struct MarkdownAssistantText: View { let text: String + private var document: ChatMarkdownDocument { + ChatMarkdownParser.parse(text) + } + var body: some View { - VStack(alignment: .leading, spacing: 8) { - ForEach(Array(Self.blocks(from: text).enumerated()), id: \.offset) { _, block in - switch block { - case .text(let value): - InlineCodeText(text: value) - .textSelection(.enabled) - case .code(let language, let value): - CodeBlockView(language: language, text: value) - } + VStack(alignment: .leading, spacing: 10) { + ForEach(Array(document.blocks.enumerated()), id: \.offset) { _, block in + blockView(block) } } } - private enum Block { - case text(String) - case code(language: String?, text: String) - } - - private static func blocks(from text: String) -> [Block] { - var blocks: [Block] = [] - var prose: [String] = [] - var code: [String] = [] - var language: String? - var inFence = false - var lastFenceBecameInline = false - - for line in text.components(separatedBy: .newlines) { - if line.trimmingCharacters(in: .whitespaces).hasPrefix("```") { - if inFence { - lastFenceBecameInline = appendCodeBlock(lines: code, language: language, to: &blocks) - code.removeAll() - language = nil - inFence = false - } else { - if !prose.isEmpty { - appendProse(prose, mergingWithPrevious: lastFenceBecameInline, to: &blocks) - prose.removeAll() - lastFenceBecameInline = false - } - let marker = line.trimmingCharacters(in: .whitespaces) - let rawLanguage = String(marker.dropFirst(3)).trimmingCharacters(in: .whitespaces) - language = rawLanguage.isEmpty ? nil : rawLanguage - inFence = true - } - } else if inFence { - code.append(line) - } else { - prose.append(line) + @ViewBuilder + private func blockView(_ block: ChatMarkdownBlock) -> some View { + if let list = block.list { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(list.style == .ordered ? "\(list.ordinal)." : "•") + .font(ChatTypography.serifBody(weight: .semibold)) + .frame(width: 24, alignment: .trailing) + quotedContent(block) } + .padding(.leading, CGFloat(list.depth) * 18) + } else { + quotedContent(block) } + } - if inFence { - _ = appendCodeBlock(lines: code, language: language, to: &blocks) - } else if !prose.isEmpty { - appendProse(prose, mergingWithPrevious: lastFenceBecameInline, to: &blocks) + @ViewBuilder + private func quotedContent(_ block: ChatMarkdownBlock) -> some View { + if block.quoteDepth > 0 { + HStack(alignment: .top, spacing: 10) { + RoundedRectangle(cornerRadius: 1) + .fill(ChatPalette.quoteRule) + .frame(width: 3) + roleContent(block) + } + .padding(.leading, CGFloat(max(block.quoteDepth - 1, 0)) * 14) + } else { + roleContent(block) } + } - return blocks + @ViewBuilder + private func roleContent(_ block: ChatMarkdownBlock) -> some View { + switch block.role { + case .paragraph: + markdownText(block.content, font: ChatTypography.serifBody()) + case .heading(let level): + markdownText(block.content, font: headingFont(level: level)) + .padding(.top, level <= 2 ? 4 : 0) + case .code(let language): + CodeBlockView( + language: language, + text: String(block.content.characters).trimmingCharacters(in: .newlines) + ) + case .thematicBreak: + Divider() + .padding(.vertical, 4) + } } - private static func appendProse(_ lines: [String], mergingWithPrevious: Bool, to blocks: inout [Block]) { - let text = lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return } + private func markdownText(_ content: AttributedString, font: Font) -> some View { + Text(Self.styledInlineContent(content, proseFont: font)) + .lineSpacing(2) + .foregroundStyle(ChatPalette.primaryText) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } - if mergingWithPrevious, case .text(let previous) = blocks.last { - blocks.removeLast() - blocks.append(.text(previous.trimmingCharacters(in: .whitespacesAndNewlines) + " " + text)) - } else { - blocks.append(.text(text)) + private func headingFont(level: Int) -> Font { + switch level { + case 1: ChatTypography.serif(size: 26, weight: .bold).leading(.tight) + case 2: ChatTypography.serif(size: 22, weight: .semibold) + case 3: ChatTypography.serif(size: 19, weight: .semibold) + default: ChatTypography.serifBody(weight: .semibold) } } - @discardableResult - private static func appendCodeBlock(lines: [String], language: String?, to blocks: inout [Block]) -> Bool { - let text = lines.joined(separator: "\n") - let nonEmptyLines = lines.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } - let languageIsPlainText = language == nil || language?.lowercased() == "text" - - // Pi often emits single path / command snippets as ```text fenced blocks. - // Those are references, not real terminal/code blocks, so keep them in - // the inline-code visual language instead of promoting them to a large - // green container. - if languageIsPlainText, nonEmptyLines.count == 1, let line = nonEmptyLines.first { - let inline = "`\(line)`" - if case .text(let previous) = blocks.last { - blocks.removeLast() - blocks.append(.text(previous.trimmingCharacters(in: .whitespacesAndNewlines) + " " + inline)) + private static func styledInlineContent(_ content: AttributedString, proseFont: Font) -> AttributedString { + var styled = content + for run in styled.runs { + let intent = run.inlinePresentationIntent ?? [] + if run.link != nil { + styled[run.range].foregroundColor = ChatPalette.linkText + } + if intent.contains(.code) { + styled[run.range].font = MapleFont.swiftUIFont(size: ChatTypography.bodySize - 1) } else { - blocks.append(.text(inline)) + styled[run.range].font = proseFont + if intent.contains(.stronglyEmphasized) { + styled[run.range].font = proseFont.bold() + } + if intent.contains(.emphasized) { + styled[run.range].font = (styled[run.range].font ?? proseFont).italic() + } } - return true - } else if !text.isEmpty { - blocks.append(.code(language: language, text: text)) } - return false + return styled } } diff --git a/PiNativeTests/ChatMessageFormattingTests.swift b/PiNativeTests/ChatMessageFormattingTests.swift new file mode 100644 index 0000000..8d8aa49 --- /dev/null +++ b/PiNativeTests/ChatMessageFormattingTests.swift @@ -0,0 +1,75 @@ +import Foundation +import XCTest +@testable import PiNative + +final class ChatMessageFormattingTests: XCTestCase { + // 2119: REQ-014.1.1 + func testCommonMarkdownProducesStructuredBlocksAndInlineSemanticsWithoutDelimiters() throws { + let source = """ + # Heading + + First paragraph with *emphasis*, **strong emphasis**, [a link](https://example.com), and `inline code`. + + Second paragraph. + + - Unordered item + + 1. Ordered item + + > Quoted guidance + + ```swift + let answer = 42 + ``` + """ + + let document = ChatMarkdownParser.parse(source) + let visibleText = document.blocks.map { String($0.content.characters) }.joined(separator: "\n") + + XCTAssertTrue(document.blocks.contains { $0.role == .heading(level: 1) && String($0.content.characters) == "Heading" }) + XCTAssertGreaterThanOrEqual(document.blocks.filter { $0.role == .paragraph && $0.list == nil && $0.quoteDepth == 0 }.count, 2) + XCTAssertTrue(document.blocks.contains { $0.list?.style == .unordered && String($0.content.characters) == "Unordered item" }) + XCTAssertTrue(document.blocks.contains { $0.list?.style == .ordered && $0.list?.ordinal == 1 && String($0.content.characters) == "Ordered item" }) + XCTAssertTrue(document.blocks.contains { $0.quoteDepth == 1 && String($0.content.characters) == "Quoted guidance" }) + XCTAssertTrue(document.blocks.contains { $0.role == .code(language: "swift") && String($0.content.characters).contains("let answer = 42") }) + + let runs = document.blocks.flatMap { Array($0.content.runs) } + XCTAssertTrue(runs.contains { $0.inlinePresentationIntent?.contains(.emphasized) == true }) + XCTAssertTrue(runs.contains { $0.inlinePresentationIntent?.contains(.stronglyEmphasized) == true }) + XCTAssertTrue(runs.contains { $0.inlinePresentationIntent?.contains(.code) == true }) + XCTAssertTrue(runs.contains { $0.link?.absoluteString == "https://example.com" }) + for delimiter in ["# Heading", "*emphasis*", "**strong emphasis**", "[a link]", "`inline code`", "```swift"] { + XCTAssertFalse(visibleText.contains(delimiter), "Rendered content retained Markdown delimiter: \(delimiter)") + } + } + + func testPlainMultilineAssistantTextPreservesLineBreaks() { + let source = (1...60).map { "Streaming response line \($0)" }.joined(separator: "\n") + + let document = ChatMarkdownParser.parse(source) + + XCTAssertEqual(String(document.blocks.first?.content.characters ?? AttributedString().characters), source) + } + + // 2119: REQ-014.2.1 + func testActivitySummaryParagraphSpacingExceedsWrappedLineSpacing() { + XCTAssertGreaterThan( + ChatMessageLayout.activitySummarySpacing, + ChatMessageLayout.activityWrappedLineSpacing, + "Separate activity summaries must use more spacing than wrapped lines within one summary." + ) + } + + // 2119: REQ-014.1.2 + func testIncompleteStreamingMarkdownAlwaysProducesReadableVisibleContent() { + for source in ["#", "**streaming emphasis", "[link](", "-", "```swift\nlet value = 1"] { + let document = ChatMarkdownParser.parse(source) + let visibleText = document.blocks + .map { String($0.content.characters) } + .joined() + .trimmingCharacters(in: .whitespacesAndNewlines) + + XCTAssertFalse(visibleText.isEmpty, "Streaming prefix disappeared: \(source.debugDescription)") + } + } +} diff --git a/PiNativeUITests/ChatMessageFormattingUITests.swift b/PiNativeUITests/ChatMessageFormattingUITests.swift new file mode 100644 index 0000000..b7312d0 --- /dev/null +++ b/PiNativeUITests/ChatMessageFormattingUITests.swift @@ -0,0 +1,33 @@ +import XCTest + +final class ChatMessageFormattingUITests: PiNativeUITestCase { + // 2119: REQ-014.2.1 + func testConsecutiveActivitySummariesRenderAsSeparatedRows() throws { + let projectURL = FileManager.default.temporaryDirectory + .appendingPathComponent("PiNativeChatFormatting-") + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: projectURL, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: projectURL) } + + let app = XCUIApplication() + app.launchArguments += ["-ApplePersistenceIgnoreState", "YES"] + app.launchEnvironment["PI_NATIVE_RESET_PROJECTS"] = "1" + app.launchEnvironment["PI_NATIVE_TEST_PROJECT_PATH"] = projectURL.path + app.launchEnvironment["PI_NATIVE_TEST_SEEDED_CHAT_TITLE"] = "chat formatting fixture" + app.launchEnvironment["PI_NATIVE_TEST_CHAT_FORMATTING_FIXTURE"] = "1" + app.launchEnvironment["PI_NATIVE_TEST_RPC_STALL"] = "1" + app.launch() + + XCTAssertTrue(app.staticTexts["chat formatting fixture"].firstMatch.waitForExistence(timeout: 10)) + let firstSummary = app.staticTexts["transcript.activitySummary.0"].firstMatch + let secondSummary = app.staticTexts["transcript.activitySummary.1"].firstMatch + XCTAssertTrue(firstSummary.waitForExistence(timeout: 5)) + XCTAssertTrue(secondSummary.waitForExistence(timeout: 5)) + + XCTAssertGreaterThan( + secondSummary.frame.minY, + firstSummary.frame.maxY, + "Consecutive activity summaries should have visible vertical space between their rows." + ) + } +} diff --git a/scripts/test-ui-related.sh b/scripts/test-ui-related.sh index d9d2e27..6e61718 100755 --- a/scripts/test-ui-related.sh +++ b/scripts/test-ui-related.sh @@ -94,9 +94,16 @@ while IFS= read -r file; do PiNative/ChatPaneView.swift) add_test "PiNativeUITests/ChatReadinessUITests" ;; - PiNative/PiConversationModel.swift|PiNative/PiConversationView.swift) + PiNative/PiConversationModel.swift) add_test "PiNativeUITests/ConversationNavigationUITests" ;; + PiNative/PiConversationView.swift) + add_test "PiNativeUITests/ConversationNavigationUITests" + add_test "PiNativeUITests/ChatMessageFormattingUITests" + ;; + PiNative/ChatMarkdownParser.swift|PiNativeUITests/ChatMessageFormattingUITests.swift) + add_test "PiNativeUITests/ChatMessageFormattingUITests" + ;; PiNative/ModelSettingsModel.swift|PiNative/ModelSettingsView.swift) add_test "PiNativeUITests/ModelSettingsUITests" ;; diff --git a/specs/REQ-014-chat-message-formatting.md b/specs/REQ-014-chat-message-formatting.md new file mode 100644 index 0000000..af3705d --- /dev/null +++ b/specs/REQ-014-chat-message-formatting.md @@ -0,0 +1,23 @@ +# REQ-014: Chat Message Formatting + +## Overview + +PiNative presents assistant responses as readable rich text while preserving code and progress feedback as distinct transcript content. Formatting should remain legible as assistant text streams and activity summaries accumulate. An activity group is one transcript row containing the user-facing summaries of related tool work. + +## Requirements + +### REQ-014.1: Assistant Markdown + +1. Assistant messages containing common Markdown prose structures—headings, paragraphs, emphasis, strong emphasis, links, lists, block quotes, inline code, and fenced code—MUST display those structures as formatted content rather than visible Markdown delimiters. +2. Incomplete or malformed Markdown received while an assistant message is streaming MUST remain visible as readable text instead of causing the message to disappear. + +### REQ-014.2: Activity summary separation + +1. Consecutive activity summaries within one activity group MUST have visually distinct paragraph spacing rather than appearing as adjacent lines of one paragraph. + +## Non-goals + +- Markdown tables, remote images, or arbitrary HTML embedded in assistant Markdown. +- Syntax highlighting inside fenced code blocks. +- Changing user-message formatting. +- Exposing raw tool arguments or output in the default transcript.