From ad976ab5783431c5d7eb13478f51b7b90ea474a1 Mon Sep 17 00:00:00 2001 From: Armando Fernandez Date: Thu, 24 Sep 2026 14:01:59 -0700 Subject: [PATCH 1/3] Stream reply text to hosts and split CLI output at tool calls AIService now raises OnResponseTextDelta with each chunk of a streaming chat turn, OnResponseStreamStarted at the start of every model call attempt, and exposes IsStreamingModelCall. A tool call that starts while the flag is true was made mid-reply, so the text before it is final; a text-written call runs after the stream, when it is false. The CLI uses these to print the text written before a tool call as its own block above the call's line, then prints only the rest of the turn once it finishes. Each auto-continuation turn prints as its own block. ReplyText.Unshown reconciles what was printed early with the turn's authoritative text, and is shared with the Desktop app. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/MandoCode/Components/App.razor | 101 +++++++++++++++++++++++- src/MandoCode/Services/Ai/AIService.cs | 54 +++++++++++-- src/MandoCode/Services/Ai/ReplyText.cs | 54 +++++++++++++ tests/MandoCode.Tests/ReplyTextTests.cs | 40 ++++++++++ 4 files changed, 239 insertions(+), 10 deletions(-) create mode 100644 src/MandoCode/Services/Ai/ReplyText.cs create mode 100644 tests/MandoCode.Tests/ReplyTextTests.cs diff --git a/src/MandoCode/Components/App.razor b/src/MandoCode/Components/App.razor index eedf78c..d252175 100644 --- a/src/MandoCode/Components/App.razor +++ b/src/MandoCode/Components/App.razor @@ -191,8 +191,18 @@ // Event handler refs, stored so Dispose can detach them cleanly private Action? _onFunctionInvokedHandler; private Action? _onFunctionCompletedHandler; + private Action? _onResponseTextDeltaHandler; + private Action? _onResponseStreamStartedHandler; private ConsoleCancelEventHandler? _cancelKeyHandler; + // Reply text streamed since the last print, and what was already printed early for the turn + // in flight. A tool call prints the text before it (see PrintReplySoFar); the turn's final + // text then prints only the rest (SettleReply). Open only during a direct chat request. + private readonly object _replyLock = new(); + private readonly System.Text.StringBuilder _replyUnprinted = new(); + private readonly List _replyPrinted = new(); + private bool _replyOpen; + // Music visualizer — animated equalizer while music plays private CancellationTokenSource? _musicVisCts; private Task? _musicVisTask; @@ -306,6 +316,10 @@ _onFunctionCompletedHandler = OnFunctionCompleted; AI.OnFunctionInvoked += _onFunctionInvokedHandler; AI.OnFunctionCompleted += _onFunctionCompletedHandler; + _onResponseTextDeltaHandler = OnResponseTextDelta; + _onResponseStreamStartedHandler = OnResponseStreamStarted; + AI.OnResponseTextDelta += _onResponseTextDeltaHandler; + AI.OnResponseStreamStarted += _onResponseStreamStartedHandler; // Wire the propose_plan tool handoff to the approval + execution UI PlanHandoff.OnPlanRequested = HandleProposedPlanAsync; @@ -786,6 +800,9 @@ // Pause the spinner so the message renders on a clean line Spinner.Stop(); + // What the model wrote before this call prints now, above the call's line. + PrintReplySoFar(); + // Track operation type for contextual spinner messages _lastOperationType = call.FunctionName.Replace("FileSystem_", "").ToLowerInvariant(); @@ -800,6 +817,70 @@ Spinner.Start(call.Description); } + private void OpenReply() + { + lock (_replyLock) + { + _replyOpen = true; + _replyUnprinted.Clear(); + _replyPrinted.Clear(); + } + } + + private void CloseReply() + { + lock (_replyLock) _replyOpen = false; + } + + private void OnResponseTextDelta(string text) + { + lock (_replyLock) + { + if (_replyOpen) _replyUnprinted.Append(text); + } + } + + // A retried model call streams its reply again from the start. + private void OnResponseStreamStarted() + { + lock (_replyLock) _replyUnprinted.Clear(); + } + + /// + /// Prints the reply streamed so far as its own block, ahead of the tool call about to run. + /// Only for a call the model made mid-stream (): a + /// tool call written as text runs after the stream, and its raw text is about to be stripped + /// from the reply, so it's left for the final text. Scrollback can't take a print back. + /// + private void PrintReplySoFar() + { + string text; + lock (_replyLock) + { + if (!_replyOpen || !AI.IsStreamingModelCall) return; + text = _replyUnprinted.ToString().Trim(); + _replyUnprinted.Clear(); + if (text.Length == 0) return; + _replyPrinted.Add(text); + } + AnsiConsole.MarkupLine("[green]MandoCode:[/]"); + RenderMarkdownGuarded(text); + } + + /// A turn finished with : returns what of it still needs + /// printing, and resets for the next turn. + private string SettleReply(string turnText) + { + List printed; + lock (_replyLock) + { + printed = _replyPrinted.ToList(); + _replyPrinted.Clear(); + _replyUnprinted.Clear(); + } + return ReplyText.Unshown(turnText, printed); + } + private void OnFunctionCompleted(MandoCode.Models.FunctionExecutionResult result) { // Pause the spinner so the message renders on a clean line @@ -1687,6 +1768,7 @@ var token = _requestCts.Token; StartCancelKeyListener(); + OpenReply(); try { var receivedFirstChunk = false; @@ -1721,8 +1803,17 @@ var lineBuffer = new System.Text.StringBuilder(); var showSpinnerBeforeNextChunk = false; + // Each chunk is one finished turn. Print what of it wasn't already printed at a + // tool call, so each turn reads in the order it happened. void AccumulateChunk(string chunk) { + var unprinted = SettleReply(chunk); + if (unprinted.Length > 0) + { + AnsiConsole.MarkupLine("[green]MandoCode:[/]"); + RenderMarkdownGuarded(unprinted); + } + responseBuffer.Append(chunk); foreach (char c in chunk) { @@ -1760,7 +1851,7 @@ AccumulateChunk(enumerator.Current); } - // Render the complete response as rich markdown + // Every turn is on screen by now; the joined text is for history and checks. var responseText = responseBuffer.ToString().Trim(); if (string.IsNullOrEmpty(responseText)) { @@ -1771,9 +1862,6 @@ { _lastAiResponse = responseText; - AnsiConsole.MarkupLine("[green]MandoCode:[/]"); - RenderMarkdownGuarded(responseText); - // Add to chat history (ANSI capture deferred to Phase 2) _messages.Add(new ChatMsg { @@ -1858,6 +1946,7 @@ } finally { + CloseReply(); StopCancelKeyListener(); var oldCts = Interlocked.Exchange(ref _requestCts, null); oldCts?.Dispose(); @@ -3760,6 +3849,10 @@ AI.OnFunctionInvoked -= _onFunctionInvokedHandler; if (_onFunctionCompletedHandler != null) AI.OnFunctionCompleted -= _onFunctionCompletedHandler; + if (_onResponseTextDeltaHandler != null) + AI.OnResponseTextDelta -= _onResponseTextDeltaHandler; + if (_onResponseStreamStartedHandler != null) + AI.OnResponseStreamStarted -= _onResponseStreamStartedHandler; if (_cancelKeyHandler != null) Console.CancelKeyPress -= _cancelKeyHandler; diff --git a/src/MandoCode/Services/Ai/AIService.cs b/src/MandoCode/Services/Ai/AIService.cs index 31615e8..0788f1a 100644 --- a/src/MandoCode/Services/Ai/AIService.cs +++ b/src/MandoCode/Services/Ai/AIService.cs @@ -202,6 +202,37 @@ private void ResetImageBudget() /// public event Action? OnFunctionCompleted; + /// + /// Raised with each chunk of reply text while a chat turn streams, so a host can show the reply + /// as it is written. Fires only when streams the + /// current model, and only for chat turns (plan steps narrate through the spinner instead). The + /// chunks are a preview: the string yields for the turn stays the + /// authoritative text — the fallback parser, notices, and a retried call can all make it differ. + /// + public event Action? OnResponseTextDelta; + + // A host's preview handler must never break the generation it is watching. + private void RaiseResponseTextDelta(string text) + { + try { OnResponseTextDelta?.Invoke(text); } catch { } + } + + /// + /// Raised as each streaming model call starts, retries included. Chunks a host buffered before + /// it belong to an earlier attempt and will not be in this one's reply. + /// + public event Action? OnResponseStreamStarted; + + private int _streamingModelCalls; + + /// + /// True while a model call is streaming its reply. A tool call that starts while this is true + /// was made by the model mid-reply, so the text streamed before it is final and safe to show + /// for good. A tool call the model wrote as text is run by the fallback parser only after the + /// call returns, when this is false — its raw text is not final and must not be shown early. + /// + public bool IsStreamingModelCall => Volatile.Read(ref _streamingModelCalls) > 0; + /// /// Exposes the completion tracker for external consumers (e.g., TaskPlannerService). /// @@ -951,7 +982,8 @@ public async IAsyncEnumerable ChatStreamWithHostInstructionAsync( retryOperationName: "ChatStreamAsync", tokenLabel: "Chat", spinnerMessage: "Thinking… (Esc to cancel)", - cancellationToken); + cancellationToken, + onTextDelta: RaiseResponseTextDelta); var rawResponse = string.IsNullOrEmpty(result.Text) ? "No response from AI." : result.Text; response = _config.EnableFallbackFunctionParsing @@ -1411,11 +1443,21 @@ private async Task InvokeAgentChatAsync( return await _agent!.RunAsync(messages, session: null, cancellationToken: linkedToken); var timeout = TimeSpan.FromSeconds(_config.ModelResponseTimeoutSeconds); - return await StreamBuffering.BufferAsync( - _agent!.RunStreamingAsync(messages, session: null, cancellationToken: linkedToken), - onChunk: () => { try { responseCts.CancelAfter(timeout); } catch (ObjectDisposedException) { } }, - onText: onTextDelta, - cancellationToken: linkedToken); + Interlocked.Increment(ref _streamingModelCalls); + try + { + // Once per attempt, so a retried call's host can drop chunks from the failed attempt. + try { OnResponseStreamStarted?.Invoke(); } catch { } + return await StreamBuffering.BufferAsync( + _agent!.RunStreamingAsync(messages, session: null, cancellationToken: linkedToken), + onChunk: () => { try { responseCts.CancelAfter(timeout); } catch (ObjectDisposedException) { } }, + onText: onTextDelta, + cancellationToken: linkedToken); + } + finally + { + Interlocked.Decrement(ref _streamingModelCalls); + } } /// diff --git a/src/MandoCode/Services/Ai/ReplyText.cs b/src/MandoCode/Services/Ai/ReplyText.cs new file mode 100644 index 0000000..3e97526 --- /dev/null +++ b/src/MandoCode/Services/Ai/ReplyText.cs @@ -0,0 +1,54 @@ +using System.Text; + +namespace MandoCode.Services; + +/// +/// Reconciles reply text a host already showed early (the part streamed before a tool call) with +/// the turn's authoritative text, which yields only once +/// the turn is over. Shared by the CLI and Desktop so both show the rest of a turn the same way. +/// +public static class ReplyText +{ + /// + /// Removes each of from , in order, ignoring + /// whitespace differences (a turn's messages are joined with separators the stream never + /// carried), and returns what is left. False when any part is missing: the final text was + /// rewritten, e.g. by the fallback parser stripping a text-written tool call. + /// + public static bool TryRemoveInOrder(string text, IReadOnlyList parts, out string? rest) + { + rest = null; + var squeezed = new StringBuilder(text.Length); + var origin = new List(text.Length); + for (int i = 0; i < text.Length; i++) + { + if (char.IsWhiteSpace(text[i])) continue; + squeezed.Append(text[i]); + origin.Add(i); + } + var haystack = squeezed.ToString(); + + var removed = new bool[text.Length]; + int from = 0; + foreach (var part in parts) + { + var needle = string.Concat(part.Where(c => !char.IsWhiteSpace(c))); + if (needle.Length == 0) continue; + int at = haystack.IndexOf(needle, from, StringComparison.Ordinal); + if (at < 0) return false; + for (int i = origin[at]; i <= origin[at + needle.Length - 1]; i++) removed[i] = true; + from = at + needle.Length; + } + + var kept = new StringBuilder(text.Length); + for (int i = 0; i < text.Length; i++) + if (!removed[i]) kept.Append(text[i]); + rest = kept.ToString(); + return true; + } + + /// The part of not yet shown, trimmed: everything when nothing + /// was shown early, or when the early parts can't be found in it. + public static string Unshown(string text, IReadOnlyList shown) => + shown.Count > 0 && TryRemoveInOrder(text, shown, out var rest) ? rest!.Trim() : text.Trim(); +} diff --git a/tests/MandoCode.Tests/ReplyTextTests.cs b/tests/MandoCode.Tests/ReplyTextTests.cs new file mode 100644 index 0000000..673687e --- /dev/null +++ b/tests/MandoCode.Tests/ReplyTextTests.cs @@ -0,0 +1,40 @@ +using MandoCode.Services; +using Xunit; + +namespace MandoCode.Tests; + +public class ReplyTextTests +{ + [Theory] + [InlineData("A.\n\nB.", new[] { "A." }, "\n\nB.")] + [InlineData("First part\nsecond part. Then more.", new[] { "First part second part." }, " Then more.")] + [InlineData("one two three", new[] { "one", "three" }, " two ")] + public void TryRemoveInOrder_IgnoresWhitespace_AndReturnsTheRest(string text, string[] parts, string expected) + { + Assert.True(ReplyText.TryRemoveInOrder(text, parts, out var rest)); + Assert.Equal(expected, rest); + } + + [Theory] + [InlineData("B then A", new[] { "A", "B" })] + [InlineData("rewritten", new[] { "original" })] + public void TryRemoveInOrder_FailsWhenAPartIsMissingOrOutOfOrder(string text, string[] parts) + { + Assert.False(ReplyText.TryRemoveInOrder(text, parts, out _)); + } + + [Fact] + public void Unshown_ReturnsTheRest_WhenTheEarlyPartsMatch() + { + Assert.Equal("Here is the answer.", + ReplyText.Unshown("Let me check the docs.\nHere is the answer.", new[] { "Let me check the docs." })); + } + + [Fact] + public void Unshown_ReturnsEverything_WhenNothingWasShown_OrTheTextWasRewritten() + { + Assert.Equal("The answer.", ReplyText.Unshown(" The answer. ", Array.Empty())); + Assert.Equal("The file is updated.", + ReplyText.Unshown("The file is updated.", new[] { "{\"name\":\"write_file\"}" })); + } +} From fd623aa68189ef49737e6c192b841e64a93bfcb2 Mon Sep 17 00:00:00 2001 From: Armando Fernandez Date: Thu, 24 Sep 2026 14:02:00 -0700 Subject: [PATCH 2/3] Fix: keep loose list numbers on the same line as their text A list with blank lines between items is loose, so Markdig wraps each item's text in

