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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ submodule.

## [Unreleased]

### Added
- **Watch replies as they're written.** The agent's reply now appears in the transcript as it
streams, instead of all at once when it's finished. If the agent says something before using a
tool ("let me check the docs…"), that text becomes its own card above the tool call, and the
answer gets a second card. The conversation reads in the order it happened, instead of merging
everything into one card at the end.

## [0.15.0] — 2026-09-10

**A new AI foundation, plans you can resume, and more ways to work alongside your agents.**
Expand Down
118 changes: 116 additions & 2 deletions src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ private sealed class FakeAiService : IAiService
private readonly Exception? _throw;
public string? LastHostInstruction { get; private set; }

/// <summary>Runs before segment <c>i</c> is yielded: where a test streams chunks and starts
/// tool calls, the way the harness does while a turn is in flight.</summary>
public Action<int>? BeforeSegment { get; set; }

public event Action<string>? OnResponseTextDelta;
public void Emit(string text) => OnResponseTextDelta?.Invoke(text);

public FakeAiService(string[] segments, Exception? throwOnStream = null)
{
_segments = segments;
Expand All @@ -46,10 +53,11 @@ public async IAsyncEnumerable<string> ChatStreamAsync(
await Task.Yield();
throw _throw;
}
foreach (var s in _segments)
for (int i = 0; i < _segments.Length; i++)
{
await Task.Yield();
yield return s;
BeforeSegment?.Invoke(i);
yield return _segments[i];
}
}

Expand Down Expand Up @@ -107,6 +115,112 @@ public async Task EachTurn_BecomesItsOwnCard_AndReturnsJoinedText()
Assert.Equal(new[] { "a:hello", "a:world" }, logged);
}

private sealed class LiveLog
{
public readonly List<string> Events = new();

public LiveLog(TranscriptWriter transcript)
{
transcript.BlockAdded += b => Add($"block:{b}");
transcript.BlockJournaled += b => Add($"journal:{b}");
transcript.LiveTextChanged += (_, t) => Add($"live:{t}");
transcript.LiveCardSealed += (_, h) => Add($"seal:{h}");
transcript.LiveEnded += (_, keep) => Add($"end:{keep}");
}

private void Add(string e) { lock (Events) Events.Add(e); }
}

private static (ResponseStreamer streamer, LiveLog log) MakeLive(FakeAiService ai)
{
var transcript = new TranscriptWriter();
var log = new LiveLog(transcript);
var config = new MandoCodeConfig { EnableTokenTracking = false };
var streamer = new ResponseStreamer(
ai, transcript, new TagHtml(), new BusyStateService(), new TokenTrackingService(), config);
return (streamer, log);
}

[Fact]
public async Task TextBeforeAToolCall_BecomesItsOwnCard_AheadOfTheAnswer()
{
var ai = new FakeAiService(new[] { "Let me check the docs.\nHere is the answer." });
var (s, log) = MakeLive(ai);
ai.BeforeSegment = _ =>
{
ai.Emit("Let me check ");
ai.Emit("the docs.");
s.SealLiveText(); // what ChatController.OnFunctionInvoked does
ai.Emit("Here is the answer.");
};

var result = await s.StreamAsync("hi", CancellationToken.None);

Assert.Equal("Let me check the docs.\nHere is the answer.", result);
var settled = log.Events.Where(e => !e.StartsWith("live:")).ToList();
Assert.Equal(new[]
{
"seal:CARD:Let me check the docs.",
"journal:CARD:Let me check the docs.",
"block:CARD:Here is the answer.",
"end:True",
"end:False", // the next (never-started) turn is closed on the way out
}, settled);
}

[Fact]
public async Task RewrittenFinalText_DropsProvisionalCards_AndShowsTheFinalText()
{
// A model that writes its tool call as text: the fallback parser strips it after streaming,
// so the provisional card's raw text is not in the final reply and must not stay on screen.
var ai = new FakeAiService(new[] { "The file is updated." });
var (s, log) = MakeLive(ai);
ai.BeforeSegment = _ =>
{
ai.Emit("{\"name\":\"write_file\"}");
s.SealLiveText();
};

await s.StreamAsync("hi", CancellationToken.None);

Assert.DoesNotContain(log.Events, e => e.StartsWith("journal:"));
Assert.Contains("block:CARD:The file is updated.", log.Events);
Assert.Equal("end:False", log.Events.First(e => e.StartsWith("end:")));
}

