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
14 changes: 14 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
101 changes: 97 additions & 4 deletions src/MandoCode/Components/App.razor
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,18 @@
// Event handler refs, stored so Dispose can detach them cleanly
private Action<FunctionCall>? _onFunctionInvokedHandler;
private Action<FunctionExecutionResult>? _onFunctionCompletedHandler;
private Action<string>? _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<string> _replyPrinted = new();
private bool _replyOpen;

// Music visualizer — animated equalizer while music plays
private CancellationTokenSource? _musicVisCts;
private Task? _musicVisTask;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();

Expand All @@ -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();
}

/// <summary>
/// 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 (<see cref="AIService.IsStreamingModelCall"/>): 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.
/// </summary>
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);
}

/// <summary>A turn finished with <paramref name="turnText"/>: returns what of it still needs
/// printing, and resets for the next turn.</summary>
private string SettleReply(string turnText)
{
List<string> 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
Expand Down Expand Up @@ -1687,6 +1768,7 @@
var token = _requestCts.Token;
StartCancelKeyListener();

OpenReply();
try
{
var receivedFirstChunk = false;
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -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))
{
Expand All @@ -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
{
Expand Down Expand Up @@ -1858,6 +1946,7 @@
}
finally
{
CloseReply();
StopCancelKeyListener();
var oldCts = Interlocked.Exchange(ref _requestCts, null);
oldCts?.Dispose();
Expand Down Expand Up @@ -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;

Expand Down
54 changes: 48 additions & 6 deletions src/MandoCode/Services/Ai/AIService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,37 @@ private void ResetImageBudget()
/// </summary>
public event Action<FunctionExecutionResult>? OnFunctionCompleted;

/// <summary>
/// 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 <see cref="MandoCodeConfig.StreamingMode"/> streams the
/// current model, and only for chat turns (plan steps narrate through the spinner instead). The
/// chunks are a preview: the string <see cref="ChatStreamAsync"/> yields for the turn stays the
/// authoritative text — the fallback parser, notices, and a retried call can all make it differ.
/// </summary>
public event Action<string>? OnResponseTextDelta;

// A host's preview handler must never break the generation it is watching.
private void RaiseResponseTextDelta(string text)
{
try { OnResponseTextDelta?.Invoke(text); } catch { }
}

/// <summary>
/// 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.
/// </summary>
public event Action? OnResponseStreamStarted;

private int _streamingModelCalls;

/// <summary>
/// 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.
/// </summary>
public bool IsStreamingModelCall => Volatile.Read(ref _streamingModelCalls) > 0;

/// <summary>
/// Exposes the completion tracker for external consumers (e.g., TaskPlannerService).
/// </summary>
Expand Down Expand Up @@ -951,7 +982,8 @@ public async IAsyncEnumerable<string> 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
Expand Down Expand Up @@ -1411,11 +1443,21 @@ private async Task<AgentResponse> 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);
}
}

/// <summary>
Expand Down
54 changes: 54 additions & 0 deletions src/MandoCode/Services/Ai/ReplyText.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using System.Text;

namespace MandoCode.Services;

/// <summary>
/// Reconciles reply text a host already showed early (the part streamed before a tool call) with
/// the turn's authoritative text, which <see cref="AIService.ChatStreamAsync"/> yields only once
/// the turn is over. Shared by the CLI and Desktop so both show the rest of a turn the same way.
/// </summary>
public static class ReplyText
{
/// <summary>
/// Removes each of <paramref name="parts"/> from <paramref name="text"/>, 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.
/// </summary>
public static bool TryRemoveInOrder(string text, IReadOnlyList<string> parts, out string? rest)
{
rest = null;
var squeezed = new StringBuilder(text.Length);
var origin = new List<int>(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;
}

/// <summary>The part of <paramref name="text"/> not yet shown, trimmed: everything when nothing
/// was shown early, or when the early parts can't be found in it.</summary>
public static string Unshown(string text, IReadOnlyList<string> shown) =>
shown.Count > 0 && TryRemoveInOrder(text, shown, out var rest) ? rest!.Trim() : text.Trim();
}
19 changes: 14 additions & 5 deletions src/MandoCode/Services/Rendering/MarkdownHtmlRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -225,13 +225,21 @@ private static IRenderable BuildListItemContent(HtmlNode li)
var blocks = new List<IRenderable>();
var inlineBuffer = new StringBuilder();

// A loose item's <p> 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)
Expand All @@ -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);
}
Expand Down
Loading