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
12 changes: 12 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ All notable changes to MandoCode will be documented in this file.
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.
- **Watch the reply being written.** While a reply streams, its latest lines show dimmed above the
spinner, so a long answer no longer looks like a frozen "Thinking…". The finished reply still
prints as formatted markdown.
- **Long diffs no longer bury the approval menu.** A change over 30 lines now shows in a scrollable
window above the approve/deny choices (PgUp/PgDn, Home/End to scroll), with a one-line summary
left in your scrollback. Before, hundreds of diff lines pushed the menu off screen. Short diffs
print as they always have.
- **A context meter above the prompt.** A bar shows how full the model's context window is, turning
yellow and then red as it fills, so you can `/compact` before a small model starts forgetting the
start of the conversation. Cloud models, which manage their own window, show the token count.
- **`/history` to look back.** Browse this session's conversation in a scrollable window and
filter it as you type.

### Fixed
- **Numbered lists keep each number beside its text.** When a model left blank lines between list
Expand Down
111 changes: 109 additions & 2 deletions src/MandoCode/Components/App.razor
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@
Hidden while an instruction prompt is active so the two text inputs don't fight for focus. *@
@if (_showPrompt && !InstructionCoordinator.IsActive)
{
@if (Config.EnableTokenTracking && TokenTracker.LastOperation is { } lastOp)
{
<ContextMeterLine UsedTokens="@lastOp.PromptTokens"
WindowTokens="@ContextMeter.KnownWindow(Config.GetEffectiveModelName(), Config.ContextLength)" />
}
<PromptInput OnSubmit="HandleVdomSubmit"
OnAutocompleteRequested="HandleAutocompleteRequested"
InitialValue="@_promptInitialValue"
Expand All @@ -78,8 +83,25 @@
ApprovalSelectCoordinator (TCS). *@
@if (ApprovalMenu.IsActive)
{
@* A decision on something too long to print (a long diff) shows it here, scrolled with
PgUp/PgDn from the menu, which keeps focus. *@
@if (ApprovalMenu.Preview.Count > 0)
{
<ScrollPreview Title="@ApprovalMenu.PreviewTitle"
Lines="@ApprovalMenu.Preview"
Offset="@_approvalPreviewOffset"
PageSize="@ApprovalPreviewPageSize" />
}
<ApprovalSelect Options="@_approvalMenuOptions"
OnSubmit="HandleApprovalMenuSubmit" />
OnSubmit="HandleApprovalMenuSubmit"
OnScroll="HandleApprovalPreviewScroll" />
}

@* /history — this session's conversation in a scrollable, filterable window. Idle-only, like the
prompt it replaces while open. *@
@if (_historyActive)
{
<HistoryView Messages="@_historyMessages" OnClose="CloseHistory" />
}

@* Wizard primitives — bare RazorConsole TextInput / Select that bypass PromptInput's slash/at
Expand Down Expand Up @@ -830,20 +852,31 @@
private void CloseReply()
{
lock (_replyLock) _replyOpen = false;
Spinner.UpdatePreview(null);
}

// The spinner's live preview only needs the tail of the reply, and copying the whole buffer on
// every chunk would make a long reply quadratic.
private const int PreviewTailChars = 4000;

private void OnResponseTextDelta(string text)
{
string preview;
lock (_replyLock)
{
if (_replyOpen) _replyUnprinted.Append(text);
if (!_replyOpen) return;
_replyUnprinted.Append(text);
var start = Math.Max(0, _replyUnprinted.Length - PreviewTailChars);
preview = _replyUnprinted.ToString(start, _replyUnprinted.Length - start);
}
Spinner.UpdatePreview(preview);
}

// A retried model call streams its reply again from the start.
private void OnResponseStreamStarted()
{
lock (_replyLock) _replyUnprinted.Clear();
Spinner.UpdatePreview(null);
}

/// <summary>
Expand All @@ -863,6 +896,7 @@
if (text.Length == 0) return;
_replyPrinted.Add(text);
}
Spinner.UpdatePreview(null);
AnsiConsole.MarkupLine("[green]MandoCode:[/]");
RenderMarkdownGuarded(text);
}
Expand All @@ -878,6 +912,7 @@
_replyPrinted.Clear();
_replyUnprinted.Clear();
}
Spinner.UpdatePreview(null);
return ReplyText.Unshown(turnText, printed);
}

Expand Down Expand Up @@ -1068,6 +1103,12 @@
continue;
}

if (command == "history")
{
await ShowHistoryAsync();
continue;
}

