diff --git a/CHANGELOG.md b/CHANGELOG.md
index 200f761..949692c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.**
diff --git a/MandoCode b/MandoCode
index e675782..9904aad 160000
--- a/MandoCode
+++ b/MandoCode
@@ -1 +1 @@
-Subproject commit e67578251c5716a6ede192a2e3e82e7dfda7c8f0
+Subproject commit 9904aad8c187bddef7c00fe8d2f88bd8f8af4904
diff --git a/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs b/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs
index 16e85cf..a38b2d6 100644
--- a/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs
+++ b/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs
@@ -32,6 +32,13 @@ private sealed class FakeAiService : IAiService
private readonly Exception? _throw;
public string? LastHostInstruction { get; private set; }
+ /// Runs before segment i is yielded: where a test streams chunks and starts
+ /// tool calls, the way the harness does while a turn is in flight.
+ public Action? BeforeSegment { get; set; }
+
+ public event Action? OnResponseTextDelta;
+ public void Emit(string text) => OnResponseTextDelta?.Invoke(text);
+
public FakeAiService(string[] segments, Exception? throwOnStream = null)
{
_segments = segments;
@@ -46,10 +53,11 @@ public async IAsyncEnumerable 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];
}
}
@@ -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 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()
{
diff --git a/src/MandoCode.Desktop/Assets/web/transcript/transcript.css b/src/MandoCode.Desktop/Assets/web/transcript/transcript.css
index 52c1205..e9cb92e 100644
--- a/src/MandoCode.Desktop/Assets/web/transcript/transcript.css
+++ b/src/MandoCode.Desktop/Assets/web/transcript/transcript.css
@@ -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;
diff --git a/src/MandoCode.Desktop/Assets/web/transcript/transcript.js b/src/MandoCode.Desktop/Assets/web/transcript/transcript.js
index f60f9f8..7ca0967 100644
--- a/src/MandoCode.Desktop/Assets/web/transcript/transcript.js
+++ b/src/MandoCode.Desktop/Assets/web/transcript/transcript.js
@@ -507,6 +507,7 @@
const wrap = document.createElement('div');
wrap.innerHTML = html;
while (wrap.firstChild) placeChild(wrap.firstChild);
+ keepDraftLast();
highlightNew();
linkifyPaths();
addCopyChips();
@@ -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;
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs
index 5d06354..c4e5d95 100644
--- a/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs
@@ -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 _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 */ }
+ }
+
/// 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.
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs b/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs
index 34dcfec..ca57f06 100644
--- a/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs
@@ -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;
@@ -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();
@@ -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;
diff --git a/src/MandoCode.Desktop/Services/AgentSession.cs b/src/MandoCode.Desktop/Services/AgentSession.cs
index 3986fb0..57bcb2d 100644
--- a/src/MandoCode.Desktop/Services/AgentSession.cs
+++ b/src/MandoCode.Desktop/Services/AgentSession.cs
@@ -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);
diff --git a/src/MandoCode.Desktop/Services/AiServiceAdapter.cs b/src/MandoCode.Desktop/Services/AiServiceAdapter.cs
index f3e0d85..f7a3258 100644
--- a/src/MandoCode.Desktop/Services/AiServiceAdapter.cs
+++ b/src/MandoCode.Desktop/Services/AiServiceAdapter.cs
@@ -34,6 +34,14 @@ public event Action? OnFunctionCompleted
remove => _ai.OnFunctionCompleted -= value;
}
+ public event Action? OnResponseTextDelta
+ {
+ add => _ai.OnResponseTextDelta += value;
+ remove => _ai.OnResponseTextDelta -= value;
+ }
+
+ public bool IsStreamingModelCall => _ai.IsStreamingModelCall;
+
public Func>? OnWriteApprovalRequested
{
get => _ai.OnWriteApprovalRequested;
diff --git a/src/MandoCode.Desktop/Services/IAiService.cs b/src/MandoCode.Desktop/Services/IAiService.cs
index 6a03cd4..f171723 100644
--- a/src/MandoCode.Desktop/Services/IAiService.cs
+++ b/src/MandoCode.Desktop/Services/IAiService.cs
@@ -38,6 +38,14 @@ bool TryAttachImage(ReadOnlyMemory bytes, string mediaType, string caption
event Action? OnFunctionInvoked;
event Action? OnFunctionCompleted;
+ /// 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.
+ event Action? OnResponseTextDelta { add { } remove { } }
+
+ /// 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.
+ bool IsStreamingModelCall => false;
+
Func>? OnWriteApprovalRequested { get; set; }
Func>? OnDeleteApprovalRequested { get; set; }
Func>? OnCommandApprovalRequested { get; set; }
diff --git a/src/MandoCode.Desktop/Services/TranscriptWriter.cs b/src/MandoCode.Desktop/Services/TranscriptWriter.cs
index 19179e2..261c5a8 100644
--- a/src/MandoCode.Desktop/Services/TranscriptWriter.cs
+++ b/src/MandoCode.Desktop/Services/TranscriptWriter.cs
@@ -18,8 +18,32 @@ public sealed class TranscriptWriter
/// surface uses it to collapse activity while leaving important notices and chat visible.
public event Action? ActivityCompleted;
+ /// Raised with the whole reply-so-far of the streaming turn gen. Not journaled:
+ /// the live draft is replaced by real cards when the turn settles.
+ public event Action? LiveTextChanged;
+
+ /// Raised when the live draft of turn gen becomes a provisional card (a tool call
+ /// started). Not journaled until keeps it.
+ public event Action? LiveCardSealed;
+
+ /// Raised once when turn gen settles: the draft goes away, and its provisional
+ /// cards are kept (true) or removed because the final text disagreed with them.
+ public event Action? LiveEnded;
+
+ /// Raised for a block that is already on screen and only needs persisting — a kept
+ /// provisional card. The journal listens; the transcript surface does not.
+ public event Action? BlockJournaled;
+
public void Append(string html) => BlockAdded?.Invoke(html);
+ public void UpdateLive(long gen, string text) => LiveTextChanged?.Invoke(gen, text);
+
+ public void SealLive(long gen, string html) => LiveCardSealed?.Invoke(gen, html);
+
+ public void EndLive(long gen, bool keepSealed) => LiveEnded?.Invoke(gen, keepSealed);
+
+ public void Journal(string html) => BlockJournaled?.Invoke(html);
+
public void Clear() => Cleared?.Invoke();
public void CompleteActivity() => ActivityCompleted?.Invoke();
diff --git a/src/MandoCode.Desktop/ViewModels/ChatController.cs b/src/MandoCode.Desktop/ViewModels/ChatController.cs
index c0720ed..4af870c 100644
--- a/src/MandoCode.Desktop/ViewModels/ChatController.cs
+++ b/src/MandoCode.Desktop/ViewModels/ChatController.cs
@@ -896,6 +896,9 @@ private string ProcessFileReferences(string input)
private void OnFunctionInvoked(FunctionCall call)
{
+ // Text streamed before this call becomes its own card, ahead of the call's pill. Only for a
+ // call made mid-stream: a text-written call's raw text is about to be stripped from the reply.
+ if (_ai.IsStreamingModelCall) _streamer.SealLiveText();
_lastOperationType = call.FunctionName.Replace("FileSystem_", "").ToLowerInvariant();
if (!call.FunctionName.StartsWith("FileSystem_", StringComparison.Ordinal))
diff --git a/src/MandoCode.Desktop/ViewModels/ResponseStreamer.cs b/src/MandoCode.Desktop/ViewModels/ResponseStreamer.cs
index 9059d9c..e260af6 100644
--- a/src/MandoCode.Desktop/ViewModels/ResponseStreamer.cs
+++ b/src/MandoCode.Desktop/ViewModels/ResponseStreamer.cs
@@ -1,3 +1,4 @@
+using System.Text;
using MandoCode.Models;
using MandoCode.Services;
using MandoCode.Desktop.Services;
@@ -12,6 +13,13 @@ namespace MandoCode.Desktop.ViewModels;
/// (the request-lifecycle bits — the CancellationTokenSource, StateChanged, operation-field resets —
/// stay in ChatController). The 401 sign-in walkthrough is a UI wizard, so it arrives as the
/// callback rather than being called directly.
+///
+/// While a turn streams, its text shows in a live draft (,
+/// throttled). When a tool call starts, turns the text so far into its own
+/// provisional card, so words written before a tool call don't merge into the answer written after
+/// it. Provisional cards are only kept if they are found, in order, in the turn's final text: a model
+/// that writes its tool calls as text has them parsed out after streaming, and that raw text must
+/// not stay on screen.
///
public sealed class ResponseStreamer
{
@@ -38,6 +46,18 @@ public ResponseStreamer(
_config = config;
}
+ /// How often the live draft repaints. Each repaint replaces one text node, but a
+ /// per-token repaint is the continuous-redraw trap, so chunks are coalesced.
+ public TimeSpan LiveFlushInterval { get; set; } = TimeSpan.FromMilliseconds(100);
+
+ private readonly object _liveLock = new();
+ private readonly StringBuilder _liveText = new();
+ private readonly List _sealedText = new();
+ private readonly List _sealedHtml = new();
+ private long _liveGen;
+ private bool _liveOpen;
+ private bool _flushPending;
+
/// Logs conversational turns ("a" for each assistant turn). Set by ChatController so the
/// same logger records both user and assistant turns.
public Action? ConversationLogger { get; set; }
@@ -54,6 +74,8 @@ public async Task StreamAsync(string input, CancellationToken token, str
{
try
{
+ _ai.OnResponseTextDelta += OnTextDelta;
+ BeginLiveTurn();
var stream = string.IsNullOrWhiteSpace(hostInstruction)
? _ai.ChatStreamAsync(input, token)
: _ai.ChatStreamWithHostInstructionAsync(input, hostInstruction, token);
@@ -77,12 +99,18 @@ public async Task StreamAsync(string input, CancellationToken token, str
do
{
var segment = enumerator.Current.Trim();
+ var (unshown, endLive) = SettleLiveTurn(segment);
if (segment.Length > 0)
{
segments.Add(segment);
- _transcript.Append(_html.AssistantCard(segment, _config.AgentName));
+ if (unshown.Length > 0)
+ _transcript.Append(_html.AssistantCard(unshown, _config.AgentName));
ConversationLogger?.Invoke("a", segment);
}
+ // After the card, so the draft is replaced rather than blinking out first.
+ endLive();
+ // The next turn's chunks only start once MoveNextAsync resumes the harness.
+ BeginLiveTurn();
} while (await enumerator.MoveNextAsync());
_busy.Stop();
@@ -132,16 +160,106 @@ public async Task StreamAsync(string input, CancellationToken token, str
}
catch (OperationCanceledException)
{
+ DiscardLiveTurn();
_transcript.Append(_html.Warn("Request cancelled."));
return "";
}
catch (Exception ex)
{
+ DiscardLiveTurn();
_transcript.Append(_html.Error($"Error: {ex.Message}"));
return "";
}
+ finally
+ {
+ _ai.OnResponseTextDelta -= OnTextDelta;
+ DiscardLiveTurn();
+ }
+ }
+
+ /// A tool call is starting: the text streamed so far becomes its own provisional card,
+ /// ahead of the tool pill the caller appends next. No-op when nothing is streaming.
+ public void SealLiveText()
+ {
+ lock (_liveLock)
+ {
+ if (!_liveOpen) return;
+ var text = _liveText.ToString().Trim();
+ _liveText.Clear();
+ if (text.Length == 0) return;
+ var html = _html.AssistantCard(text, _config.AgentName);
+ _sealedText.Add(text);
+ _sealedHtml.Add(html);
+ _transcript.SealLive(_liveGen, html);
+ }
}
+ private void OnTextDelta(string text)
+ {
+ lock (_liveLock)
+ {
+ if (!_liveOpen) return;
+ _liveText.Append(text);
+ if (_flushPending) return;
+ _flushPending = true;
+ _ = FlushLiveLaterAsync(_liveGen);
+ }
+ }
+
+ private async Task FlushLiveLaterAsync(long gen)
+ {
+ await Task.Delay(LiveFlushInterval).ConfigureAwait(false);
+ lock (_liveLock)
+ {
+ if (gen != _liveGen || !_liveOpen) return; // the turn settled while we waited
+ _flushPending = false;
+ var text = _liveText.ToString();
+ if (text.Trim().Length > 0) _transcript.UpdateLive(gen, text);
+ }
+ }
+
+ private void BeginLiveTurn()
+ {
+ lock (_liveLock)
+ {
+ _liveGen++;
+ _liveOpen = true;
+ _flushPending = false;
+ _liveText.Clear();
+ _sealedText.Clear();
+ _sealedHtml.Clear();
+ }
+ }
+
+ /// Closes the live turn against its authoritative text. Returns the part of it that
+ /// still needs a card (all of it, unless provisional cards were kept) and the call that clears
+ /// the draft, which the caller makes once that card is appended.
+ private (string Unshown, Action EndLive) SettleLiveTurn(string finalText)
+ {
+ lock (_liveLock)
+ {
+ if (!_liveOpen) return (finalText, () => { });
+ _liveOpen = false;
+ string? rest = null;
+ var keep = _sealedText.Count > 0 && ReplyText.TryRemoveInOrder(finalText, _sealedText, out rest);
+ if (keep)
+ foreach (var html in _sealedHtml) _transcript.Journal(html);
+ var gen = _liveGen;
+ return (keep ? rest!.Trim() : finalText, () => _transcript.EndLive(gen, keep));
+ }
+ }
+
+ private void DiscardLiveTurn()
+ {
+ lock (_liveLock)
+ {
+ if (!_liveOpen) return;
+ _liveOpen = false;
+ _transcript.EndLive(_liveGen, false);
+ }
+ }
+
+
private static bool Looks401(string responseText)
=> !string.IsNullOrEmpty(responseText)
&& responseText.Contains("401 Unauthorized", StringComparison.OrdinalIgnoreCase);