db620d33df
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
417 lines
15 KiB
C#
417 lines
15 KiB
C#
// Copyright (c) Microsoft. All rights reserved.
|
|
|
|
using Harness.ConsoleReactiveComponents;
|
|
using Microsoft.Agents.AI;
|
|
using Microsoft.Extensions.AI;
|
|
|
|
namespace Harness.Shared.Console;
|
|
|
|
/// <summary>
|
|
/// Default <see cref="IUXStateDriver"/> implementation. Owned by
|
|
/// <see cref="HarnessAppComponent"/>; mutates the component's state via a
|
|
/// <c>SetState</c>-style callback. Each public operation updates state and lets
|
|
/// the component's render-skip optimization handle the actual draw.
|
|
/// </summary>
|
|
internal sealed class HarnessConsoleUXStateDriver : IUXStateDriver
|
|
{
|
|
private readonly Func<HarnessAppComponentState> _getState;
|
|
private readonly Action<HarnessAppComponentState> _setState;
|
|
private readonly Action _requestShutdown;
|
|
private readonly Func<AgentSession, Task> _replaceSession;
|
|
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
|
|
private readonly List<string> _outputItems = [];
|
|
private readonly object _stateLock = new();
|
|
|
|
private OutputEntryType? _lastEntryType;
|
|
private bool _hasReceivedAnyText;
|
|
private OutputEntry? _currentStreamingEntry;
|
|
private int _currentStreamingEntryIndex = -1;
|
|
private string? _currentMode;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="HarnessConsoleUXStateDriver"/> class.
|
|
/// </summary>
|
|
/// <param name="getState">Returns the component's current state.</param>
|
|
/// <param name="setState">Replaces the component's state and triggers a re-render.</param>
|
|
/// <param name="requestShutdown">Callback invoked when a command handler requests application shutdown.</param>
|
|
/// <param name="replaceSession">Callback invoked to replace the current agent session (e.g., on import).</param>
|
|
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
|
|
public HarnessConsoleUXStateDriver(
|
|
Func<HarnessAppComponentState> getState,
|
|
Action<HarnessAppComponentState> setState,
|
|
Action requestShutdown,
|
|
Func<AgentSession, Task> replaceSession,
|
|
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
|
{
|
|
this._getState = getState;
|
|
this._setState = setState;
|
|
this._requestShutdown = requestShutdown;
|
|
this._replaceSession = replaceSession;
|
|
this._modeColors = modeColors;
|
|
this._currentMode = getState().ModeText;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public string? CurrentMode
|
|
{
|
|
get => this._currentMode;
|
|
set
|
|
{
|
|
this.UpdateState(s =>
|
|
{
|
|
this._currentMode = value;
|
|
return s with
|
|
{
|
|
ModeColor = ModeColors.Get(value, this._modeColors),
|
|
ModeText = value,
|
|
};
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void BeginStreaming() =>
|
|
this.UpdateState(s => s with
|
|
{
|
|
Mode = BottomPanelMode.Streaming,
|
|
ShowSpinner = true,
|
|
});
|
|
|
|
/// <inheritdoc/>
|
|
public void StopSpinner() =>
|
|
this.UpdateState(s => s with { ShowSpinner = false });
|
|
|
|
/// <inheritdoc/>
|
|
public void EndStreaming() =>
|
|
this.UpdateState(s => s with
|
|
{
|
|
Mode = BottomPanelMode.TextInput,
|
|
ShowSpinner = false,
|
|
});
|
|
|
|
/// <inheritdoc/>
|
|
public void BeginStreamingOutput()
|
|
{
|
|
lock (this._stateLock)
|
|
{
|
|
this._hasReceivedAnyText = false;
|
|
this._currentStreamingEntry = null;
|
|
this._currentStreamingEntryIndex = -1;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void SetUsageText(string usageText) =>
|
|
this.UpdateState(s => s with { UsageText = usageText });
|
|
|
|
/// <inheritdoc/>
|
|
public void SetQueuedMessages(IReadOnlyList<ChatMessage> pending)
|
|
{
|
|
var newQueued = new List<string>(pending.Count);
|
|
foreach (var msg in pending)
|
|
{
|
|
string text = msg.Text ?? string.Empty;
|
|
newQueued.Add(RenderEntry($" 💬 {text}\n", ConsoleColor.DarkGray));
|
|
}
|
|
|
|
this.UpdateState(s => s with { QueuedItems = newQueued });
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void QueueFollowUpQuestions(IReadOnlyList<FollowUpQuestion> questions)
|
|
{
|
|
if (questions.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
this.UpdateState(s =>
|
|
{
|
|
bool wasEmpty = s.PendingQuestions.Count == 0;
|
|
|
|
var combined = new List<FollowUpQuestion>(s.PendingQuestions.Count + questions.Count);
|
|
combined.AddRange(s.PendingQuestions);
|
|
combined.AddRange(questions);
|
|
|
|
HarnessAppComponentState next = s with { PendingQuestions = combined };
|
|
|
|
if (wasEmpty)
|
|
{
|
|
next = this.ConfigureForHeadQuestion(next, combined[0]);
|
|
}
|
|
|
|
return next;
|
|
});
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void AddFollowUpResponse(ChatMessage response)
|
|
{
|
|
this.UpdateState(s =>
|
|
{
|
|
var combined = new List<ChatMessage>(s.AccumulatedFollowUpResponses.Count + 1);
|
|
combined.AddRange(s.AccumulatedFollowUpResponses);
|
|
combined.Add(response);
|
|
return s with { AccumulatedFollowUpResponses = combined };
|
|
});
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void AdvanceFollowUpQuestion()
|
|
{
|
|
this.UpdateState(s =>
|
|
{
|
|
if (s.PendingQuestions.Count == 0)
|
|
{
|
|
return s;
|
|
}
|
|
|
|
var remaining = s.PendingQuestions.Skip(1).ToList();
|
|
HarnessAppComponentState next = s with { PendingQuestions = remaining };
|
|
|
|
if (remaining.Count > 0)
|
|
{
|
|
return this.ConfigureForHeadQuestion(next, remaining[0]);
|
|
}
|
|
|
|
return next with
|
|
{
|
|
Mode = BottomPanelMode.TextInput,
|
|
ListSelectionOptions = [],
|
|
ListSelectionTitle = null,
|
|
ListSelectionCustomTextPlaceholder = null,
|
|
ListSelectionIndex = 0,
|
|
ListSelectionCustomInputText = "",
|
|
};
|
|
});
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public IReadOnlyList<ChatMessage> TakeFollowUpResponses()
|
|
{
|
|
return this.UpdateState(s =>
|
|
{
|
|
IReadOnlyList<ChatMessage> responses = s.AccumulatedFollowUpResponses;
|
|
if (responses.Count == 0)
|
|
{
|
|
return (s, responses);
|
|
}
|
|
|
|
return (s with { AccumulatedFollowUpResponses = [] }, responses);
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Configures the bottom-panel display fields on the supplied state for the
|
|
/// given head question. For text questions, also writes the prompt as an
|
|
/// info line above the input row as a side effect.
|
|
/// </summary>
|
|
private HarnessAppComponentState ConfigureForHeadQuestion(HarnessAppComponentState state, FollowUpQuestion question)
|
|
{
|
|
if (question is ChoiceFollowUpQuestion choice)
|
|
{
|
|
return state with
|
|
{
|
|
Mode = BottomPanelMode.ListSelection,
|
|
ListSelectionOptions = choice.Choices.ToList(),
|
|
ListSelectionTitle = choice.Prompt,
|
|
ListSelectionCustomTextPlaceholder = choice.AllowCustomText ? "✏️ Type a custom response..." : null,
|
|
ListSelectionIndex = 0,
|
|
ListSelectionCustomInputText = "",
|
|
};
|
|
}
|
|
|
|
// Text question — prompt is rendered as an info line above the input row.
|
|
// We append entries and capture the scroll snapshot inline so the caller's
|
|
// single _setState picks up both the new output and the UI mode change.
|
|
ConsoleColor ruleColor = ModeColors.Get(this._currentMode, this._modeColors);
|
|
List<string> scrollSnapshot = this.AppendOutputEntriesAndSnapshot(
|
|
new OutputEntry(OutputEntryType.InfoLine, "\n", ruleColor),
|
|
new OutputEntry(OutputEntryType.InfoLine, $" {question.Prompt}", ruleColor));
|
|
|
|
return state with
|
|
{
|
|
Mode = BottomPanelMode.TextInput,
|
|
ListSelectionOptions = [],
|
|
ListSelectionTitle = null,
|
|
ListSelectionCustomTextPlaceholder = null,
|
|
ListSelectionIndex = 0,
|
|
ListSelectionCustomInputText = "",
|
|
ScrollAreaContentItems = scrollSnapshot,
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void WriteUserInputEcho(string text)
|
|
{
|
|
this.UpdateState(s =>
|
|
{
|
|
List<string> snapshot = this.AppendOutputEntriesAndSnapshot(new OutputEntry(
|
|
OutputEntryType.UserInput,
|
|
$"\nYou: {text}\n\n",
|
|
ConsoleColor.Green));
|
|
return s with { ScrollAreaContentItems = snapshot };
|
|
});
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public Task WriteInfoAsync(string text, ConsoleColor? color = null) =>
|
|
this.WriteInfoCoreAsync(text, color, newLine: false);
|
|
|
|
/// <inheritdoc/>
|
|
public Task WriteInfoLineAsync(string text, ConsoleColor? color = null) =>
|
|
this.WriteInfoCoreAsync(text, color, newLine: true);
|
|
|
|
private Task WriteInfoCoreAsync(string text, ConsoleColor? color, bool newLine)
|
|
{
|
|
this.UpdateState(s =>
|
|
{
|
|
// Add a blank line separator when transitioning from streaming text or user input.
|
|
string prefix = this._lastEntryType is OutputEntryType.StreamingText or OutputEntryType.StreamFooter
|
|
? "\n "
|
|
: " ";
|
|
|
|
string fullText = newLine ? prefix + text + "\n\n" : prefix + text;
|
|
List<string> snapshot = this.AppendOutputEntriesAndSnapshot(new OutputEntry(
|
|
OutputEntryType.InfoLine,
|
|
fullText,
|
|
color ?? ModeColors.Get(this._currentMode, this._modeColors)));
|
|
return s with { ScrollAreaContentItems = snapshot };
|
|
});
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public Task WriteTextAsync(string text, ConsoleColor? color = null)
|
|
{
|
|
this.UpdateState(s =>
|
|
{
|
|
this._lastEntryType = OutputEntryType.StreamingText;
|
|
this._hasReceivedAnyText = true;
|
|
|
|
ConsoleColor effectiveColor = color ?? ModeColors.Get(this._currentMode, this._modeColors);
|
|
|
|
if (this._currentStreamingEntry is not null
|
|
&& this._currentStreamingEntryIndex == this._outputItems.Count - 1)
|
|
{
|
|
// The streaming entry is still the last item — safe to replace in place.
|
|
this._currentStreamingEntry = this._currentStreamingEntry with
|
|
{
|
|
Text = this._currentStreamingEntry.Text + text,
|
|
};
|
|
this._outputItems[^1] = RenderEntry(this._currentStreamingEntry.Text, this._currentStreamingEntry.Color);
|
|
}
|
|
else
|
|
{
|
|
// Either the first text delta or other entries (tool calls, info lines)
|
|
// were appended after the previous streaming entry — start a fresh one.
|
|
const string Prefix = "\n";
|
|
this._currentStreamingEntry = new OutputEntry(OutputEntryType.StreamingText, Prefix + text, effectiveColor);
|
|
this._outputItems.Add(RenderEntry(this._currentStreamingEntry.Text, this._currentStreamingEntry.Color));
|
|
this._currentStreamingEntryIndex = this._outputItems.Count - 1;
|
|
}
|
|
|
|
return s with { ScrollAreaContentItems = new List<string>(this._outputItems) };
|
|
});
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public Task EndStreamingOutputAsync()
|
|
{
|
|
this.UpdateState(s =>
|
|
{
|
|
if (this._hasReceivedAnyText)
|
|
{
|
|
this._outputItems.Add(RenderEntry("\n", null));
|
|
this._currentStreamingEntry = null;
|
|
this._lastEntryType = OutputEntryType.StreamFooter;
|
|
return s with { ScrollAreaContentItems = new List<string>(this._outputItems) };
|
|
}
|
|
|
|
return s;
|
|
});
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public Task WriteNoTextWarningAsync(bool hasFollowUpActions)
|
|
{
|
|
if (!this._hasReceivedAnyText && !hasFollowUpActions)
|
|
{
|
|
this.UpdateState(s =>
|
|
{
|
|
List<string> snapshot = this.AppendOutputEntriesAndSnapshot(new OutputEntry(
|
|
OutputEntryType.StreamFooter,
|
|
" (no text response from agent)\n",
|
|
ConsoleColor.DarkYellow));
|
|
return s with { ScrollAreaContentItems = snapshot };
|
|
});
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wraps the supplied text with ANSI foreground color escape sequences (or returns
|
|
/// the text unchanged when no color is specified). Output is appended to
|
|
/// <see cref="_outputItems"/> and consumed verbatim by <see cref="TextScrollPanel"/>
|
|
/// and <see cref="TextPanel"/>.
|
|
/// </summary>
|
|
private static string RenderEntry(string text, ConsoleColor? color) =>
|
|
color.HasValue
|
|
? $"{AnsiEscapes.SetForegroundColor(color.Value)}{text}{AnsiEscapes.ResetAttributes}"
|
|
: text;
|
|
|
|
private void UpdateState(Func<HarnessAppComponentState, HarnessAppComponentState> update)
|
|
{
|
|
lock (this._stateLock)
|
|
{
|
|
this._setState(update(this._getState()));
|
|
}
|
|
}
|
|
|
|
private T UpdateState<T>(Func<HarnessAppComponentState, (HarnessAppComponentState State, T Result)> update)
|
|
{
|
|
lock (this._stateLock)
|
|
{
|
|
var (newState, result) = update(this._getState());
|
|
this._setState(newState);
|
|
return result;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Appends one or more output entries to the output list, updates
|
|
/// <see cref="_lastEntryType"/> to the last entry's type, and returns a
|
|
/// snapshot of <see cref="_outputItems"/>. Must be called inside a locked
|
|
/// context (e.g. within an <see cref="UpdateState"/> callback).
|
|
/// </summary>
|
|
private List<string> AppendOutputEntriesAndSnapshot(params OutputEntry[] entries)
|
|
{
|
|
this.AppendOutputEntriesCore(entries);
|
|
return new List<string>(this._outputItems);
|
|
}
|
|
|
|
private void AppendOutputEntriesCore(OutputEntry[] entries)
|
|
{
|
|
foreach (OutputEntry entry in entries)
|
|
{
|
|
this._outputItems.Add(RenderEntry(entry.Text, entry.Color));
|
|
}
|
|
|
|
if (entries.Length > 0)
|
|
{
|
|
this._lastEntryType = entries[^1].Type;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void RequestShutdown() => this._requestShutdown();
|
|
|
|
/// <inheritdoc/>
|
|
public Task ReplaceSessionAsync(AgentSession newSession) => this._replaceSession(newSession);
|
|
}
|