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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<sub>` and `<sup>` 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
Expand Down
3 changes: 3 additions & 0 deletions TestFlight/WhatToTest.en-US.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
101 changes: 101 additions & 0 deletions openclient-llm-test/Core/Utils/MarkdownParserTests+Footnotes.swift
Original file line number Diff line number Diff line change
@@ -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)])
}
}
90 changes: 90 additions & 0 deletions openclient-llm-test/Core/Utils/MarkdownParserTests+Media.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
}
}
114 changes: 114 additions & 0 deletions openclient-llm-test/Core/Utils/RenderedMarkdownTests+Escapes.swift
Original file line number Diff line number Diff line change
@@ -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 &amp; &lt;tag&gt;"#

// 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 & <tag>"#)
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\* &amp; <b>code</b> [^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\* &amp; <b>code</b> [^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 &amp; and &lt;tag&gt;"#,
"<b>Bold</b><br><i>Other inline HTML</i>",
#"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)
}
}
}
Loading