[Fact]
public async Task StreamedChunks_RepaintTheDraft_WithTheWholeReplySoFar()
{
var ai = new FakeAiService(new[] { "Hello there" });
var (s, log) = MakeLive(ai);
s.LiveFlushInterval = TimeSpan.FromMilliseconds(5);
ai.BeforeSegment = _ =>
{
ai.Emit("Hello ");
ai.Emit("there");
Thread.Sleep(200); // let the throttled repaint land before the turn settles
};

await s.StreamAsync("hi", CancellationToken.None);

Assert.Contains("live:Hello there", log.Events);
Assert.DoesNotContain(log.Events, e => e.StartsWith("seal:"));
Assert.Contains("block:CARD:Hello there", log.Events);
}

[Fact]
public async Task SealWithoutAStreamingTurn_IsANoOp()
{
var ai = new FakeAiService(new[] { "done" });
var (s, log) = MakeLive(ai);

s.SealLiveText();
await s.StreamAsync("hi", CancellationToken.None);
s.SealLiveText();

Assert.DoesNotContain(log.Events, e => e.StartsWith("seal:"));
}

[Fact]
public async Task HostInstruction_UsesSeparateAiServiceChannel()
{
Expand Down
4 changes: 4 additions & 0 deletions src/MandoCode.Desktop/Assets/web/transcript/transcript.css
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,10 @@
.assistant { margin-top: 4px; position: relative; }
.assistant-label { color: var(--green); font-weight: 700; margin-bottom: 2px; }
.md p { margin: 6px 0; }
/* Live reply draft: plain text until the turn settles into a rendered card. No e-ink filter —
the draft repaints while it streams, and a filter would re-rasterize it on every repaint. */
.live-text { white-space: pre-wrap; overflow-wrap: anywhere; }
html[data-flat] .live-draft .md { filter: none; }
.md pre {
background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
padding: 10px 12px; overflow-x: auto; position: relative;
Expand Down
59 changes: 59 additions & 0 deletions src/MandoCode.Desktop/Assets/web/transcript/transcript.js
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,7 @@
const wrap = document.createElement('div');
wrap.innerHTML = html;
while (wrap.firstChild) placeChild(wrap.firstChild);
keepDraftLast();
highlightNew();
linkifyPaths();
addCopyChips();
Expand All @@ -523,6 +524,64 @@
};
window.__clear = function () { log.innerHTML = ''; updatePill(); };

// --- live reply draft: the streaming turn's text, repainted in place (throttled by the host) ---
// Plain text in one text node, so each repaint is a single node swap with no markdown or
// highlighting pass. Each turn has a generation number and an ended one never repaints: the host
// can deliver a late throttled update after the turn's end, and it must not bring the draft back.
let liveEnded = 0;
function keepDraftLast() {
const draft = document.getElementById('live-draft');
if (draft && draft !== log.lastElementChild) log.appendChild(draft);
}
window.__live = function (gen, label, text) {
if (gen <= liveEnded) return;
const nearBottom = (window.innerHeight + window.scrollY) >= (document.body.scrollHeight - 60);
let draft = document.getElementById('live-draft');
if (!draft) {
draft = document.createElement('div');
draft.id = 'live-draft';
// data-copy/data-rx pre-set: copy chips and reactions belong on the real card, not the draft.
draft.className = 'assistant live-draft';
draft.setAttribute('data-copy', '1');
draft.setAttribute('data-rx', '1');
draft.title = new Date().toLocaleTimeString();
const name = document.createElement('div');
name.className = 'assistant-label';
name.textContent = label;
const body = document.createElement('div');
// .md so every theme's message padding and type apply; data-fl pre-set so path
// linkifying skips it (the next repaint would wipe the links anyway).
body.className = 'md live-text';
body.setAttribute('data-fl', '1');
draft.appendChild(name);
draft.appendChild(body);
log.appendChild(draft);
}
draft.querySelector('.live-text').textContent = text;
if (nearBottom) window.scrollTo(0, document.body.scrollHeight);
updatePill();
};
// The draft so far became a real card because a tool call started. It stays provisional (marked
// with its turn) until __liveEnd decides whether the turn's final text agreed with it.
window.__liveSeal = function (gen, html) {
if (gen <= liveEnded) return;
const draft = document.getElementById('live-draft');
if (draft) draft.remove();
const before = log.lastElementChild;
window.__append(html);
for (let n = before ? before.nextElementSibling : log.firstElementChild; n; n = n.nextElementSibling)
n.setAttribute('data-live-seg', String(gen));
};
window.__liveEnd = function (gen, keep) {
if (gen > liveEnded) liveEnded = gen;
const draft = document.getElementById('live-draft');
if (draft) draft.remove();
log.querySelectorAll('[data-live-seg="' + gen + '"]').forEach(function (n) {
if (keep) n.removeAttribute('data-live-seg'); else n.remove();
});
updatePill();
};

document.addEventListener('click', function (e) {
const link = e.target.closest('a[data-file]');
if (!link) return;
Expand Down
57 changes: 57 additions & 0 deletions src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,63 @@ private async void AppendHtml(string html)
}
}

// ============================================================
// Live reply draft (the streaming turn; see ResponseStreamer)
// ============================================================

// Provisional cards of the current live turn that went to the pending queue because the
// WebView wasn't ready (a background agent's tab). Queued in place so they keep their order
// ahead of the turn's final card; pulled back out if the turn settles against them.
private readonly List<string> _queuedLiveSeals = new();
private long _queuedLiveGen;

// Live repaints are never queued: a draft only matters while it is visible, and the settled
// cards replace it either way.
private void UpdateLiveDraft(long gen, string text)
{
var core = CanScript ? TranscriptView.CoreWebView2 : null;
if (core == null) return;
var label = Session.Config.AgentName ?? "MandoCode";
_ = RunTranscriptScriptAsync(core,
$"window.__live({gen}, {JsonSerializer.Serialize(label)}, {JsonSerializer.Serialize(text)})");
}

private void SealLiveDraft(long gen, string html)
{
var core = CanScript ? TranscriptView.CoreWebView2 : null;
if (core == null)
{
if (gen != _queuedLiveGen) { _queuedLiveSeals.Clear(); _queuedLiveGen = gen; }
_queuedLiveSeals.Add(html);
_pendingHtml.Enqueue(html);
return;
}
_ = RunTranscriptScriptAsync(core, $"window.__liveSeal({gen}, {JsonSerializer.Serialize(html)})");
}

private void EndLiveDraft(long gen, bool keep)
{
if (gen == _queuedLiveGen && _queuedLiveSeals.Count > 0)
{
if (!keep)
{
var rest = _pendingHtml.Where(b => !_queuedLiveSeals.Any(s => ReferenceEquals(s, b))).ToList();
_pendingHtml.Clear();
foreach (var block in rest) _pendingHtml.Enqueue(block);
}
_queuedLiveSeals.Clear();
}
var core = CanScript ? TranscriptView.CoreWebView2 : null;
if (core == null) return;
_ = RunTranscriptScriptAsync(core, $"window.__liveEnd({gen}, {(keep ? "true" : "false")})");
}

private static async Task RunTranscriptScriptAsync(Microsoft.Web.WebView2.Core.CoreWebView2 core, string script)
{
try { await core.ExecuteScriptAsync(script); }
catch { /* transient during navigation/teardown */ }
}

/// <summary>Collapses the current group of routine tool/status output without touching visible
/// messages that need attention. If a turn ends before WebView initialization, apply it after
/// the queued blocks have reached the document.</summary>
Expand Down
9 changes: 9 additions & 0 deletions src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ public ChatTabView(Window owner, AgentSession session, TranscriptHtmlBuilder htm
_transcript.BlockAdded += OnTranscriptBlock;
_transcript.Cleared += OnTranscriptCleared;
_transcript.ActivityCompleted += OnTranscriptActivityCompleted;
_transcript.LiveTextChanged += OnLiveText;
_transcript.LiveCardSealed += OnLiveSealed;
_transcript.LiveEnded += OnLiveEnded;
Session.Busy.Changed += OnBusyChanged;
Session.TitleChanged += OnAgentTitleChanged;
Session.PreviewTools.ExecuteAsync = DispatchPreviewRequestAsync;
Expand All @@ -133,6 +136,9 @@ public ChatTabView(Window owner, AgentSession session, TranscriptHtmlBuilder htm
// Harness events arrive on background threads; each hop marshals to the UI thread.
private void OnTranscriptBlock(string html) => OnUi(() => AppendHtml(html));
private void OnTranscriptCleared() => OnUi(ClearTranscript);
private void OnLiveText(long gen, string text) => OnUi(() => UpdateLiveDraft(gen, text));
private void OnLiveSealed(long gen, string html) => OnUi(() => SealLiveDraft(gen, html));
private void OnLiveEnded(long gen, bool keep) => OnUi(() => EndLiveDraft(gen, keep));
private void OnTranscriptActivityCompleted() => OnUi(() =>
{
CompleteTranscriptActivity();
Expand Down Expand Up @@ -357,6 +363,9 @@ public void Shutdown()
_transcript.BlockAdded -= OnTranscriptBlock;
_transcript.Cleared -= OnTranscriptCleared;
_transcript.ActivityCompleted -= OnTranscriptActivityCompleted;
_transcript.LiveTextChanged -= OnLiveText;
_transcript.LiveCardSealed -= OnLiveSealed;
_transcript.LiveEnded -= OnLiveEnded;
Session.Busy.Changed -= OnBusyChanged;
Session.TitleChanged -= OnAgentTitleChanged;
Session.PreviewTools.ExecuteAsync = null;
Expand Down
1 change: 1 addition & 0 deletions src/MandoCode.Desktop/Services/AgentSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ public AgentSession(
// Journal every transcript block as it's written (tier-2 session persistence).
// /clear also clears the on-disk history — cleared means cleared, both files.
Transcript.BlockAdded += htmlBlock => TranscriptJournal.Append(PersistKey, htmlBlock);
Transcript.BlockJournaled += htmlBlock => TranscriptJournal.Append(PersistKey, htmlBlock);
Transcript.Cleared += () =>
{
TranscriptJournal.Delete(PersistKey);
Expand Down
8 changes: 8 additions & 0 deletions src/MandoCode.Desktop/Services/AiServiceAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ public event Action<FunctionExecutionResult>? OnFunctionCompleted
remove => _ai.OnFunctionCompleted -= value;
}

public event Action<string>? OnResponseTextDelta
{
add => _ai.OnResponseTextDelta += value;
remove => _ai.OnResponseTextDelta -= value;
}

public bool IsStreamingModelCall => _ai.IsStreamingModelCall;

public Func<string, string?, string, Task<DiffApprovalResult>>? OnWriteApprovalRequested
{
get => _ai.OnWriteApprovalRequested;
Expand Down
8 changes: 8 additions & 0 deletions src/MandoCode.Desktop/Services/IAiService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ bool TryAttachImage(ReadOnlyMemory<byte> bytes, string mediaType, string caption
event Action<FunctionCall>? OnFunctionInvoked;
event Action<FunctionExecutionResult>? OnFunctionCompleted;

/// <summary>Reply text as a chat turn streams — a preview only; the turn's yielded string stays
/// authoritative. Default no-op so a fake that doesn't stream needn't implement it.</summary>
event Action<string>? OnResponseTextDelta { add { } remove { } }

/// <summary>True while a model call is streaming. A tool call starting now was made mid-reply,
/// so the text before it is final; a text-written call runs after the stream, when it's false.</summary>
bool IsStreamingModelCall => false;

Func<string, string?, string, Task<DiffApprovalResult>>? OnWriteApprovalRequested { get; set; }
Func<string, string?, Task<DiffApprovalResult>>? OnDeleteApprovalRequested { get; set; }
Func<string, Task<DiffApprovalResult>>? OnCommandApprovalRequested { get; set; }
Expand Down
Loading
Loading