with whitespace-only text nodes around it. That whitespace led the item, pushing its text below the number and leaving a blank line after every item. The item's inline text is now trimmed at the ends; spacing between inline elements and the break between two paragraphs are kept. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Rendering/MarkdownHtmlRenderer.cs | 19 +++++--- .../MarkdownHtmlRendererTests.cs | 43 +++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/src/MandoCode/Services/Rendering/MarkdownHtmlRenderer.cs b/src/MandoCode/Services/Rendering/MarkdownHtmlRenderer.cs index 598a7df..7e131e0 100644 --- a/src/MandoCode/Services/Rendering/MarkdownHtmlRenderer.cs +++ b/src/MandoCode/Services/Rendering/MarkdownHtmlRenderer.cs @@ -225,13 +225,21 @@ private static IRenderable BuildListItemContent(HtmlNode li) var blocks = new List(); var inlineBuffer = new StringBuilder(); + // A loose item's

sits between whitespace-only text nodes (Markdig's own newlines). Kept, + // they lead the item — its text drops below its number — and trail it as a blank line. Only + // the ends are trimmed: whitespace between inline elements is real spacing. + void TrimInlineEnd() + { + var end = inlineBuffer.Length; + while (end > 0 && char.IsWhiteSpace(inlineBuffer[end - 1])) end--; + inlineBuffer.Length = end; + } + void FlushInline() { - if (inlineBuffer.Length > 0) - { - blocks.Add(new Markup(inlineBuffer.ToString())); - inlineBuffer.Clear(); - } + var text = inlineBuffer.ToString().Trim(); + inlineBuffer.Clear(); + if (text.Length > 0) blocks.Add(new Markup(text)); } foreach (var child in li.ChildNodes) @@ -244,6 +252,7 @@ void FlushInline() if (child.NodeType == HtmlNodeType.Element && string.Equals(child.Name, "p", StringComparison.OrdinalIgnoreCase)) { + TrimInlineEnd(); if (inlineBuffer.Length > 0) inlineBuffer.Append("\n\n"); AppendInlines(child, inlineBuffer); } diff --git a/tests/MandoCode.Tests/MarkdownHtmlRendererTests.cs b/tests/MandoCode.Tests/MarkdownHtmlRendererTests.cs index edc9b89..3dfab00 100644 --- a/tests/MandoCode.Tests/MarkdownHtmlRendererTests.cs +++ b/tests/MandoCode.Tests/MarkdownHtmlRendererTests.cs @@ -96,6 +96,49 @@ public void Headings_render_with_hash_prefix() Assert.Contains("### Sources", rendered); } + [Fact] + public void Loose_ordered_list_keeps_each_number_on_its_items_first_line() + { + // Blank lines between items make the list "loose": Markdig wraps each item's text in

, + // with whitespace-only text around it. That whitespace used to lead the item, pushing the + // text onto the line below its number and leaving a blank line after every item. + var markdown = "1. **Record inflows** — ETF money.\n\n2. **Upgrades** — Firedancer.\n\n3. **Growth** — more volume."; + var lines = RenderPlain(MarkdownHtmlRenderer.BuildRenderable(markdown)) + .Split('\n').Select(l => l.TrimEnd()).ToList(); + + Assert.Contains(lines, l => l.StartsWith("1. Record inflows")); + Assert.Contains(lines, l => l.StartsWith("2. Upgrades")); + Assert.Contains(lines, l => l.StartsWith("3. Growth")); + Assert.DoesNotContain(lines, l => l.Trim() is "1." or "2." or "3."); + } + + [Fact] + public void Loose_list_item_with_two_paragraphs_keeps_the_break_between_them() + { + var markdown = "1. First paragraph.\n\n Second paragraph.\n\n2. Next item."; + var lines = RenderPlain(MarkdownHtmlRenderer.BuildRenderable(markdown)) + .Split('\n').Select(l => l.TrimEnd()).ToList(); + + var first = lines.FindIndex(l => l.StartsWith("1. First paragraph.")); + Assert.True(first >= 0); + Assert.Equal("", lines[first + 1].Trim()); + Assert.Contains("Second paragraph.", lines[first + 2]); + } + + private static string RenderPlain(IRenderable renderable) + { + var writer = new StringWriter(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(writer), + }); + console.Profile.Width = 100; + console.Write(renderable); + return writer.ToString().Replace("\r\n", "\n"); + } + private static string RenderToString(IRenderable renderable) { var writer = new StringWriter(); From 6652b4d299ff1042bb0c0a55b58552d7f9b6afd8 Mon Sep 17 00:00:00 2001 From: Armando Fernandez Date: Thu, 24 Sep 2026 15:28:26 -0700 Subject: [PATCH 3/3] Docs: record the reply split and list fix in the changelog Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f732403..151bbfb 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to MandoCode will be documented in this file. +## [Unreleased] + +### Added +- **Replies read in the order they happened.** When the model says something before it uses a + tool ("let me check the docs…"), the CLI now prints that as its own block above the tool's line, + and the answer that follows as a second block. Before, everything waited until the end and came + out as one merged block. Auto-continued turns also print as each one finishes. Apps built on the + engine, like MandoCode Desktop, can now follow a reply as it streams. + +### Fixed +- **Numbered lists keep each number beside its text.** When a model left blank lines between list + items, every number sat alone on its own line with the text underneath and an empty line after + it. Items now render on one line, as intended. + ## [0.15.0] - 2026-09-10 **MandoCode's AI engine now runs on Microsoft Agent Framework.** The chat layer, its tools, and