if (command == "help")
{
Console.WriteLine();
Expand Down Expand Up @@ -1108,6 +1149,7 @@
table.AddRow("/plan-discard", "Forget an unfinished plan");
table.AddRow("/compact", "Compress context into a recap; keeps the transcript");
table.AddRow("/clear", "Wipe all conversation context and start fresh");
table.AddRow("/history", "Browse this session's conversation, filtered as you type");
table.AddRow("/exit", "Exit MandoCode");
AnsiConsole.Write(table);
AnsiConsole.WriteLine();
Expand Down Expand Up @@ -1377,14 +1419,79 @@
/// re-renders. Done here (on state change) rather than inline in markup so the option
/// list isn't rebuilt on every unrelated render pass.
/// </summary>
private bool _historyActive;
private IReadOnlyList<ChatMsg> _historyMessages = Array.Empty<ChatMsg>();
private TaskCompletionSource? _historyTcs;

/// <summary>Opens /history and waits for it to close. The loop is idle while it's open, so the
/// view owns the screen's live region the way the prompt normally does.</summary>
private async Task ShowHistoryAsync()
{
if (_messages.Count == 0)
{
AnsiConsole.MarkupLine("[dim]Nothing in this session's history yet.[/]");
AnsiConsole.WriteLine();
return;
}

// A snapshot: the view builds its lines once, and the list mustn't change under it.
_historyMessages = _messages.ToList();
// Async continuations, so closing from a Blazor event resumes the loop off the dispatcher
// thread (see WaitForVdomInputAsync).
_historyTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_historyActive = true;
_showPrompt = false;
await InvokeAsync(StateHasChanged);

await _historyTcs.Task;

_historyActive = false;
_historyMessages = Array.Empty<ChatMsg>();
await InvokeAsync(StateHasChanged);
// Let the VDOM remove the view before anything prints below it.
await Task.Delay(100);
}

private void CloseHistory() => _historyTcs?.TrySetResult();

private void OnApprovalMenuStateChanged()
{
_approvalMenuOptions = ApprovalMenu.Options
.Select(o => new ApprovalSelect.Option(o.Text, o.Color))
.ToList();
_approvalPreviewOffset = 0; // every request starts at the top of its preview
InvokeAsync(StateHasChanged);
}

private int _approvalPreviewOffset;

// Room for the menu's options, the panel's borders and footer, and the question above it.
private static int ApprovalPreviewPageSize
{
get
{
try { return Math.Clamp(Console.WindowHeight - 16, 6, 30); }
catch { return 20; }
}
}

private void HandleApprovalPreviewScroll(ApprovalSelect.ScrollKey key)
{
var count = ApprovalMenu.Preview.Count;
if (count == 0) return;

var page = ApprovalPreviewPageSize;
var max = Math.Max(0, count - page);
_approvalPreviewOffset = key switch
{
ApprovalSelect.ScrollKey.PageUp => Math.Max(0, _approvalPreviewOffset - page),
ApprovalSelect.ScrollKey.PageDown => Math.Min(max, _approvalPreviewOffset + page),
ApprovalSelect.ScrollKey.Home => 0,
_ => max,
};
StateHasChanged();
}

/// <summary>
/// Bound to the wizard's Select.ValueChanged — fires when the user presses Enter on a
/// highlighted option (RazorConsole's Select uses Enter as the commit signal).
Expand Down
21 changes: 21 additions & 0 deletions src/MandoCode/Components/ApprovalSelect.razor
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@
[Parameter] public IReadOnlyList<Option> Options { get; set; } = Array.Empty<Option>();
[Parameter] public EventCallback<string> OnSubmit { get; set; }

/// <summary>Scroll keys for a preview shown above the menu. The menu keeps focus so the arrows
/// still choose; PageUp/PageDown/Home/End arrive here as a <see cref="ScrollKey"/> instead.</summary>
[Parameter] public EventCallback<ScrollKey> OnScroll { get; set; }

public enum ScrollKey { PageUp, PageDown, Home, End }

private int _focusedIndex;

// Accept both naming conventions, mirroring RazorConsole's own Select component:
Expand All @@ -56,8 +62,23 @@
key.Equals("Enter", StringComparison.OrdinalIgnoreCase) ||
key.Equals("Return", StringComparison.OrdinalIgnoreCase);

private static ScrollKey? AsScrollKey(string key) => key switch
{
_ when key.Equals("PageUp", StringComparison.OrdinalIgnoreCase) => ScrollKey.PageUp,
_ when key.Equals("PageDown", StringComparison.OrdinalIgnoreCase) => ScrollKey.PageDown,
_ when key.Equals("Home", StringComparison.OrdinalIgnoreCase) => ScrollKey.Home,
_ when key.Equals("End", StringComparison.OrdinalIgnoreCase) => ScrollKey.End,
_ => null,
};

private async Task HandleKeyDown(KeyboardEventArgs e)
{
if (AsScrollKey(e.Key) is { } scroll)
{
await OnScroll.InvokeAsync(scroll);
return;
}

if (Options.Count == 0) return;

if (IsUp(e.Key))
Expand Down
36 changes: 36 additions & 0 deletions src/MandoCode/Components/ContextMeterLine.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
@using Spectre.Console
@using RazorConsole.Components
@using MandoCode.Services
@using RazorConsole.Core.Renderables
@using Markup = RazorConsole.Components.Markup

@* How full the context window is, one line above the prompt. Only rendered while the prompt is
waiting (App.razor gates it with the prompt itself), when nothing prints to scrollback, so a
VDOM line here can't collide with imperative output. Same four-Markup shape every render.

Only the used part carries the level color; the unused part is a darker grey, so the bar's
length reads by contrast rather than by shade alone. The two halves sit in a gapless FlexBox row:
Columns would put a space between them and split the bar. *@

<Columns>
<Markup Content="Context" Foreground="Color.Grey" Decoration="Decoration.Dim" />
<FlexBox Direction="FlexDirection.Row" Gap="0" Width="@Math.Max(1, Reading.Bar.Length)">
<Markup Content="@Reading.Filled" Foreground="@BarColor" />
<Markup Content="@Reading.Empty" Foreground="Color.Grey42" />
</FlexBox>
<Markup Content="@Reading.Label" Foreground="Color.Grey" Decoration="Decoration.Dim" />
</Columns>

@code {
[Parameter] public long UsedTokens { get; set; }
[Parameter] public int WindowTokens { get; set; }

private ContextMeter.Reading Reading => ContextMeter.Read(UsedTokens, WindowTokens);

private Color BarColor => Reading.Level switch
{
ContextMeter.Level.Full => Color.Red,
ContextMeter.Level.Warm => Color.Yellow,
_ => Color.Green,
};
}
Loading