From 1d1ce5a63a1d2f4a4145b2e93cd801307ce0e797 Mon Sep 17 00:00:00 2001 From: Arturo Carretero Calvo <10163049+ArtCC@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:15:32 +0200 Subject: [PATCH] Add footnotes and script rendering to Markdown parser - Add Markdown footnote parsing and rendering with numbered references - Add native subscript and superscript support for `` and `` tags - Fix Markdown table parsing for optional outer pipes and escaped pipes - Prevent tables from consuming following headings, lists, quotes, code blocks, images, and footnote definitions - Adjust message layout spacing and compact assistant metadata/action row - Update changelog and TestFlight release notes for build 109 - Add parser and rendering tests for footnotes, tables, escapes, and scripts --- CHANGELOG.md | 15 +- TestFlight/WhatToTest.en-US.txt | 3 + ...tToTest.en-ES.txt => WhatToTest.es-ES.txt} | 3 + .../Utils/MarkdownParserTests+Footnotes.swift | 101 ++++++++ .../Utils/MarkdownParserTests+Media.swift | 90 +++++++ .../Utils/RenderedMarkdownTests+Escapes.swift | 114 +++++++++ .../RenderedMarkdownTests+Footnotes.swift | 141 +++++++++++ .../Utils/RenderedMarkdownTests+Scripts.swift | 237 ++++++++++++++++++ .../Shared/Core/Utils/MarkdownFootnote.swift | 15 ++ .../Utils/MarkdownFootnoteReferences.swift | 107 ++++++++ .../Core/Utils/MarkdownParser+Footnotes.swift | 81 ++++++ .../Core/Utils/MarkdownParser+Tables.swift | 85 +++++++ .../Shared/Core/Utils/MarkdownParser.swift | 120 ++++----- .../Core/Utils/MarkdownScriptAttribute.swift | 15 ++ .../Utils/MarkdownScriptParser+Links.swift | 133 ++++++++++ .../Core/Utils/MarkdownScriptParser.swift | 87 +++++++ .../Shared/Core/Utils/RenderedMarkdown.swift | 20 +- .../Features/Chat/Views/BlockquoteView.swift | 2 +- .../Chat/Views/BulletedListView.swift | 2 +- .../Chat/Views/ChatView+Messages.swift | 2 +- .../Chat/Views/MarkdownFootnotesView.swift | 43 ++++ .../Chat/Views/MarkdownInlineText.swift | 52 ++++ .../Chat/Views/MarkdownTableView.swift | 2 +- .../Views/MessageBubbleView+Markdown.swift | 12 +- .../Views/MessageBubbleView+Previews.swift | 100 +++++++- .../Chat/Views/MessageBubbleView.swift | 119 +++++---- .../Chat/Views/NumberedListView.swift | 2 +- .../Features/Chat/Views/TaskListView.swift | 2 +- 28 files changed, 1566 insertions(+), 139 deletions(-) rename TestFlight/{WhatToTest.en-ES.txt => WhatToTest.es-ES.txt} (79%) create mode 100644 openclient-llm-test/Core/Utils/MarkdownParserTests+Footnotes.swift create mode 100644 openclient-llm-test/Core/Utils/RenderedMarkdownTests+Escapes.swift create mode 100644 openclient-llm-test/Core/Utils/RenderedMarkdownTests+Footnotes.swift create mode 100644 openclient-llm-test/Core/Utils/RenderedMarkdownTests+Scripts.swift create mode 100644 openclient-llm/Shared/Core/Utils/MarkdownFootnote.swift create mode 100644 openclient-llm/Shared/Core/Utils/MarkdownFootnoteReferences.swift create mode 100644 openclient-llm/Shared/Core/Utils/MarkdownParser+Footnotes.swift create mode 100644 openclient-llm/Shared/Core/Utils/MarkdownParser+Tables.swift create mode 100644 openclient-llm/Shared/Core/Utils/MarkdownScriptAttribute.swift create mode 100644 openclient-llm/Shared/Core/Utils/MarkdownScriptParser+Links.swift create mode 100644 openclient-llm/Shared/Core/Utils/MarkdownScriptParser.swift create mode 100644 openclient-llm/Shared/Features/Chat/Views/MarkdownFootnotesView.swift create mode 100644 openclient-llm/Shared/Features/Chat/Views/MarkdownInlineText.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 9887a33f..5a8e03fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. -## [1.7.5-build-108] - 2026-09-14 +## [1.7.5-build-109] - 2026-09-14 + +### Added + +- Markdown footnotes with numbered references and multiline notes collected at the end of assistant responses +- Native subscript and superscript rendering for `` and `` tags across Markdown text, including formatted links + +### Changed + +- Reduced spacing between user and assistant messages and brought timestamps closer to message content +- Consolidated assistant message metadata and Listen and Regenerate Response actions into a compact, adaptive row ### Fixed +- Markdown tables now support optional outer pipe delimiters in headers and rows +- Escaped pipes in Markdown tables now remain inside their cells, including within inline code +- Markdown tables no longer consume following headings, lists, quotes, images, code blocks, or footnote definitions containing pipes - **Minor bug fixes** ## [1.7.0-build-105] - 2026-09-11 diff --git a/TestFlight/WhatToTest.en-US.txt b/TestFlight/WhatToTest.en-US.txt index 61821c4f..dc1fddf4 100644 --- a/TestFlight/WhatToTest.en-US.txt +++ b/TestFlight/WhatToTest.en-US.txt @@ -5,6 +5,9 @@ Hi there! We've got some great new features for you in this update. • Your assistant's toolbox is now in your hands! Open the new Tools section in Settings to see what each built-in tool does and turn it on or off. Everything is listed alphabetically in your language, and OpenClient remembers your choices. • Got a picture you'd like to change? Image-generation models with vision now accept one or more reference images for editing on iPhone, iPad, and Mac. Trying again keeps your original prompt and references, so you don't have to set everything up from scratch. • We've given your creations some extra care, too. Images stay in the conversation if you stop a reply after they arrive, and regenerating just the text keeps the image without requesting another generation. We've improved image-only replies, image format handling, and references in long chats and imported backups. If an analysis request is too large for your specialist, clearer guidance helps you adjust it. Private chats still keep images only for the session. +• Your conversations just got easier to read. We've brought your messages and the model's replies closer together, with each reply's time, details, and actions in a compact row. Less empty space, more conversation in view. +• Better formatting for your replies! Markdown tables now display correctly even without separators at their outer edges. Footnotes appear as numbered notes at the end of the message, and escaped characters are handled correctly inside tables, too. +• Small details make a difference, too. You can now see subscripts and superscripts, like H₂O or x², even inside links and alongside bold or italic text. Their size follows your text size on iPhone, iPad, and Mac. • Voice setup is a little easier as well. If you haven't selected a model, or your previous choice is no longer available, text-to-speech picks the first available model and speech-to-text selects Apple speech recognition. Both work even when there's only one option. We've also included smaller fixes and improvements to keep things running smoothly. Thanks for your continued support and for helping us build the best possible LLM client together. diff --git a/TestFlight/WhatToTest.en-ES.txt b/TestFlight/WhatToTest.es-ES.txt similarity index 79% rename from TestFlight/WhatToTest.en-ES.txt rename to TestFlight/WhatToTest.es-ES.txt index 681d052f..b6b5a78e 100644 --- a/TestFlight/WhatToTest.en-ES.txt +++ b/TestFlight/WhatToTest.es-ES.txt @@ -5,6 +5,9 @@ • ¡Ahora tú controlas las herramientas de tu asistente! Abre la nueva sección Herramientas de Ajustes para ver qué hace cada herramienta integrada y activarla o desactivarla. Todas aparecen ordenadas alfabéticamente en tu idioma y OpenClient recuerda tus preferencias. • ¿Quieres modificar una imagen? Los modelos de generación de imágenes con capacidad de visión ahora pueden utilizar una o varias imágenes de referencia para editarlas en iPhone, iPad y Mac. Si vuelves a intentarlo, se conservan tanto el prompt original como las imágenes de referencia, para que no tengas que configurarlo todo de nuevo. • También hemos mejorado el tratamiento de las imágenes generadas. Las imágenes permanecen en la conversación si detienes una respuesta después de que se hayan generado, y regenerar solo el texto conserva la imagen sin solicitar una nueva generación. También hemos mejorado las respuestas que contienen únicamente imágenes, la gestión de formatos y las referencias en conversaciones largas y copias de seguridad importadas. Si una solicitud de análisis es demasiado grande para tu especialista, recibirás indicaciones más claras para poder ajustarla. Los chats privados siguen conservando las imágenes únicamente durante la sesión. +• Tus conversaciones, más cómodas de leer. Hemos acercado tus mensajes y las respuestas del modelo, y reunido la hora, los detalles y las acciones de cada respuesta en una fila compacta. Menos espacio vacío y más conversación a la vista. +• ¡Mejor formato para las respuestas! Las tablas de Markdown ahora se muestran bien aunque no incluyan separadores en los extremos. Las notas al pie aparecen numeradas al final del mensaje y los caracteres escapados se respetan también dentro de las tablas. +• Los pequeños detalles también cuentan. Ya puedes ver subíndices y superíndices, como H₂O o x², incluso en enlaces y junto a negrita o cursiva. Su tamaño se adapta al del texto en iPhone, iPad y Mac. • La configuración de voz también es ahora más sencilla. Si no has seleccionado ningún modelo o el que usabas ya no está disponible, la conversión de texto a voz seleccionará el primer modelo disponible y la conversión de voz a texto utilizará el reconocimiento de voz de Apple. Ambas funciones también funcionan cuando solo hay una opción disponible. Además, hemos incluido pequeñas correcciones y mejoras para que todo siga funcionando como debe. Gracias por seguir apoyándonos y por ayudarnos a crear juntos el mejor cliente posible para modelos de lenguaje. diff --git a/openclient-llm-test/Core/Utils/MarkdownParserTests+Footnotes.swift b/openclient-llm-test/Core/Utils/MarkdownParserTests+Footnotes.swift new file mode 100644 index 00000000..dea9c2d6 --- /dev/null +++ b/openclient-llm-test/Core/Utils/MarkdownParserTests+Footnotes.swift @@ -0,0 +1,101 @@ +// +// MarkdownParserTests+Footnotes.swift +// openclient-llm-test +// +// Created by Arturo Carretero Calvo on 13/09/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import XCTest +@testable import openclient_llm + +extension MarkdownParserTests { + func test_parse_footnoteDefinitions_extractsLabelsAndContent() { + // Given + let input = "Text[^1] and another[^source].\n[^1]: First note.\n[^source]: **Source** and `code`." + + // When + let blocks = MarkdownParser.parse(input) + + // Then + XCTAssertEqual(blocks, [ + .text("Text[^1] and another[^source]."), + .footnote(label: "1", content: "First note."), + .footnote(label: "source", content: "**Source** and `code`.") + ]) + } + + func test_parse_multilineFootnote_preservesParagraphsAndFollowingText() { + // Given + let input = "[^note]: First line.\n Second line.\n\n Another paragraph.\nFollowing text." + + // When + let blocks = MarkdownParser.parse(input) + + // Then + XCTAssertEqual(blocks, [ + .footnote(label: "note", content: "First line.\nSecond line.\n\nAnother paragraph."), + .text("Following text.") + ]) + } + + func test_parse_footnoteWithHardLineBreak_includesContinuation() { + // Given + let input = "[^note]: First line. \nSecond line.\nFollowing text." + + // When + let blocks = MarkdownParser.parse(input) + + // Then + XCTAssertEqual(blocks, [ + .footnote(label: "note", content: "First line. \nSecond line."), + .text("Following text.") + ]) + } + + func test_parse_emptyFirstFootnoteLine_acceptsIndentedContent() { + // Given + let input = "[^note]:\n\tNote content." + + // When + let blocks = MarkdownParser.parse(input) + + // Then + XCTAssertEqual(blocks, [.footnote(label: "note", content: "Note content.")]) + } + + func test_parse_invalidOrEscapedFootnoteDefinitions_preservesLiteralText() { + // Given + let inputs = ["[^]: Note.", "[^two words]: Note.", "[^note]:", #"\[^note]: Literal."#] + + // When + let results = inputs.map { MarkdownParser.parse($0) } + + // Then + for (input, blocks) in zip(inputs, results) { + XCTAssertEqual(blocks, [.text(input)]) + } + } + + func test_parse_footnotesAndTablesInsideCode_preservesCodeVerbatim() { + // Given + let code = "[^note]: Literal.\nName | Age\n--- | ---\nAlice | 30\n\\*escaped\\*" + + // When + let blocks = MarkdownParser.parse("```markdown\n" + code + "\n```") + + // Then + XCTAssertEqual(blocks, [.codeBlock(code: code, language: "markdown")]) + } + + func test_parse_definitionInsideMultilineCodeSpan_preservesLiteralText() { + // Given + let source = "Example: ``literal ` syntax\n[^note]: Not a footnote.\n``" + + // When + let blocks = MarkdownParser.parse(source) + + // Then + XCTAssertEqual(blocks, [.text(source)]) + } +} diff --git a/openclient-llm-test/Core/Utils/MarkdownParserTests+Media.swift b/openclient-llm-test/Core/Utils/MarkdownParserTests+Media.swift index b9788e57..d8e8b47e 100644 --- a/openclient-llm-test/Core/Utils/MarkdownParserTests+Media.swift +++ b/openclient-llm-test/Core/Utils/MarkdownParserTests+Media.swift @@ -191,4 +191,94 @@ extension MarkdownParserTests { XCTFail("Expected .text"); return } } + + func test_parse_tableWithOptionalOuterPipes_returnsSameCells() { + // Given + let inputs = [ + "Name | Age\n--- | ---\nAlice | 30", + "| Name | Age |\n--- | ---\nAlice | 30", + "Name | Age\n| --- | --- |\n| Alice | 30 |", + "Name | Age |\n:--- | ---:\n| Alice | 30" + ] + + // When + let results = inputs.map { MarkdownParser.parse($0) } + + // Then + for blocks in results { + XCTAssertEqual(blocks, [.table(headers: ["Name", "Age"], rows: [["Alice", "30"]])]) + } + } + + func test_parse_tableWithEscapedPipes_preservesLiteralPipesAndEmptyCells() { + // Given + let input = #""" + Pattern | Value | Empty + --- | --- | --- + a\|b | `x\|y` | | + \| | trailing\| | | + """# + + // When + let blocks = MarkdownParser.parse(input) + + // Then + XCTAssertEqual(blocks, [.table( + headers: ["Pattern", "Value", "Empty"], + rows: [["a|b", "`x|y`", ""], ["|", "trailing|", ""]] + )]) + } + + func test_parse_tableWithEscapedBackslash_keepsFollowingPipeAsSeparator() { + // Given + let input = #""" + Path | Value + --- | --- + C:\\| 42 + """# + + // When + let blocks = MarkdownParser.parse(input) + + // Then + XCTAssertEqual(blocks, [.table(headers: ["Path", "Value"], rows: [[#"C:\\"#, "42"]])]) + } + + func test_parse_invalidTableSeparators_preservesText() { + // Given + let inputs = [ + "Name | Age\n--- | :\nAlice | 30", + "Name | Age\n--- | --:--\nAlice | 30", + "Name | Age\n--- | --- | ---\nAlice | 30", + "Name \\| Age\n--- | ---\nAlice | 30" + ] + + // When + let results = inputs.map { MarkdownParser.parse($0) } + + // Then + for (input, blocks) in zip(inputs, results) { + XCTAssertEqual(blocks, [.text(input)]) + } + } + + func test_parse_tableFollowedByBlocksContainingPipes_doesNotConsumeNextBlock() { + // Given + let table = "Name | Age\n--- | ---\nAlice | 30\n" + let suffixes: [(String, MessageBlock)] = [ + ("# Heading | Details", .heading(text: "Heading | Details", level: 1)), + ("> Quote | Details", .blockquote("Quote | Details")), + ("- Item | Details", .unorderedList(items: [.init(content: "Item | Details", depth: 0)])), + ("```\na | b\n```", .codeBlock(code: "a | b", language: nil)), + ("[^source]: A | B", .footnote(label: "source", content: "A | B")) + ] + + // When + let results = suffixes.map { MarkdownParser.parse(table + $0.0) } + + // Then + for (suffix, blocks) in zip(suffixes, results) { + XCTAssertEqual(blocks, [.table(headers: ["Name", "Age"], rows: [["Alice", "30"]]), suffix.1]) + } + } } diff --git a/openclient-llm-test/Core/Utils/RenderedMarkdownTests+Escapes.swift b/openclient-llm-test/Core/Utils/RenderedMarkdownTests+Escapes.swift new file mode 100644 index 00000000..c21db9b9 --- /dev/null +++ b/openclient-llm-test/Core/Utils/RenderedMarkdownTests+Escapes.swift @@ -0,0 +1,114 @@ +// +// RenderedMarkdownTests+Escapes.swift +// openclient-llm-test +// +// Created by Arturo Carretero Calvo on 13/09/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import XCTest +@testable import openclient_llm + +extension RenderedMarkdownTests { + func test_renderConcurrently_escapedInlineMarkdown_preservesLiteralCharacters() async throws { + // Given + let source = #"\*literal\* \_text\_ \[brackets\] \`backticks\` \\path & <tag>"# + + // When + let result = await MarkdownParser.renderConcurrently(source) + let rendered = try XCTUnwrap(result) + let attributed = rendered.attributedString(for: source) + + // Then + XCTAssertEqual(String(attributed.characters), #"*literal* _text_ [brackets] `backticks` \path & "#) + XCTAssertFalse(attributed.runs.contains { $0.inlinePresentationIntent?.contains(.emphasized) == true }) + XCTAssertFalse(attributed.runs.contains { $0.inlinePresentationIntent?.contains(.code) == true }) + } + + func test_renderConcurrently_inlineCode_keepsEscapesAndEntitiesLiteral() async throws { + // Given + let source = #"`\*literal\* & code [^note]`"# + + // When + let result = await MarkdownParser.renderConcurrently(source + "\n[^note]: Note.") + let rendered = try XCTUnwrap(result) + + // Then + XCTAssertEqual( + String(rendered.attributedString(for: source).characters), + #"\*literal\* & code [^note]"# + ) + XCTAssertTrue(rendered.attributedString(for: source).runs.contains { + $0.inlinePresentationIntent?.contains(.code) == true + }) + } + + func test_renderConcurrently_escapedBlockMarkers_doesNotCreateBlocks() async throws { + // Given + let source = ##""" + \# Heading + \> Quote + \- Item + 1\. Item + \[^note]: Literal note syntax. + """## + + // When + let result = await MarkdownParser.renderConcurrently(source) + let rendered = try XCTUnwrap(result) + + // Then + XCTAssertEqual(rendered.blocks, [.text(source)]) + XCTAssertTrue(rendered.footnotes.isEmpty) + XCTAssertEqual(String(rendered.attributedString(for: source).characters), """ + # Heading + > Quote + - Item + 1. Item + [^note]: Literal note syntax. + """) + } + + func test_renderConcurrently_escapedPipeInTableCode_doesNotAddColumnOrBackslash() async throws { + // Given + let source = "Pattern | Description\n--- | ---\n`a\\|b` | A \\| B" + + // When + let result = await MarkdownParser.renderConcurrently(source) + let rendered = try XCTUnwrap(result) + + // Then + XCTAssertEqual(rendered.blocks, [.table( + headers: ["Pattern", "Description"], + rows: [["`a|b`", "A | B"]] + )]) + XCTAssertEqual(String(rendered.attributedString(for: "`a|b`").characters), "a|b") + XCTAssertTrue(rendered.attributedString(for: "`a|b`").runs.contains { + $0.inlinePresentationIntent?.contains(.code) == true + }) + } + + func test_renderConcurrently_existingInlineSyntax_matchesFoundationWithFootnotesPresent() async throws { + // Given + let sources = [ + "**Bold**, *italic*, ~~removed~~, `code`, and [link](https://example.com)", + #"\*literal\* and \\path and & and <tag>"#, + "Bold
Other inline HTML", + #"Inline $x^2$ and \(\frac{a}{b}\) and $$E=mc^2$$"# + ] + + for source in sources { + let expected = try AttributedString( + markdown: source, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) + + // When + let result = await MarkdownParser.renderConcurrently(source + "\n[^note]: A note.") + let rendered = try XCTUnwrap(result) + + // Then + XCTAssertEqual(rendered.attributedString(for: source), expected) + } + } +} diff --git a/openclient-llm-test/Core/Utils/RenderedMarkdownTests+Footnotes.swift b/openclient-llm-test/Core/Utils/RenderedMarkdownTests+Footnotes.swift new file mode 100644 index 00000000..6a9ba878 --- /dev/null +++ b/openclient-llm-test/Core/Utils/RenderedMarkdownTests+Footnotes.swift @@ -0,0 +1,141 @@ +// +// RenderedMarkdownTests+Footnotes.swift +// openclient-llm-test +// +// Created by Arturo Carretero Calvo on 13/09/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import XCTest +@testable import openclient_llm + +extension RenderedMarkdownTests { + func test_renderConcurrently_footnotes_numbersByFirstReferenceAndReusesNumbers() async throws { + // Given + let body = "First[^second], next[^first], repeat[^second]." + let source = "[^first]: One.\n[^second]: Two.\n" + body + + // When + let result = await MarkdownParser.renderConcurrently(source) + let rendered = try XCTUnwrap(result) + + // Then + XCTAssertEqual(String(rendered.attributedString(for: body).characters), "First¹, next², repeat¹.") + XCTAssertEqual(rendered.footnotes, [ + MarkdownFootnote(number: 1, label: "second", content: "Two."), + MarkdownFootnote(number: 2, label: "first", content: "One.") + ]) + } + + func test_renderConcurrently_footnotes_preservesInlineFormatting() async throws { + // Given + let body = "**Fact[^source]** and *context*." + let note = "See **details** and [source](https://example.com)." + let source = body + "\n[^source]: " + note + let expected = try AttributedString( + markdown: "**Fact¹** and *context*.", + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) + + // When + let result = await MarkdownParser.renderConcurrently(source) + let rendered = try XCTUnwrap(result) + + // Then + XCTAssertEqual(rendered.attributedString(for: body), expected) + XCTAssertEqual(String(rendered.attributedString(for: note).characters), "See details and source.") + XCTAssertTrue(rendered.attributedString(for: note).runs.contains { + $0.link?.absoluteString == "https://example.com" + }) + } + + func test_renderConcurrently_footnoteSyntaxInCodeEscapesAndLinks_preservesLiterals() async throws { + // Given + let literals = #"`[^note]` and ``code ` [^note]`` and \[^note] and [link](https://example.com/[^note])"# + let body = literals + " and real[^note]." + let expectedSource = literals + " and real¹." + let expected = try AttributedString( + markdown: expectedSource, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) + + // When + let result = await MarkdownParser.renderConcurrently(body + "\n[^note]: Note.") + let rendered = try XCTUnwrap(result) + + // Then + XCTAssertEqual(rendered.attributedString(for: body), expected) + } + + func test_renderConcurrently_unresolvedFootnote_keepsReferenceVisible() async throws { + // Given + let body = "Known[^known] and unknown[^missing]." + + // When + let result = await MarkdownParser.renderConcurrently(body + "\n[^known]: Note.") + let rendered = try XCTUnwrap(result) + + // Then + XCTAssertEqual(String(rendered.attributedString(for: body).characters), "Known¹ and unknown[^missing].") + } + + func test_renderConcurrently_tableAndHeadingReferences_resolvesBoth() async throws { + // Given + let source = "# Heading[^note]\nName | Value\n--- | ---\nItem[^note] | **42**\n[^note]: Context." + + // When + let result = await MarkdownParser.renderConcurrently(source) + let rendered = try XCTUnwrap(result) + + // Then + XCTAssertEqual(String(rendered.attributedString(for: "Heading[^note]").characters), "Heading¹") + XCTAssertEqual(String(rendered.attributedString(for: "Item[^note]").characters), "Item¹") + XCTAssertEqual(String(rendered.attributedString(for: "**42**").characters), "42") + XCTAssertEqual(rendered.footnotes.count, 1) + } + + func test_renderConcurrently_duplicateDefinitions_usesFirstDefinition() async throws { + // Given + let source = "Fact[^note].\n[^note]: First.\n[^note]: Second." + + // When + let result = await MarkdownParser.renderConcurrently(source) + let rendered = try XCTUnwrap(result) + + // Then + XCTAssertEqual(rendered.footnotes, [MarkdownFootnote(number: 1, label: "note", content: "First.")]) + } + + func test_renderConcurrently_unicodeAndEscapedBackslashes_preservesReferenceOffsets() async throws { + // Given + let body = #"á🙂[^a] **más[^b]** \\[^a] \[^a]"# + let expected = try AttributedString( + markdown: #"á🙂¹ **más²** \\¹ \[^a]"#, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) + + // When + let result = await MarkdownParser.renderConcurrently(body + "\n[^a]: First.\n[^b]: Second.") + let rendered = try XCTUnwrap(result) + + // Then + XCTAssertEqual(rendered.attributedString(for: body), expected) + } + + func test_renderConcurrently_referenceSyntaxInNestedLink_preservesLabelAndDestination() async throws { + // Given + let link = "[label[^note]](https://example.com/a(b)/[^note])" + let body = link + " and reference[^note]." + let expected = try AttributedString( + markdown: link + " and reference¹.", + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) + + // When + let result = await MarkdownParser.renderConcurrently(body + "\n[^note]: Note.") + let rendered = try XCTUnwrap(result) + + // Then + XCTAssertEqual(rendered.attributedString(for: body), expected) + } +} diff --git a/openclient-llm-test/Core/Utils/RenderedMarkdownTests+Scripts.swift b/openclient-llm-test/Core/Utils/RenderedMarkdownTests+Scripts.swift new file mode 100644 index 00000000..1dcd68de --- /dev/null +++ b/openclient-llm-test/Core/Utils/RenderedMarkdownTests+Scripts.swift @@ -0,0 +1,237 @@ +// +// RenderedMarkdownTests+Scripts.swift +// openclient-llm-test +// +// Created by Arturo Carretero Calvo on 13/09/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import XCTest +@testable import openclient_llm + +extension RenderedMarkdownTests { + func test_renderConcurrently_subAndSup_marksPositionsAndRemovesTags() async throws { + // Given + let source = "H2O and xn + 1." + + // When + let result = await MarkdownParser.renderConcurrently(source) + let rendered = try XCTUnwrap(result) + let content = rendered.attributedString(for: source) + + // Then + XCTAssertEqual(String(content.characters), "H2O and xn + 1.") + let subscriptRange = try XCTUnwrap(content.range(of: "2")) + let superscriptRange = try XCTUnwrap(content.range(of: "n + 1")) + XCTAssertEqual(content[subscriptRange][MarkdownScriptAttribute.self], -1) + XCTAssertEqual(content[superscriptRange][MarkdownScriptAttribute.self], 1) + let plainRange = try XCTUnwrap(content.range(of: "O and x")) + XCTAssertNil(content[plainRange][MarkdownScriptAttribute.self]) + } + + func test_renderConcurrently_scriptsWithFormattingAndLinks_preservesOtherAttributes() async throws { + // Given + let source = "**H2O** + [x*n*](https://example.com)." + + // When + let result = await MarkdownParser.renderConcurrently(source) + let rendered = try XCTUnwrap(result) + let content = rendered.attributedString(for: source) + + // Then + XCTAssertEqual(String(content.characters), "H2O + xn.") + let subscriptRange = try XCTUnwrap(content.range(of: "2")) + let superscriptRange = try XCTUnwrap(content.range(of: "n")) + XCTAssertEqual(content[subscriptRange][MarkdownScriptAttribute.self], -1) + XCTAssertEqual(content[superscriptRange][MarkdownScriptAttribute.self], 1) + XCTAssertTrue(content[subscriptRange].inlinePresentationIntent?.contains(.stronglyEmphasized) == true) + XCTAssertTrue(content[superscriptRange].inlinePresentationIntent?.contains(.emphasized) == true) + XCTAssertEqual(content[superscriptRange].link?.absoluteString, "https://example.com") + } + + func test_renderConcurrently_escapedEncodedAndCodeTags_keepsLiteralSyntax() async throws { + // Given + let sources = [ + #"\2\ and \n\"#, + "<sub>2</sub> and <sup>n</sup>", + "`2` and ``n``", + "Text and more text", + "Text value
", + "[link](https://example.com/%3Csup%3En%3C/sup%3E)" + ] + + for source in sources { + let expected = try AttributedString( + markdown: source, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) + + // When + let result = await MarkdownParser.renderConcurrently(source) + let rendered = try XCTUnwrap(result) + + // Then + XCTAssertEqual(rendered.attributedString(for: source), expected) + } + } + + func test_renderConcurrently_unmatchedAndCrossedTags_keepsExistingRendering() async throws { + // Given + let sources = [ + "x2", + "x2", + "x2
", + "x2
", + "x2" + ] + + for source in sources { + let expected = try AttributedString( + markdown: source, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) + + // When + let result = await MarkdownParser.renderConcurrently(source) + let rendered = try XCTUnwrap(result) + + // Then + XCTAssertEqual(rendered.attributedString(for: source), expected) + } + } + + func test_renderConcurrently_nestedAndUppercaseTags_appliesInnermostPosition() async throws { + // Given + let source = "xabc and H2O" + + // When + let result = await MarkdownParser.renderConcurrently(source) + let rendered = try XCTUnwrap(result) + let content = rendered.attributedString(for: source) + + // Then + XCTAssertEqual(String(content.characters), "xabc and H2O") + for text in ["a", "c"] { + let range = try XCTUnwrap(content.range(of: text)) + XCTAssertEqual(content[range][MarkdownScriptAttribute.self], 1) + } + let nestedRange = try XCTUnwrap(content.range(of: "b")) + XCTAssertEqual(content[nestedRange][MarkdownScriptAttribute.self], -1) + } + + func test_renderConcurrently_scriptsAcrossBlockTypes_usesSharedInlineRendering() async throws { + // Given + let source = """ + # Heading x2 + > Quote H2O + - List xn + 1. Numbered H2O + - [x] Task x3 + + Name | Value + --- | --- + Water | H2O + + Note[^chemistry]. + [^chemistry]: Water is H2O. + """ + + // When + let result = await MarkdownParser.renderConcurrently(source) + let rendered = try XCTUnwrap(result) + + // Then + let expectations: [(String, Int)] = [ + ("Heading x2", 1), ("Quote H2O", -1), + ("List xn", 1), ("Numbered H2O", -1), + ("Task x3", 1), ("H2O", -1), + ("Water is H2O.", -1) + ] + for (text, position) in expectations { + let content = try XCTUnwrap(rendered.inlineContent[text]) + XCTAssertTrue(content.runs.contains { $0[MarkdownScriptAttribute.self] == position }, text) + } + XCTAssertEqual(rendered.footnotes.count, 1) + } + + func test_renderConcurrently_fencedHTML_remainsCode() async throws { + // Given + let code = "H2O and x2" + + // When + let result = await MarkdownParser.renderConcurrently("```html\n" + code + "\n```") + let rendered = try XCTUnwrap(result) + + // Then + XCTAssertEqual(rendered.blocks, [.codeBlock(code: code, language: "html")]) + XCTAssertTrue(rendered.inlineContent.isEmpty) + } + + func test_renderConcurrently_scriptLinkWithOuterBold_preservesNestedFormattingAndURL() async throws { + // Given + let source = "**[x*n*](https://example.com/a(b) \"Example\")**" + + // When + let result = await MarkdownParser.renderConcurrently(source) + let rendered = try XCTUnwrap(result) + let content = rendered.attributedString(for: source) + + // Then + XCTAssertEqual(String(content.characters), "xn") + let range = try XCTUnwrap(content.range(of: "n")) + XCTAssertEqual(content[range][MarkdownScriptAttribute.self], 1) + XCTAssertEqual(content[range].link?.absoluteString, "https://example.com/a(b)") + XCTAssertTrue(content[range].inlinePresentationIntent?.contains(.emphasized) == true) + XCTAssertTrue(content[range].inlinePresentationIntent?.contains(.stronglyEmphasized) == true) + } + + func test_renderConcurrently_identicalURLsWithEscapedAndRealTags_formatsOnlyRealTags() async throws { + // Given + let literalLink = #"[x\*n*\](https://example.com)"# + let scriptLink = "[x*n*](https://example.com)" + let literal = try AttributedString( + markdown: literalLink, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) + let cases = [ + (literalLink + " + " + scriptLink, String(literal.characters) + " + xn"), + (scriptLink + " + " + literalLink, "xn + " + String(literal.characters)) + ] + + for (source, expected) in cases { + // When + let result = await MarkdownParser.renderConcurrently(source) + let rendered = try XCTUnwrap(result) + let content = rendered.attributedString(for: source) + + // Then + XCTAssertEqual(String(content.characters), expected) + let literalRange = try XCTUnwrap(content.range(of: String(literal.characters))) + XCTAssertFalse(content[literalRange].runs.contains { $0[MarkdownScriptAttribute.self] != nil }) + let scriptRange = try XCTUnwrap(content.range(of: "xn")) + XCTAssertTrue(content[scriptRange].runs.contains { $0[MarkdownScriptAttribute.self] == 1 }) + } + } + + func test_renderConcurrently_codeLinkBesideScriptLink_keepsCodeTagsLiteral() async throws { + // Given + let codeLink = "[`n`](https://example.com)" + let source = codeLink + " + [H2O](https://example.com)" + let code = try AttributedString( + markdown: codeLink, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) + + // When + let result = await MarkdownParser.renderConcurrently(source) + let rendered = try XCTUnwrap(result) + let content = rendered.attributedString(for: source) + + // Then + XCTAssertEqual(String(content.characters), String(code.characters) + " + H2O") + let codeRange = try XCTUnwrap(content.range(of: String(code.characters))) + XCTAssertEqual(AttributedString(content[codeRange]), code) + let subscriptRange = try XCTUnwrap(content.range(of: "2")) + XCTAssertEqual(content[subscriptRange][MarkdownScriptAttribute.self], -1) + } +} diff --git a/openclient-llm/Shared/Core/Utils/MarkdownFootnote.swift b/openclient-llm/Shared/Core/Utils/MarkdownFootnote.swift new file mode 100644 index 00000000..f7f25871 --- /dev/null +++ b/openclient-llm/Shared/Core/Utils/MarkdownFootnote.swift @@ -0,0 +1,15 @@ +// +// MarkdownFootnote.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 13/09/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +nonisolated struct MarkdownFootnote: Equatable, Sendable { + let number: Int + let label: String + let content: String +} diff --git a/openclient-llm/Shared/Core/Utils/MarkdownFootnoteReferences.swift b/openclient-llm/Shared/Core/Utils/MarkdownFootnoteReferences.swift new file mode 100644 index 00000000..4933e73e --- /dev/null +++ b/openclient-llm/Shared/Core/Utils/MarkdownFootnoteReferences.swift @@ -0,0 +1,107 @@ +// +// MarkdownFootnoteReferences.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 13/09/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +nonisolated struct MarkdownFootnoteReferences: Sendable { + let notes: [MarkdownFootnote] + private let numbers: [String: Int] + + init(blocks: [MessageBlock]) { + var definitions: [String: String] = [:] + var labels: [String] = [] + for case .footnote(let label, let content) in blocks where definitions[label] == nil { + definitions[label] = content + labels.append(label) + } + guard !definitions.isEmpty else { + self.numbers = [:] + notes = [] + return + } + + var numbers: [String: Int] = [:] + let bodyBlocks = blocks.filter { + if case .footnote = $0 { return false } + return true + } + let sources = MarkdownParser.inlineSources(in: bodyBlocks) + labels.compactMap { definitions[$0] } + for source in sources { + for match in Self.referenceMatches(in: source) { + guard let range = Range(match.range(at: 2), in: source) else { continue } + let label = String(source[range]) + if definitions[label] != nil && numbers[label] == nil { + numbers[label] = numbers.count + 1 + } + } + } + for label in labels where numbers[label] == nil { + numbers[label] = numbers.count + 1 + } + self.numbers = numbers + notes = labels.compactMap { label -> MarkdownFootnote? in + guard let number = numbers[label], let content = definitions[label] else { return nil } + return MarkdownFootnote(number: number, label: label, content: content) + }.sorted { $0.number < $1.number } + } + + func replacingReferences(in source: String) -> String { + guard !numbers.isEmpty else { return source } + var result = source + for match in Self.referenceMatches(in: source).reversed() { + guard let labelRange = Range(match.range(at: 2), in: source), + let number = numbers[String(source[labelRange])], + let range = Range(match.range, in: result) else { continue } + let digits = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"] + let superscript = String(number).compactMap(\.wholeNumberValue).map { digits[$0] }.joined() + result.replaceSubrange(range, with: superscript) + } + return result + } +} + +// MARK: - Private + +private nonisolated extension MarkdownFootnoteReferences { + static func referenceMatches(in source: String) -> [NSTextCheckingResult] { + guard source.contains("[^") else { return [] } + // Consume code spans, escapes and link labels before looking for references. + let pattern = #"(?s)(?\n]*>"# + + #"|\[\^([^\]\s\[]+)\](?![(:])"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + var references: [NSTextCheckingResult] = [] + var destinationEnd = source.startIndex + for match in regex.matches(in: source, range: NSRange(source.startIndex..., in: source)) { + guard let range = Range(match.range, in: source), range.lowerBound >= destinationEnd else { continue } + if match.range(at: 2).location != NSNotFound { + references.append(match) + } else if source[range].hasSuffix("](") { + destinationEnd = endOfLinkDestination(in: source, from: range.upperBound) + } + } + return references + } + + static func endOfLinkDestination(in source: String, from startIndex: String.Index) -> String.Index { + var index = startIndex + var depth = 1 + while index < source.endIndex { + let character = source[index] + index = source.index(after: index) + if character == "\\", index < source.endIndex { + index = source.index(after: index) + } else if character == "(" { + depth += 1 + } else if character == ")" { + depth -= 1 + if depth == 0 { return index } + } + } + return source.endIndex + } +} diff --git a/openclient-llm/Shared/Core/Utils/MarkdownParser+Footnotes.swift b/openclient-llm/Shared/Core/Utils/MarkdownParser+Footnotes.swift new file mode 100644 index 00000000..6a036116 --- /dev/null +++ b/openclient-llm/Shared/Core/Utils/MarkdownParser+Footnotes.swift @@ -0,0 +1,81 @@ +// +// MarkdownParser+Footnotes.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 13/09/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +nonisolated extension MarkdownParser { + static func footnoteCodeLines(in lines: [String]) -> Set { + guard lines.contains(where: { $0.trimmingCharacters(in: .whitespaces).hasPrefix("[^") }) else { return [] } + let source = lines.joined(separator: "\n") + // Definitions inside multiline code spans must remain literal Markdown examples. + let pattern = #"(?s)\\.|(? = [] + var offset = 0 + var rangeIndex = 0 + for (lineIndex, line) in lines.enumerated() { + let contentOffset = offset + line.prefix(while: { $0 == " " }).count + while rangeIndex < ranges.count && NSMaxRange(ranges[rangeIndex]) <= contentOffset { + rangeIndex += 1 + } + if rangeIndex < ranges.count && NSLocationInRange(contentOffset, ranges[rangeIndex]) { + protectedLines.insert(lineIndex) + } + offset += line.utf16.count + 1 + } + return protectedLines + } + + static func parseFootnote(lines: [String], startIndex: inout Int) -> MessageBlock? { + let line = lines[startIndex] + let indentation = line.prefix(while: { $0 == " " }).count + let trimmed = line.dropFirst(indentation) + guard indentation < 4, trimmed.hasPrefix("[^"), + let end = trimmed.range(of: "]:") else { return nil } + let label = String(trimmed.dropFirst(2).prefix(upTo: end.lowerBound)) + guard !label.isEmpty, !label.contains(where: { $0.isWhitespace || $0 == "[" || $0 == "]" }) else { + return nil + } + + var content = [String(trimmed[end.upperBound...].drop(while: { $0 == " " || $0 == "\t" }))] + var index = startIndex + 1 + while index < lines.count { + if let continuation = footnoteContinuation(lines[index]) { + content.append(continuation) + index += 1 + } else if lines[index].trimmingCharacters(in: .whitespaces).isEmpty, + index + 1 < lines.count, footnoteContinuation(lines[index + 1]) != nil { + content.append("") + index += 1 + } else if content.last?.hasSuffix(" ") == true, + !lines[index].trimmingCharacters(in: .whitespaces).isEmpty, + !isTableBodyBoundary(lines[index]) { + content.append(lines[index]) + index += 1 + } else { + break + } + } + + let text = content.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + startIndex = index + return .footnote(label: label, content: text) + } +} + +private nonisolated extension MarkdownParser { + static func footnoteContinuation(_ line: String) -> String? { + if line.hasPrefix(" ") { return String(line.dropFirst(4)) } + if line.hasPrefix("\t") { return String(line.dropFirst()) } + return nil + } +} diff --git a/openclient-llm/Shared/Core/Utils/MarkdownParser+Tables.swift b/openclient-llm/Shared/Core/Utils/MarkdownParser+Tables.swift new file mode 100644 index 00000000..1c83ff43 --- /dev/null +++ b/openclient-llm/Shared/Core/Utils/MarkdownParser+Tables.swift @@ -0,0 +1,85 @@ +// +// MarkdownParser+Tables.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 13/09/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +nonisolated extension MarkdownParser { + static func tryParseTable(lines: [String], startIndex: Int) -> MessageBlock? { + guard startIndex + 1 < lines.count else { return nil } + + let headers = parseTableRow(lines[startIndex]) + guard !headers.isEmpty, + isTableSeparator(lines[startIndex + 1], columnCount: headers.count) else { return nil } + + var rows: [[String]] = [] + var index = startIndex + 2 + while index < lines.count { + if isTableBodyBoundary(lines[index]) { break } + let row = parseTableRow(lines[index]) + if row.isEmpty { break } + rows.append(row) + index += 1 + } + return .table(headers: headers, rows: rows) + } + + static func advancePastTable(lines: [String], startIndex: Int) -> Int { + var index = startIndex + 2 + while index < lines.count && !isTableBodyBoundary(lines[index]) && !parseTableRow(lines[index]).isEmpty { + index += 1 + } + return index + } +} + +// MARK: - Private + +private nonisolated extension MarkdownParser { + static func parseTableRow(_ line: String) -> [String] { + let trimmed = line.trimmingCharacters(in: .whitespaces) + var cells: [String] = [] + var cell = "" + var index = trimmed.startIndex + var endsWithSeparator = false + + while index < trimmed.endIndex { + let character = trimmed[index] + index = trimmed.index(after: index) + endsWithSeparator = false + if character == "\\", index < trimmed.endIndex { + // Escaped pipes are cell content, including inside inline code. + if trimmed[index] != "|" { cell.append(character) } + cell.append(trimmed[index]) + index = trimmed.index(after: index) + } else if character == "|" { + cells.append(cell.trimmingCharacters(in: .whitespaces)) + cell = "" + endsWithSeparator = true + } else { + cell.append(character) + } + } + + guard !cells.isEmpty else { return [] } + if !endsWithSeparator { cells.append(cell.trimmingCharacters(in: .whitespaces)) } + if trimmed.hasPrefix("|") { cells.removeFirst() } + return cells + } + + static func isTableSeparator(_ line: String, columnCount: Int) -> Bool { + let cells = parseTableRow(line) + guard cells.count == columnCount else { return false } + + return cells.allSatisfy { cell in + var marker = cell[...] + if marker.hasPrefix(":") { marker = marker.dropFirst() } + if marker.hasSuffix(":") { marker = marker.dropLast() } + return !marker.isEmpty && marker.allSatisfy { $0 == "-" } + } + } +} diff --git a/openclient-llm/Shared/Core/Utils/MarkdownParser.swift b/openclient-llm/Shared/Core/Utils/MarkdownParser.swift index 68849ae5..2d3dfa73 100644 --- a/openclient-llm/Shared/Core/Utils/MarkdownParser.swift +++ b/openclient-llm/Shared/Core/Utils/MarkdownParser.swift @@ -21,6 +21,7 @@ nonisolated enum MessageBlock: Equatable, Sendable { case table(headers: [String], rows: [[String]]) case taskList(items: [MarkdownTaskItem]) case image(alt: String, url: String) + case footnote(label: String, content: String) } // MARK: - MarkdownListItem @@ -53,6 +54,7 @@ nonisolated struct MarkdownParser: Sendable { static func parse(_ raw: String) -> [MessageBlock] { let lines = raw.components(separatedBy: "\n") + let protectedFootnoteLines = footnoteCodeLines(in: lines) var blocks: [MessageBlock] = [] var index = 0 @@ -60,7 +62,9 @@ nonisolated struct MarkdownParser: Sendable { while index < lines.count { let line = lines[index] - if line.hasPrefix("```") { + if !protectedFootnoteLines.contains(index), let footnote = parseFootnote(lines: lines, startIndex: &index) { + blocks.append(footnote) + } else if line.hasPrefix("```") { let language = extractLanguage(from: line) var codeLines: [String] = [] index += 1 @@ -83,31 +87,55 @@ nonisolated struct MarkdownParser: Sendable { blocks.append(table) index = advancePastTable(lines: lines, startIndex: index) } else { - parseMixedBlock(lines: lines, startIndex: &index, into: &blocks) + parseMixedBlock( + lines: lines, + startIndex: &index, + protectedFootnoteLines: protectedFootnoteLines, + into: &blocks + ) } } return blocks } + + static func isTableBodyBoundary(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespaces) + return trimmed.hasPrefix("```") + || isHorizontalRule(line) + || isBlockquoteLine(line) + || parseUnorderedListItem(line) != nil + || parseOrderedListItem(line) != nil + || extractHeading(from: line) != nil + || parseImageLine(line) != nil + || (trimmed.hasPrefix("[^") && trimmed.contains("]:")) + } } // MARK: - Private: Mixed Block Parsing private nonisolated extension MarkdownParser { - static func parseMixedBlock(lines: [String], startIndex: inout Int, into blocks: inout [MessageBlock]) { + static func parseMixedBlock( + lines: [String], + startIndex: inout Int, + protectedFootnoteLines: Set, + into blocks: inout [MessageBlock] + ) { var accumulatedText: [String] = [] while startIndex < lines.count && !lines[startIndex].hasPrefix("```") { let currentLine = lines[startIndex] - if isHorizontalRule(currentLine) { + if !protectedFootnoteLines.contains(startIndex), + let footnote = parseFootnote(lines: lines, startIndex: &startIndex) { flushText(&accumulatedText, into: &blocks) - blocks.append(.horizontalRule) - startIndex += 1 + blocks.append(footnote) continue - } else if let image = parseImageLine(currentLine) { + } + + if let block = parseRuleOrImage(currentLine) { flushText(&accumulatedText, into: &blocks) - blocks.append(image) + blocks.append(block) startIndex += 1 continue } @@ -153,6 +181,13 @@ private nonisolated extension MarkdownParser { flushText(&accumulatedText, into: &blocks) } + static func parseRuleOrImage(_ line: String) -> MessageBlock? { + if isHorizontalRule(line) { + return .horizontalRule + } + return parseImageLine(line) + } + static func flushText(_ text: inout [String], into blocks: inout [MessageBlock]) { let joined = text.joined(separator: "\n") if !joined.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { @@ -380,75 +415,6 @@ private nonisolated extension MarkdownParser { } } -// MARK: - Private: Table - -private nonisolated extension MarkdownParser { - static func tryParseTable(lines: [String], startIndex: Int) -> MessageBlock? { - guard startIndex + 1 < lines.count else { return nil } - - let headerLine = lines[startIndex] - let separatorLine = lines[startIndex + 1] - - let headers = parseTableRow(headerLine) - guard !headers.isEmpty else { return nil } - guard isTableSeparator(separatorLine, columnCount: headers.count) else { return nil } - - var rows: [[String]] = [] - var index = startIndex + 2 - - while index < lines.count { - let row = parseTableRow(lines[index]) - if row.isEmpty { break } - rows.append(row) - index += 1 - } - - return .table(headers: headers, rows: rows) - } - - static func parseTableRow(_ line: String) -> [String] { - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard trimmed.hasPrefix("|") || trimmed.hasSuffix("|") else { return [] } - - let cells = trimmed - .split(separator: "|", omittingEmptySubsequences: false) - .map { $0.trimmingCharacters(in: .whitespaces) } - - let startIndex = trimmed.hasPrefix("|") ? 1 : 0 - let endIndex = cells.count - (trimmed.hasSuffix("|") ? 1 : 0) - - guard startIndex < min(cells.count, endIndex + 1) else { return [] } - - let filtered = Array(cells[startIndex.. Bool { - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard trimmed.contains("|") && trimmed.contains("-") else { return false } - - let cells = parseTableRow(line) - guard cells.count == columnCount else { return false } - - return cells.allSatisfy { cell in - let cleaned = cell.trimmingCharacters(in: .whitespaces) - guard !cleaned.isEmpty else { return false } - let withoutColons = cleaned.replacingOccurrences(of: ":", with: "") - return withoutColons.allSatisfy { $0 == "-" } - } - } - - static func advancePastTable(lines: [String], startIndex: Int) -> Int { - var index = startIndex + 2 - while index < lines.count { - let row = parseTableRow(lines[index]) - if row.isEmpty { break } - index += 1 - } - return index - } -} - // MARK: - Private: Code & Headings private nonisolated extension MarkdownParser { diff --git a/openclient-llm/Shared/Core/Utils/MarkdownScriptAttribute.swift b/openclient-llm/Shared/Core/Utils/MarkdownScriptAttribute.swift new file mode 100644 index 00000000..56fd0cda --- /dev/null +++ b/openclient-llm/Shared/Core/Utils/MarkdownScriptAttribute.swift @@ -0,0 +1,15 @@ +// +// MarkdownScriptAttribute.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 13/09/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +/// Semantic position: -1 for a subscript, 1 for a superscript. UI resolves the font metrics. +nonisolated enum MarkdownScriptAttribute: AttributedStringKey { + typealias Value = Int + static let name = "com.artcc.openclient.markdown.script" +} diff --git a/openclient-llm/Shared/Core/Utils/MarkdownScriptParser+Links.swift b/openclient-llm/Shared/Core/Utils/MarkdownScriptParser+Links.swift new file mode 100644 index 00000000..5c38c175 --- /dev/null +++ b/openclient-llm/Shared/Core/Utils/MarkdownScriptParser+Links.swift @@ -0,0 +1,133 @@ +// +// MarkdownScriptParser+Links.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 13/09/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +nonisolated extension MarkdownScriptParser { + static func applyingScripts(to content: AttributedString, source: String) -> AttributedString { + let scripted = applyingScripts(to: content) + guard source.range(of: " AttributedString { + let text = String(content.characters) + var searchStart = text.startIndex + var replacements: [(Range, AttributedString)] = [] + for link in inlineLinks(in: source) { + guard let parsed = parseInline(link.source), let url = parsed.link, + let range = matchingLinkRange( + String(parsed.characters), url: url, in: content, text: text, searchStart: &searchStart + ) else { continue } + guard let label = parseInline(link.label) else { continue } + let formatted = applyingScripts(to: label) + guard formatted.runs.contains(where: { $0[MarkdownScriptAttribute.self] != nil }) else { continue } + + // Foundation can flatten a link label's HTML and emphasis attributes. Parse the original + // label on its own, then restore the link and any formatting inherited from outside it. + let inherited = content[range].runs.first?.attributes ?? AttributeContainer() + replacements.append((range, inheritingAttributes(inherited, in: formatted))) + } + var result = content + for (range, replacement) in replacements.reversed() { + result.replaceSubrange(range, with: replacement) + } + return result + } + + static func matchingLinkRange( + _ label: String, + url: URL, + in content: AttributedString, + text: String, + searchStart: inout String.Index + ) -> Range? { + guard !label.isEmpty else { return nil } + var cursor = searchStart + while let range = text.range(of: label, options: .literal, range: cursor.. AttributedString { + var result = content + result.mergeAttributes(attributes, mergePolicy: .keepCurrent) + let inheritedIntent = attributes.inlinePresentationIntent ?? [] + if !inheritedIntent.isEmpty { + for run in result.runs { + result[run.range].inlinePresentationIntent = (run.inlinePresentationIntent ?? []).union(inheritedIntent) + } + } + return result + } + + static func parseInline(_ source: String) -> AttributedString? { + try? AttributedString(markdown: source, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) + } + + static func inlineLinks(in source: String) -> [InlineLink] { + // Consume escapes and code before recognizing link labels. Include images to skip their destinations. + let pattern = #"(?s)(?|<(?:[^<>"']|"[^"]*"|'[^']*')*>"# + + #"|(!?)\[((?:\\.|[^\[\]\\\n]|\[[^\]\n]*\])*)\]\("# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + var links: [InlineLink] = [] + var destinationEnd = source.startIndex + for match in regex.matches(in: source, range: NSRange(source.startIndex..., in: source)) { + guard match.range(at: 3).location != NSNotFound, + let range = Range(match.range, in: source), range.lowerBound >= destinationEnd, + let labelRange = Range(match.range(at: 3), in: source), + let end = linkDestinationEnd(in: source, from: range.upperBound) else { continue } + destinationEnd = end + guard match.range(at: 2).length == 0 else { continue } + links.append(InlineLink(source: String(source[range.lowerBound.. String.Index? { + var index = startIndex + var depth = 1 + var quote: Character? + while index < source.endIndex { + let character = source[index] + index = source.index(after: index) + if character == "\\", index < source.endIndex { + index = source.index(after: index) + } else if let delimiter = quote { + if character == delimiter { quote = nil } + } else if character == "\"" || character == "'" { + quote = character + } else if character == "(" { + depth += 1 + } else if character == ")" { + depth -= 1 + if depth == 0 { return index } + } + } + return nil + } +} diff --git a/openclient-llm/Shared/Core/Utils/MarkdownScriptParser.swift b/openclient-llm/Shared/Core/Utils/MarkdownScriptParser.swift new file mode 100644 index 00000000..eeeaed15 --- /dev/null +++ b/openclient-llm/Shared/Core/Utils/MarkdownScriptParser.swift @@ -0,0 +1,87 @@ +// +// MarkdownScriptParser.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 13/09/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +nonisolated enum MarkdownScriptParser { + static func applyingScripts(to content: AttributedString) -> AttributedString { + let pairs = matchingPairs(in: content) + guard !pairs.isEmpty else { return content } + var result = content + + // Apply outer spans first, allowing an inner sub/sup to select its own position. + for pair in pairs.reversed() { + result[pair.opening.range.upperBound.. $1.lowerBound } + for range in tagRanges { + result.removeSubrange(range) + } + return result + } +} + +// MARK: - Private + +private nonisolated extension MarkdownScriptParser { + nonisolated struct Tag { + let name: String + let isClosing: Bool + let range: Range + } + + nonisolated struct Pair { + let opening: Tag + let closing: Tag + } + + static func matchingPairs(in content: AttributedString) -> [Pair] { + var stack: [Tag] = [] + var pairs: [Pair] = [] + for tag in tags(in: content) { + if !tag.isClosing { + stack.append(tag) + } else if let opening = stack.last, opening.name == tag.name { + stack.removeLast() + pairs.append(Pair(opening: opening, closing: tag)) + } else { + // Crossed or unmatched tags stay literal instead of consuming surrounding text. + stack.removeAll() + } + } + return pairs + } + + static func tags(in content: AttributedString) -> [Tag] { + let pattern = #"(?s)||<(?:[^<>"']|"[^"]*"|'[^']*')*>"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { + return [] + } + var tags: [Tag] = [] + for run in content.runs { + // Foundation identifies HTML in ordinary text; link labels are recovered separately from source. + guard run.inlinePresentationIntent?.contains(.inlineHTML) == true else { continue } + let text = String(content[run.range].characters) + for match in regex.matches(in: text, range: NSRange(text.startIndex..., in: text)) { + guard match.range(at: 1).location != NSNotFound, + let range = Range(match.range, in: text), + let nameRange = Range(match.range(at: 1), in: text) else { continue } + let lower = content.characters.index(run.range.lowerBound, offsetBy: text[.. AttributedString { inlineContent[source] ?? AttributedString(source) @@ -27,30 +28,41 @@ nonisolated extension MarkdownParser { let blocks = parse(source) guard !Task.isCancelled else { return nil } + let references = MarkdownFootnoteReferences(blocks: blocks) var inlineContent: [String: AttributedString] = [:] for content in inlineSources(in: blocks) where inlineContent[content] == nil { guard !Task.isCancelled else { return nil } - inlineContent[content] = attributedString(for: content) + inlineContent[content] = attributedString(for: references.replacingReferences(in: content)) await Task.yield() } guard !Task.isCancelled else { return nil } - return RenderedMarkdown(source: source, blocks: blocks, inlineContent: inlineContent) + return RenderedMarkdown( + source: source, + blocks: blocks, + inlineContent: inlineContent, + footnotes: references.notes + ) } } private nonisolated extension MarkdownParser { static func attributedString(for source: String) -> AttributedString { - (try? AttributedString( + let content = (try? AttributedString( markdown: source, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) )) ?? AttributedString(source) + return MarkdownScriptParser.applyingScripts(to: content, source: source) } +} +nonisolated extension MarkdownParser { static func inlineSources(in blocks: [MessageBlock]) -> [String] { blocks.flatMap { block -> [String] in switch block { case .text(let content), .blockquote(let content): return [content] + case .heading(let text, _), .footnote(_, let text): + return [text] case .unorderedList(let items): return items.map(\.content) case .orderedList(let items): @@ -59,7 +71,7 @@ private nonisolated extension MarkdownParser { return headers + rows.flatMap { $0 } case .taskList(let items): return items.map(\.content) - case .codeBlock, .heading, .horizontalRule, .image: + case .codeBlock, .horizontalRule, .image: return [] } } diff --git a/openclient-llm/Shared/Features/Chat/Views/BlockquoteView.swift b/openclient-llm/Shared/Features/Chat/Views/BlockquoteView.swift index 5028c6a6..b9c9d8ae 100644 --- a/openclient-llm/Shared/Features/Chat/Views/BlockquoteView.swift +++ b/openclient-llm/Shared/Features/Chat/Views/BlockquoteView.swift @@ -22,7 +22,7 @@ struct BlockquoteView: View { // MARK: - View var body: some View { - Text(inlineContent[content] ?? AttributedString(content)) + MarkdownInlineText(inlineContent[content] ?? AttributedString(content)) .font(.callout) .foregroundStyle(.secondary) .textSelection(.enabled) diff --git a/openclient-llm/Shared/Features/Chat/Views/BulletedListView.swift b/openclient-llm/Shared/Features/Chat/Views/BulletedListView.swift index d9732a2a..909678c9 100644 --- a/openclient-llm/Shared/Features/Chat/Views/BulletedListView.swift +++ b/openclient-llm/Shared/Features/Chat/Views/BulletedListView.swift @@ -41,7 +41,7 @@ private extension BulletedListView { .frame(width: 16, alignment: .center) .padding(.leading, CGFloat(item.depth) * 16) - Text(inlineContent[item.content] ?? AttributedString(item.content)) + MarkdownInlineText(inlineContent[item.content] ?? AttributedString(item.content)) .font(.body) .foregroundStyle(Color.primary) .textSelection(.enabled) diff --git a/openclient-llm/Shared/Features/Chat/Views/ChatView+Messages.swift b/openclient-llm/Shared/Features/Chat/Views/ChatView+Messages.swift index e2f3a0d7..100ca973 100644 --- a/openclient-llm/Shared/Features/Chat/Views/ChatView+Messages.swift +++ b/openclient-llm/Shared/Features/Chat/Views/ChatView+Messages.swift @@ -18,7 +18,7 @@ extension ChatView { let lastMessageId = state.messages.last?.id // LazyVStack can fail to converge when upward scrolling overlaps live message layout updates. - return VStack(spacing: 24) { + return VStack(spacing: 16) { ForEach(state.messages) { message in let isLast = message.id == lastMessageId let isStreamingMsg = state.isStreaming && isLast diff --git a/openclient-llm/Shared/Features/Chat/Views/MarkdownFootnotesView.swift b/openclient-llm/Shared/Features/Chat/Views/MarkdownFootnotesView.swift new file mode 100644 index 00000000..c7d01b28 --- /dev/null +++ b/openclient-llm/Shared/Features/Chat/Views/MarkdownFootnotesView.swift @@ -0,0 +1,43 @@ +// +// MarkdownFootnotesView.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 13/09/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import SwiftUI + +struct MarkdownFootnotesView: View { + let footnotes: [MarkdownFootnote] + let inlineContent: [String: AttributedString] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Divider() + ForEach(footnotes, id: \.number) { footnote in + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(verbatim: "\(footnote.number).") + .monospacedDigit() + MarkdownInlineText(inlineContent[footnote.content] ?? AttributedString(footnote.content)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .accessibilityElement(children: .combine) + } + } + .font(.footnote) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } +} + +#Preview { + MarkdownFootnotesView( + footnotes: [ + MarkdownFootnote(number: 1, label: "source", content: "Source and additional context."), + MarkdownFootnote(number: 2, label: "details", content: "A longer note.\nWith a second line.") + ], + inlineContent: [:] + ) + .padding() +} diff --git a/openclient-llm/Shared/Features/Chat/Views/MarkdownInlineText.swift b/openclient-llm/Shared/Features/Chat/Views/MarkdownInlineText.swift new file mode 100644 index 00000000..60a80d0d --- /dev/null +++ b/openclient-llm/Shared/Features/Chat/Views/MarkdownInlineText.swift @@ -0,0 +1,52 @@ +// +// MarkdownInlineText.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 13/09/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import SwiftUI + +struct MarkdownInlineText: View { + @Environment(\.font) private var font + @Environment(\.fontResolutionContext) private var fontResolutionContext + + let content: AttributedString + + init(_ content: AttributedString) { + self.content = content + } + + var body: some View { + Text(styledContent) + } + + private var styledContent: AttributedString { + var result = content + for run in content.runs { + guard let position = run[MarkdownScriptAttribute.self] else { continue } + let baseFont = run.font ?? font ?? .body + let pointSize = baseFont.resolve(in: fontResolutionContext).pointSize + result[run.range].font = baseFont.scaled(by: 0.75) + result[run.range].baselineOffset = pointSize * (position > 0 ? 0.35 : -0.2) + } + return result + } +} + +#Preview("Subscripts and superscripts") { + var subscriptText = AttributedString("2") + subscriptText[MarkdownScriptAttribute.self] = -1 + var superscriptText = AttributedString("n + 1") + superscriptText[MarkdownScriptAttribute.self] = 1 + let content = AttributedString("H") + subscriptText + AttributedString("O and x") + superscriptText + + return VStack(alignment: .leading, spacing: 16) { + MarkdownInlineText(content).font(.body) + MarkdownInlineText(content).font(.title2) + MarkdownInlineText(content).font(.footnote) + MarkdownInlineText(content).font(.body).environment(\.dynamicTypeSize, .accessibility3) + } + .padding() +} diff --git a/openclient-llm/Shared/Features/Chat/Views/MarkdownTableView.swift b/openclient-llm/Shared/Features/Chat/Views/MarkdownTableView.swift index faca4875..5833a512 100644 --- a/openclient-llm/Shared/Features/Chat/Views/MarkdownTableView.swift +++ b/openclient-llm/Shared/Features/Chat/Views/MarkdownTableView.swift @@ -88,7 +88,7 @@ private extension MarkdownTableView { } func cellView(_ content: AttributedString, isBold: Bool) -> some View { - Text(content) + MarkdownInlineText(content) .font(isBold ? .subheadline.weight(.semibold) : .subheadline) .foregroundStyle(Color.primary) .textSelection(.enabled) diff --git a/openclient-llm/Shared/Features/Chat/Views/MessageBubbleView+Markdown.swift b/openclient-llm/Shared/Features/Chat/Views/MessageBubbleView+Markdown.swift index 932731e9..b16645fa 100644 --- a/openclient-llm/Shared/Features/Chat/Views/MessageBubbleView+Markdown.swift +++ b/openclient-llm/Shared/Features/Chat/Views/MessageBubbleView+Markdown.swift @@ -50,8 +50,16 @@ extension MessageBubbleView { TaskListView(items: items, inlineContent: renderedMarkdown.inlineContent) case .image(let alt, let url): MarkdownImageView(alt: alt, urlString: url, onLayoutChanged: onLayoutChanged) + case .footnote: + EmptyView() } } + if !renderedMarkdown.footnotes.isEmpty { + MarkdownFootnotesView( + footnotes: renderedMarkdown.footnotes, + inlineContent: renderedMarkdown.inlineContent + ) + } } } @@ -66,14 +74,14 @@ extension MessageBubbleView { } func textBlockView(_ content: String) -> some View { - Text(renderedMarkdown.attributedString(for: content)) + MarkdownInlineText(renderedMarkdown.attributedString(for: content)) .foregroundStyle(Color.primary) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } func headingBlockView(_ text: String, level: Int) -> some View { - Text(text) + MarkdownInlineText(renderedMarkdown.attributedString(for: text)) .font(headingFont(level)) .fontWeight(.semibold) .foregroundStyle(Color.primary) diff --git a/openclient-llm/Shared/Features/Chat/Views/MessageBubbleView+Previews.swift b/openclient-llm/Shared/Features/Chat/Views/MessageBubbleView+Previews.swift index e3d356db..0691f411 100644 --- a/openclient-llm/Shared/Features/Chat/Views/MessageBubbleView+Previews.swift +++ b/openclient-llm/Shared/Features/Chat/Views/MessageBubbleView+Previews.swift @@ -66,7 +66,11 @@ import SwiftUI #Preview("Assistant actions, compact") { MessageBubbleView( - message: ChatMessage(role: .assistant, content: "Would you like the story to continue?"), + message: ChatMessage( + role: .assistant, + content: "Would you like the story to continue?", + tokenUsage: TokenUsage(totalTokens: 6573) + ), hasTTS: true, isLastMessage: true, onSpeakTapped: {}, @@ -92,7 +96,11 @@ import SwiftUI #Preview("Assistant actions, large text") { MessageBubbleView( - message: ChatMessage(role: .assistant, content: "Would you like the story to continue?"), + message: ChatMessage( + role: .assistant, + content: "Would you like the story to continue?", + tokenUsage: TokenUsage(totalTokens: 6573) + ), hasTTS: true, isLastMessage: true, onSpeakTapped: {}, @@ -102,3 +110,91 @@ import SwiftUI .frame(width: 320) .environment(\.dynamicTypeSize, .accessibility3) } + +#Preview("Conversation spacing") { + ScrollView { + VStack(spacing: 16) { + MessageBubbleView( + message: ChatMessage( + role: .assistant, + content: "Would you like me to compare these images?", + tokenUsage: TokenUsage(totalTokens: 6655) + ), + hasTTS: true, + onSpeakTapped: {} + ) + MessageBubbleView(message: ChatMessage(role: .user, content: "Hello")) + MessageBubbleView( + message: ChatMessage( + role: .assistant, + content: "Hello! 😊\n\nHow can I help you today?", + tokenUsage: TokenUsage(totalTokens: 6573) + ), + hasTTS: true, + isLastMessage: true, + onSpeakTapped: {}, + onRegenerateTapped: {} + ) + } + .padding(16) + } + .frame(width: 390) +} + +#Preview("Table without outer pipes and footnotes") { + MessageBubbleView( + message: ChatMessage( + role: .assistant, + content: #""" + ## Comparison[^context] + + Name | Value | Pattern + --- | ---: | --- + **First** | 42[^source] | `a\|b` + Second | 18 | c\|d + + The same source is used again[^source]. + + [^source]: See the **original** [source](https://example.com). + [^context]: These values are examples. + This note continues on another line. + """# + ) + ) + .padding(16) + .frame(width: 390) +} + +#Preview("Sub and sup in Markdown") { + ScrollView { + MessageBubbleView( + message: ChatMessage( + role: .assistant, + content: #""" + ## H2O and xn + 1 + + **Water: H2O.** Powers: x2 + y2. + + A formatted link: [x*n*](https://example.com). + + - CO2 and aindex + - [x] Check xn + 1 + + > A quote containing H2O. + + Name | Value + --- | --- + Water | H2O + Power | xn + 1 + + Literal: `2` and \2\. + A reference[^note]. + + [^note]: Compare H2O with CO2. + """# + ) + ) + .padding(16) + } + .frame(width: 390) +} diff --git a/openclient-llm/Shared/Features/Chat/Views/MessageBubbleView.swift b/openclient-llm/Shared/Features/Chat/Views/MessageBubbleView.swift index aff2e302..cd604ff2 100644 --- a/openclient-llm/Shared/Features/Chat/Views/MessageBubbleView.swift +++ b/openclient-llm/Shared/Features/Chat/Views/MessageBubbleView.swift @@ -60,7 +60,7 @@ private extension MessageBubbleView { HStack { Spacer(minLength: 60) - VStack(alignment: .trailing, spacing: 6) { + VStack(alignment: .trailing, spacing: 2) { if !message.attachments.isEmpty { attachmentsView } @@ -92,7 +92,7 @@ private extension MessageBubbleView { .frame(width: 28, height: 28) .glassEffect(.regular, in: .circle) - VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 6) { if !message.attachments.isEmpty { attachmentsView } @@ -113,43 +113,13 @@ private extension MessageBubbleView { } } - VStack(alignment: .leading, spacing: 0) { - if !message.content.isEmpty || !message.attachments.isEmpty { - HStack(spacing: 8) { - if let usage = message.tokenUsage, !isStreaming, showTokenUsage { - tokenUsageLabel(usage) - } - - Spacer(minLength: 8) - - timestampLabel - messageActionsMenu - } - } - - if let results = message.webSearchResults, !results.isEmpty, !isStreaming { - WebSearchSourcesView(results: results) - .padding(.vertical, 10) - } - - FlowLayout(spacing: 8) { - if !isStreaming && !message.content.isEmpty && message.role == .assistant && hasTTS { - speakButton - } + if !message.content.isEmpty || !message.attachments.isEmpty { + assistantMessageFooter + } - if !isStreaming, !message.content.isEmpty, isLastMessage, let onRegenerateTapped { - Button(action: onRegenerateTapped) { - Label(String(localized: "Regenerate Response"), systemImage: "arrow.clockwise") - .font(.caption) - .foregroundStyle(.secondary) -#if os(iOS) - .frame(minHeight: 44) -#endif - .contentShape(.rect) - } - .buttonStyle(.plain) - } - } + if let results = message.webSearchResults, !results.isEmpty, !isStreaming { + WebSearchSourcesView(results: results) + .padding(.vertical, 10) } } .frame(minHeight: 28, alignment: .center) @@ -254,6 +224,59 @@ private extension MessageBubbleView { // MARK: - Message Actions + var assistantMessageFooter: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { + assistantTokenUsage + Spacer(minLength: 0) + assistantInlineActions + timestampLabel + messageActionsMenu + } + VStack(alignment: .leading, spacing: 0) { + assistantTokenUsage + HStack(spacing: 8) { + assistantInlineActions + Spacer(minLength: 0) + timestampLabel + messageActionsMenu + } + } + } + } + + @ViewBuilder + var assistantTokenUsage: some View { + if let usage = message.tokenUsage, !isStreaming, showTokenUsage { + tokenUsageLabel(usage) + } + } + + var assistantInlineActions: some View { + HStack(spacing: 0) { + if !isStreaming && !message.content.isEmpty && message.role == .assistant && hasTTS { + speakButton + } + if !isStreaming, !message.content.isEmpty, isLastMessage, let onRegenerateTapped { + Button(action: onRegenerateTapped) { + Label(String(localized: "Regenerate Response"), systemImage: "arrow.clockwise") + .labelStyle(.iconOnly) + .font(.caption) + .foregroundStyle(.secondary) +#if os(iOS) + .frame(width: 44, height: 44) +#else + .frame(width: 28, height: 28) +#endif + .contentShape(.rect) + } + .buttonStyle(.plain) + .accessibilityLabel(String(localized: "Regenerate Response")) + .help(String(localized: "Regenerate Response")) + } + } + } + var messageActionsMenu: some View { Menu { messageContextMenu(message.content) @@ -445,21 +468,23 @@ private extension MessageBubbleView { onSpeakTapped?() } } label: { - HStack(spacing: 4) { - Image(systemName: isSpeaking ? "stop.circle.fill" : "speaker.wave.2") - .font(.system(size: 10)) - Text(isSpeaking - ? String(localized: "Stop") - : String(localized: "Listen")) - .font(.caption) - } + Label( + isSpeaking ? String(localized: "Stop") : String(localized: "Listen"), + systemImage: isSpeaking ? "stop.circle.fill" : "speaker.wave.2" + ) + .labelStyle(.iconOnly) + .font(.caption) .foregroundStyle(isSpeaking ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary)) #if os(iOS) - .frame(minHeight: 44) + .frame(width: 44, height: 44) +#else + .frame(width: 28, height: 28) #endif .contentShape(.rect) } .buttonStyle(.plain) + .accessibilityLabel(isSpeaking ? String(localized: "Stop") : String(localized: "Listen")) + .help(isSpeaking ? String(localized: "Stop") : String(localized: "Listen")) } // MARK: - Image Actions diff --git a/openclient-llm/Shared/Features/Chat/Views/NumberedListView.swift b/openclient-llm/Shared/Features/Chat/Views/NumberedListView.swift index 4e35a185..87789532 100644 --- a/openclient-llm/Shared/Features/Chat/Views/NumberedListView.swift +++ b/openclient-llm/Shared/Features/Chat/Views/NumberedListView.swift @@ -41,7 +41,7 @@ private extension NumberedListView { .frame(minWidth: 24, alignment: .trailing) .padding(.leading, CGFloat(item.depth) * 16) - Text(inlineContent[item.content] ?? AttributedString(item.content)) + MarkdownInlineText(inlineContent[item.content] ?? AttributedString(item.content)) .font(.body) .foregroundStyle(Color.primary) .textSelection(.enabled) diff --git a/openclient-llm/Shared/Features/Chat/Views/TaskListView.swift b/openclient-llm/Shared/Features/Chat/Views/TaskListView.swift index 1b6db355..91ae1f9e 100644 --- a/openclient-llm/Shared/Features/Chat/Views/TaskListView.swift +++ b/openclient-llm/Shared/Features/Chat/Views/TaskListView.swift @@ -42,7 +42,7 @@ private extension TaskListView { .padding(.leading, CGFloat(item.depth) * 16) .accessibilityHidden(true) - Text(inlineContent[item.content] ?? AttributedString(item.content)) + MarkdownInlineText(inlineContent[item.content] ?? AttributedString(item.content)) .font(.body) .foregroundStyle(item.isChecked ? Color.primary.opacity(0.6) : Color.primary) .strikethrough(item.isChecked)