From 63b6d932bcc02a664ceb4afe42abcb2bab2acb10 Mon Sep 17 00:00:00 2001 From: Timothy Coleman Date: Thu, 22 Oct 2026 11:55:21 +0100 Subject: [PATCH 1/4] Put the machine chip before the repo chip in the launcher Picking a machine resets the repo to one of its own, so the machine is the choice the repo follows from. Co-Authored-By: Claude Opus 5.5 --- .../Views/LauncherPaneView.axaml | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/Capacitor.App/Views/LauncherPaneView.axaml b/src/Capacitor.App/Views/LauncherPaneView.axaml index 807b2d8b1..fac8b0f09 100644 --- a/src/Capacitor.App/Views/LauncherPaneView.axaml +++ b/src/Capacitor.App/Views/LauncherPaneView.axaml @@ -40,20 +40,11 @@ BorderThickness="0" MinHeight="72" MaxHeight="220" TextWrapping="Wrap" VerticalContentAlignment="Top" AcceptsReturn="True" /> - - - + + From 7245d677198cb2701e5ab119db342452372c30f5 Mon Sep 17 00:00:00 2001 From: Timothy Coleman Date: Thu, 22 Oct 2026 14:25:09 +0100 Subject: [PATCH 2/4] Render the user's chat turns as typed, with only Slack-style formatting Full markdown read a Windows path's \. as an escape, joined typed lines and collapsed blank lines. The pipeline is an allowlist so a parser a Markdig upgrade adds stays off Co-Authored-By: Claude Opus 5.5 --- src/Capacitor.App/GitHubHtml/ChatPipeline.cs | 57 +++++++++++++++++++ src/Capacitor.App/Views/ChatTabView.axaml | 2 +- src/Capacitor.App/Views/MarkdownFlavor.cs | 3 +- src/Capacitor.App/Views/MarkdownView.cs | 6 +- .../MarkdownViewTests.cs | 50 ++++++++++++++++ 5 files changed, 115 insertions(+), 3 deletions(-) diff --git a/src/Capacitor.App/GitHubHtml/ChatPipeline.cs b/src/Capacitor.App/GitHubHtml/ChatPipeline.cs index 8567e751c..371c0df7e 100644 --- a/src/Capacitor.App/GitHubHtml/ChatPipeline.cs +++ b/src/Capacitor.App/GitHubHtml/ChatPipeline.cs @@ -1,4 +1,7 @@ using Markdig; +using Markdig.Extensions.EmphasisExtras; +using Markdig.Parsers; +using Markdig.Parsers.Inlines; using Markdig.Renderers; using Markdig.Syntax; using Markdig.Syntax.Inlines; @@ -13,6 +16,60 @@ public static class ChatPipeline { public static MarkdownPipeline Instance { get; } = new MarkdownPipelineBuilder().UseSupportedExtensions().Use(new Hoist()).Build(); + /// What a person typed, shown as they typed it: emphasis, strikethrough, code, lists, quotes + /// and bare URLs render, and no other markdown rule applies. A backslash is never an escape — + /// in `C:\Users\me\.config` the `\.` is a path separator — and every newline is a line break. + public static MarkdownPipeline Prompt { get; } = PromptPipeline(); + + /// Only the parsers named here run, so a parser a Markdig upgrade adds stays off. Markdig's own + /// order is kept: a paragraph, and plain text after every inline parser, take what nothing + /// earlier claimed. The paragraph parser still reads a `[label]: url` line as a definition. + static MarkdownPipeline PromptPipeline() { + var builder = new MarkdownPipelineBuilder(); + builder.BlockParsers.Clear(); + builder.BlockParsers.AddRange([ + new QuoteBlockParser(), + new ListBlockParser(), + new FencedCodeBlockParser(), + new ParagraphBlockParser { ParseSetexHeadings = false }, + ]); + builder.InlineParsers.Clear(); + builder.InlineParsers.AddRange([ + new EmphasisInlineParser(), + new CodeInlineParser(), + new LineBreakInlineParser { EnableSoftAsHard = true }, + ]); + return builder + .UseAutoLinks() + .UseEmphasisExtras(EmphasisExtraOptions.Strikethrough) + .Use(new Hoist()) + .Use(new BlankLines()) + .Build(); + } + + sealed class BlankLines : IMarkdownExtension { + public void Setup(MarkdownPipelineBuilder pipeline) => pipeline.DocumentProcessed += Keep; + + public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) { } + + /// Markdown reads any run of blank lines as one paragraph break. Adjacent paragraphs are + /// joined instead, by a line break for each newline that separated them in the source. + static void Keep(MarkdownDocument document) { + foreach (var container in document.Descendants().Prepend(document).ToArray()) + for (var i = container.Count - 1; i > 0; i--) { + if (container[i - 1] is not ParagraphBlock { Inline: { } into } before + || container[i] is not ParagraphBlock { Inline: { } from } after) continue; + var lastLine = before.Line + into.Descendants().Count(); + for (var line = lastLine; line < after.Line; line++) into.AppendChild(new LineBreakInline { IsHard = true }); + while (from.FirstChild is { } inline) { + inline.Remove(); + into.AppendChild(inline); + } + container.RemoveAt(i); + } + } + } + sealed class Hoist : IMarkdownExtension { public void Setup(MarkdownPipelineBuilder pipeline) => pipeline.DocumentProcessed += Process; diff --git a/src/Capacitor.App/Views/ChatTabView.axaml b/src/Capacitor.App/Views/ChatTabView.axaml index f8f17ee16..ed05f6c3a 100644 --- a/src/Capacitor.App/Views/ChatTabView.axaml +++ b/src/Capacitor.App/Views/ChatTabView.axaml @@ -115,7 +115,7 @@ Background="{StaticResource KcapSurfaceRaisedBrush}"> - diff --git a/src/Capacitor.App/Views/MarkdownFlavor.cs b/src/Capacitor.App/Views/MarkdownFlavor.cs index 88de9d647..3a9ca682a 100644 --- a/src/Capacitor.App/Views/MarkdownFlavor.cs +++ b/src/Capacitor.App/Views/MarkdownFlavor.cs @@ -1,4 +1,5 @@ namespace Capacitor.App.Views; /// Chat shows HTML as source: a transcript is not written for github.com. The reader converts it. -public enum MarkdownFlavor { Chat, GitHub } +/// Prompt is what a person typed: shown as typed, with only the formatting they would type on purpose. +public enum MarkdownFlavor { Chat, GitHub, Prompt } diff --git a/src/Capacitor.App/Views/MarkdownView.cs b/src/Capacitor.App/Views/MarkdownView.cs index 76fac43f8..4e322e48f 100644 --- a/src/Capacitor.App/Views/MarkdownView.cs +++ b/src/Capacitor.App/Views/MarkdownView.cs @@ -230,7 +230,11 @@ void ApplyFlavor() { // After the highlighter, whose code block renderer this one wraps. _viewer.Extensions.Add(_codeActions); // Only the pipeline change re-renders, so it goes last. - _viewer.Pipeline = Flavor == MarkdownFlavor.GitHub ? GitHubPipeline.Instance : ChatPipeline.Instance; + _viewer.Pipeline = Flavor switch { + MarkdownFlavor.GitHub => GitHubPipeline.Instance, + MarkdownFlavor.Prompt => ChatPipeline.Prompt, + _ => ChatPipeline.Instance, + }; _selectionHost?.UpdateSelection(this); } diff --git a/test/Capacitor.App.Tests.Unit/MarkdownViewTests.cs b/test/Capacitor.App.Tests.Unit/MarkdownViewTests.cs index cf4d81362..7c87e0cd0 100644 --- a/test/Capacitor.App.Tests.Unit/MarkdownViewTests.cs +++ b/test/Capacitor.App.Tests.Unit/MarkdownViewTests.cs @@ -541,6 +541,56 @@ await RunOnUiAsync(async () => { }); } + /// Pins the prompt's subset: emphasis, strikethrough, code, lists, quotes and a bare URL + /// render, and nothing else markdown would read into typed text does — a heading, a rule, a + /// setext underline, HTML, an entity, link syntax, an indented block or a backslash escape. + [Test] + [NotInParallel("AvaloniaSession")] + public async Task A_prompt_renders_only_what_someone_types_on_purpose() { + await RunOnUiAsync(async () => { + var typed = "C:\\Users\\me\\.config \\*\n# not a heading\nunderlined\n---\n***\n& x [label](l.example)\n indented\n**bold** *em* ~~struck~~ `code` https://u.example\n\n```\nvar x = 1;\n```\n\n- one\n\n> quoted"; + var (window, root, opened) = Show(typed, MarkdownFlavor.Prompt); + try { + var paragraph = Paragraphs(root).First(); + await Assert.That(Reads(paragraph).Split('\n', StringSplitOptions.TrimEntries)).IsEquivalentTo([ + @"C:\Users\me\.config \*", "# not a heading", "underlined", "---", "***", + "& x [label](l.example)", "indented", "bold em struck code https://u.example", + ]); + await Assert.That(Spans(paragraph.Inlines!).Count()).IsEqualTo(1); + await Assert.That(Spans(paragraph.Inlines!).Count()).IsEqualTo(1); + await Assert.That(Spans(paragraph.Inlines!).Any(r => r.Text == "code" && r.Classes.Contains("markdown-code-inline"))).IsTrue(); + await Assert.That(Spans(paragraph.Inlines!).Select(l => l.NavigateUri!.ToString())) + .IsEquivalentTo(["https://u.example/"]); + await Assert.That(All(root).Any(s => s.Classes.Contains("markdown-thematic-break"))).IsFalse(); + var code = All(root).Single(b => b.Classes.Contains("markdown-code-block")); + await Assert.That(Reads(All(code).Single())).IsEqualTo("var x = 1;"); + await Assert.That(All(root).Count(t => t.Classes.Contains("markdown-list-marker") && t.Text == "•")).IsEqualTo(1); + var quote = All(root).Single(b => b.Classes.Contains("markdown-blockquote")); + await Assert.That(Paragraphs(quote).Select(Reads)).IsEquivalentTo(["quoted"]); + } finally { window.Close(); } + }); + } + + /// Pins that a prompt keeps its line breaks and its runs of blank lines, where chat joins the + /// lines of a paragraph and collapses blank lines to one paragraph gap. + [Test] + [NotInParallel("AvaloniaSession")] + public async Task A_prompt_keeps_its_newlines_and_blank_lines() { + await RunOnUiAsync(async () => { + const string typed = "one\ntwo\n\n\n\nthree"; + var (window, root, _) = Show(typed, MarkdownFlavor.Prompt); + try { + var paragraph = Paragraphs(root).Single(); + await Assert.That(Reads(paragraph).Split('\n', StringSplitOptions.TrimEntries)).IsEquivalentTo(["one", "two", "", "", "", "three"]); + } finally { window.Close(); } + + (window, root, _) = Show(typed); + try { + await Assert.That(Paragraphs(root).Select(Reads)).IsEquivalentTo(["one two", "three"]); + } finally { window.Close(); } + }); + } + /// Pins the degrade for text Markdig refuses to parse (128 nested quotes): the source as plain /// text, in either flavor, and the next parseable text renders as markdown again. [Test] From e56264e3c8d561a9a72fb7ae624eeceb41768093 Mon Sep 17 00:00:00 2001 From: Timothy Coleman Date: Thu, 22 Oct 2026 14:48:42 +0100 Subject: [PATCH 3/4] Keep standalone rules and code-span blank lines as typed in a prompt Markdig's list parser opens a thematic break itself, and a paragraph's source lines are released before the blank-line pass runs, so neither can be left to the parser allowlist Co-Authored-By: Claude Opus 5.5 --- src/Capacitor.App/GitHubHtml/ChatPipeline.cs | 29 ++++++++++++-- .../MarkdownViewTests.cs | 39 ++++++++++++++++--- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/src/Capacitor.App/GitHubHtml/ChatPipeline.cs b/src/Capacitor.App/GitHubHtml/ChatPipeline.cs index 371c0df7e..071617914 100644 --- a/src/Capacitor.App/GitHubHtml/ChatPipeline.cs +++ b/src/Capacitor.App/GitHubHtml/ChatPipeline.cs @@ -26,12 +26,14 @@ public static class ChatPipeline { /// earlier claimed. The paragraph parser still reads a `[label]: url` line as a definition. static MarkdownPipeline PromptPipeline() { var builder = new MarkdownPipelineBuilder(); + var paragraphs = new ParagraphBlockParser { ParseSetexHeadings = false }; + paragraphs.Closed += BlankLines.RecordLastLine; builder.BlockParsers.Clear(); builder.BlockParsers.AddRange([ new QuoteBlockParser(), - new ListBlockParser(), + new RulelessListBlockParser(), new FencedCodeBlockParser(), - new ParagraphBlockParser { ParseSetexHeadings = false }, + paragraphs, ]); builder.InlineParsers.Clear(); builder.InlineParsers.AddRange([ @@ -47,11 +49,32 @@ static MarkdownPipeline PromptPipeline() { .Build(); } + /// Markdig's list parser opens a thematic break itself, ahead of any list item, so leaving the + /// thematic break parser out is not enough to keep `---` or `***` as typed. + sealed class RulelessListBlockParser : ListBlockParser { + public override BlockState TryOpen(BlockProcessor processor) { + var rules = ThematicBreakParser.Default; + if (rules.HasOpeningCharacter(processor.CurrentChar) && rules.TryOpen(processor).IsBreak()) { + processor.NewBlocks.Pop(); + return BlockState.None; + } + return base.TryOpen(processor); + } + } + sealed class BlankLines : IMarkdownExtension { + static readonly object LastLineKey = new(); + public void Setup(MarkdownPipelineBuilder pipeline) => pipeline.DocumentProcessed += Keep; public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) { } + /// A paragraph's source lines are released once its inlines are parsed, and a code span + /// can cross a line without leaving a line break behind, so the last line is taken here. + public static void RecordLastLine(BlockProcessor processor, Block block) { + if (block is ParagraphBlock paragraph) paragraph.SetData(LastLineKey, paragraph.Line + Math.Max(paragraph.Lines.Count - 1, 0)); + } + /// Markdown reads any run of blank lines as one paragraph break. Adjacent paragraphs are /// joined instead, by a line break for each newline that separated them in the source. static void Keep(MarkdownDocument document) { @@ -59,7 +82,7 @@ static void Keep(MarkdownDocument document) { for (var i = container.Count - 1; i > 0; i--) { if (container[i - 1] is not ParagraphBlock { Inline: { } into } before || container[i] is not ParagraphBlock { Inline: { } from } after) continue; - var lastLine = before.Line + into.Descendants().Count(); + var lastLine = before.GetData(LastLineKey) as int? ?? before.Line; for (var line = lastLine; line < after.Line; line++) into.AppendChild(new LineBreakInline { IsHard = true }); while (from.FirstChild is { } inline) { inline.Remove(); diff --git a/test/Capacitor.App.Tests.Unit/MarkdownViewTests.cs b/test/Capacitor.App.Tests.Unit/MarkdownViewTests.cs index 7c87e0cd0..88f8c4872 100644 --- a/test/Capacitor.App.Tests.Unit/MarkdownViewTests.cs +++ b/test/Capacitor.App.Tests.Unit/MarkdownViewTests.cs @@ -10,6 +10,7 @@ using Avalonia.Threading; using Capacitor.App.Views; using MarkView.Avalonia; +using MarkView.Avalonia.Rendering.Inlines; using ReactiveUI.Reactive; using static Capacitor.App.Tests.Unit.AvaloniaSession; using static Capacitor.App.Tests.Unit.MarkdownViewHarness; @@ -552,10 +553,9 @@ await RunOnUiAsync(async () => { var (window, root, opened) = Show(typed, MarkdownFlavor.Prompt); try { var paragraph = Paragraphs(root).First(); - await Assert.That(Reads(paragraph).Split('\n', StringSplitOptions.TrimEntries)).IsEquivalentTo([ + await Assert.That(Reads(paragraph).ReplaceLineEndings("\n")).IsEqualTo(string.Join('\n', @"C:\Users\me\.config \*", "# not a heading", "underlined", "---", "***", - "& x [label](l.example)", "indented", "bold em struck code https://u.example", - ]); + "& x [label](l.example)", "indented", "bold em struck code https://u.example")); await Assert.That(Spans(paragraph.Inlines!).Count()).IsEqualTo(1); await Assert.That(Spans(paragraph.Inlines!).Count()).IsEqualTo(1); await Assert.That(Spans(paragraph.Inlines!).Any(r => r.Text == "code" && r.Classes.Contains("markdown-code-inline"))).IsTrue(); @@ -580,13 +580,40 @@ await RunOnUiAsync(async () => { const string typed = "one\ntwo\n\n\n\nthree"; var (window, root, _) = Show(typed, MarkdownFlavor.Prompt); try { - var paragraph = Paragraphs(root).Single(); - await Assert.That(Reads(paragraph).Split('\n', StringSplitOptions.TrimEntries)).IsEquivalentTo(["one", "two", "", "", "", "three"]); + await Assert.That(Reads(Paragraphs(root).Single()).ReplaceLineEndings("\n")).IsEqualTo(typed); } finally { window.Close(); } (window, root, _) = Show(typed); try { - await Assert.That(Paragraphs(root).Select(Reads)).IsEquivalentTo(["one two", "three"]); + await Assert.That(string.Join('|', Paragraphs(root).Select(Reads))).IsEqualTo("one two|three"); + } finally { window.Close(); } + }); + } + + /// Pins the paragraph's last line for a code span that crosses a newline: the blank line after + /// it is counted from where the span ends, not from the line it starts on. + [Test] + [NotInParallel("AvaloniaSession")] + public async Task A_prompt_counts_blank_lines_after_a_code_span_that_crosses_a_line() { + await RunOnUiAsync(async () => { + var (window, root, _) = Show("`x\ny`\n\nz", MarkdownFlavor.Prompt); + try { + await Assert.That(Reads(Paragraphs(root).Single()).ReplaceLineEndings("\n")).IsEqualTo("x y\n\nz"); + } finally { window.Close(); } + }); + } + + /// Pins that a rule standing on its own line stays text: Markdig's list parser opens one itself, + /// apart from the thematic break parser the prompt leaves out. + [Test] + [NotInParallel("AvaloniaSession")] + public async Task A_prompt_shows_a_standalone_rule_as_typed() { + await RunOnUiAsync(async () => { + const string typed = "---\n\n- - -\n\n***\n\n___"; + var (window, root, _) = Show(typed, MarkdownFlavor.Prompt); + try { + await Assert.That(All(root).Any(s => s.Classes.Contains("markdown-thematic-break"))).IsFalse(); + await Assert.That(Reads(Paragraphs(root).Single()).ReplaceLineEndings("\n")).IsEqualTo(typed); } finally { window.Close(); } }); } From 196e12691f98032a0f78536f8c05435a51870441 Mon Sep 17 00:00:00 2001 From: Timothy Coleman Date: Fri, 23 Oct 2026 12:30:55 +0100 Subject: [PATCH 4/4] Join a prompt's paragraphs front to back Joining from the end moved the inlines gathered so far again at every join, quadratic in the paragraph count of a long paste; the kept paragraph now takes over the joined one's last line Co-Authored-By: Claude Opus 5.5 --- src/Capacitor.App/GitHubHtml/ChatPipeline.cs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/Capacitor.App/GitHubHtml/ChatPipeline.cs b/src/Capacitor.App/GitHubHtml/ChatPipeline.cs index 071617914..3f7046546 100644 --- a/src/Capacitor.App/GitHubHtml/ChatPipeline.cs +++ b/src/Capacitor.App/GitHubHtml/ChatPipeline.cs @@ -79,18 +79,23 @@ public static void RecordLastLine(BlockProcessor processor, Block block) { /// joined instead, by a line break for each newline that separated them in the source. static void Keep(MarkdownDocument document) { foreach (var container in document.Descendants().Prepend(document).ToArray()) - for (var i = container.Count - 1; i > 0; i--) { - if (container[i - 1] is not ParagraphBlock { Inline: { } into } before - || container[i] is not ParagraphBlock { Inline: { } from } after) continue; - var lastLine = before.GetData(LastLineKey) as int? ?? before.Line; - for (var line = lastLine; line < after.Line; line++) into.AppendChild(new LineBreakInline { IsHard = true }); + for (var i = 0; i < container.Count - 1;) { + if (container[i] is not ParagraphBlock { Inline: { } into } before + || container[i + 1] is not ParagraphBlock { Inline: { } from } after) { + i++; + continue; + } + for (var line = LastLine(before); line < after.Line; line++) into.AppendChild(new LineBreakInline { IsHard = true }); while (from.FirstChild is { } inline) { inline.Remove(); into.AppendChild(inline); } - container.RemoveAt(i); + before.SetData(LastLineKey, LastLine(after)); + container.RemoveAt(i + 1); } } + + static int LastLine(ParagraphBlock paragraph) => paragraph.GetData(LastLineKey) as int? ?? paragraph.Line; } sealed class Hoist : IMarkdownExtension {