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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ submodule.
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.
- **A context meter in every tab.** The strip under the message box now shows how full the agent's
context window is, for example "Context ▰▰▰▱▱▱ 41% of 32k tokens", with a bar that turns
yellow and then red as it fills. It tells you when to `/compact` before the model starts losing the
earlier conversation. The colors are tuned to stay readable in every theme, a full window also
shows ⚠ so the warning never depends on color alone, and screen readers announce it. Cloud models,
which manage their own window, show their token count.

## [0.15.0] — 2026-09-10

Expand Down
44 changes: 44 additions & 0 deletions src/MandoCode.Desktop.Tests/ContextMeterColorsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using MandoCode.Desktop.Services;
using MandoCode.Services;
using Xunit;

namespace MandoCode.Desktop.Tests;

/// <summary>
/// The context meter's bar is read by its length, and its percentage is printed in the fill color.
/// So in every theme the fill must clear the text floor against the page and the non-text floor
/// against the empty track. A theme added with a faint green, gold, or red fails here instead of
/// shipping an unreadable percentage, or a bar that looks full, or empty, when it isn't.
/// </summary>
public class ContextMeterColorsTests
{
public static TheoryData<string, ContextMeter.Level> ThemesAndLevels()
{
var data = new TheoryData<string, ContextMeter.Level>();
foreach (var t in UiTheme.All)
foreach (var level in Enum.GetValues<ContextMeter.Level>())
data.Add(t.Name, level);
return data;
}

[Theory]
[MemberData(nameof(ThemesAndLevels))]
public void FillReadsAsTextOnThePage_AndStandsApartFromTheTrack(string name, ContextMeter.Level level)
{
var theme = UiTheme.All.Single(t => t.Name == name);
var fill = ContextMeterColors.Fill(theme, level);

var onPage = ColorMath.Contrast(fill, theme.Background);
var onTrack = ColorMath.Contrast(fill, ContextMeterColors.Track(theme));
Assert.True(onPage >= ContextMeterColors.TextFloor, $"{name}/{level}: fill is {onPage:F2}:1 against the page");
Assert.True(onTrack >= ContextMeterColors.NonTextFloor, $"{name}/{level}: fill is {onTrack:F2}:1 against the track");
}

[Fact]
public void AColorThatAlreadyClearsTheFloorIsLeftAlone()
{
// Lifting is a last resort: where the palette's own green already works, it's what shows.
var theme = UiTheme.All.Single(t => t.Name == "Dracula");
Assert.Equal(theme.Green, ContextMeterColors.Fill(theme, ContextMeter.Level.Ok));
}
}
1 change: 1 addition & 0 deletions src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
<Compile Include="..\MandoCode.Desktop\Services\AgentInbox.cs" Link="src\AgentInbox.cs" />
<Compile Include="..\MandoCode.Desktop\Services\Delegations.cs" Link="src\Delegations.cs" />
<Compile Include="..\MandoCode.Desktop\Services\UiTheme.cs" Link="src\UiTheme.cs" />
<Compile Include="..\MandoCode.Desktop\Services\ContextMeterColors.cs" Link="src\ContextMeterColors.cs" />
<Compile Include="..\MandoCode.Desktop\Services\ConversationLog.cs" Link="src\ConversationLog.cs" />
<!-- History's full-text matching + snippet extraction. Pure (text in, match out); the file reads
and caching live in ConversationTextCache, which is NOT compiled here because it touches
Expand Down
80 changes: 80 additions & 0 deletions src/MandoCode.Desktop/Controls/ChatTabView.ContextMeter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using MandoCode.Desktop.Services;
using MandoCode.Services;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;

namespace MandoCode.Desktop;

public sealed partial class ChatTabView
{
// ============================================================
// Context meter (status strip, left of the git chip)
// ============================================================

/// <summary>
/// Shows how full this agent's context window is. "Used" is the last request's prompt tokens:
/// every request resends the whole conversation, so that's what the next one starts from. Local
/// models get a bar against the configured context length; a cloud model manages its window
/// server-side, so it gets the count alone. Runs on every header refresh (token updates raise
/// one) and on theme changes, since the fill color is derived from the theme.
/// </summary>
private void UpdateContextMeter()
{
var lastOp = Session.Tokens.LastOperation;
if (!Session.Config.EnableTokenTracking || lastOp == null || lastOp.PromptTokens <= 0)
{
ContextMeterPanel.Visibility = Visibility.Collapsed;
UpdateStatusStripVisibility();
return;
}

var window = ContextMeter.KnownWindow(Session.Config.GetEffectiveModelName(), Session.Config.ContextLength);
var reading = ContextMeter.Read(lastOp.PromptTokens, window);
var theme = ThemeManager.Current;

var hasBar = window > 0;
var used = TokenTrackingService.FormatTokenCount(lastOp.PromptTokens);
ContextMeterTrack.Visibility = hasBar ? Visibility.Visible : Visibility.Collapsed;
ContextMeterPercent.Visibility = hasBar ? Visibility.Visible : Visibility.Collapsed;
if (hasBar)
{
var fill = new SolidColorBrush(ThemeManager.C(ContextMeterColors.Fill(theme, reading.Level)));
var fraction = Math.Clamp((double)lastOp.PromptTokens / window, 0, 1);
var percent = (int)Math.Round(fraction * 100);
var size = TokenTrackingService.FormatTokenCount(window);

ContextMeterTrack.Background = new SolidColorBrush(ThemeManager.C(ContextMeterColors.Track(theme)));
ContextMeterFill.Background = fill;
ContextMeterFill.Width = ContextMeterTrack.Width * fraction;

// A full window also says so with a glyph, so the warning never rests on color alone.
ContextMeterPercent.Foreground = fill;
ContextMeterPercent.Text = reading.Level == ContextMeter.Level.Full ? $"⚠ {percent}%" : $"{percent}%";
ContextMeterText.Text = $"of {size} tokens"; // the used count is in the tooltip
AutomationProperties.SetName(ContextMeterPanel,
$"Context window {percent} percent full, {used} of {size} tokens");
}
else
{
ContextMeterText.Text = reading.Label;
AutomationProperties.SetName(ContextMeterPanel, $"Context: {reading.Label}");
}
ToolTipService.SetToolTip(ContextMeterPanel, hasBar
? $"Context window: the last request used {reading.Label}. "
+ "Every request resends the conversation, so this is where the next one starts. "
+ "Near full, /compact shrinks it."
: $"{reading.Label}. This cloud model manages its context window server-side.");

ContextMeterPanel.Visibility = Visibility.Visible;
UpdateStatusStripVisibility();
}

/// <summary>The strip shows when either of its parts has something to show.</summary>
private void UpdateStatusStripVisibility() =>
StatusStrip.Visibility =
ContextMeterPanel.Visibility == Visibility.Visible || GitStatusPanel.Visibility == Visibility.Visible
? Visibility.Visible
: Visibility.Collapsed;
}
8 changes: 5 additions & 3 deletions src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ private async void RefreshBranchChip(bool force = false)
_wsTracker.CaptureBaselineIfPending(info);
if (info == null)
{
StatusStrip.Visibility = Visibility.Collapsed;
GitStatusPanel.Visibility = Visibility.Collapsed;
UpdateStatusStripVisibility();
return;
}

Expand All @@ -65,14 +66,15 @@ private async void RefreshBranchChip(bool force = false)
var foreignRoot = info.RepoRoot.Length > 0 && !string.Equals(
Path.TrimEndingDirectorySeparator(info.RepoRoot),
Path.TrimEndingDirectorySeparator(root), StringComparison.OrdinalIgnoreCase);
ToolTipService.SetToolTip(StatusStrip,
ToolTipService.SetToolTip(GitStatusPanel,
(info.Detached ? "Detached HEAD at commit " + info.Branch : "Git branch: " + info.Branch)
+ " — " + state
+ (info.Ahead > 0 || info.Behind > 0
? $" ({info.Ahead} ahead, {info.Behind} behind upstream)" : "")
// Git found the repo in an ANCESTOR folder — say so, or this reads as a ghost.
+ (foreignRoot ? $"\nRepository root: {info.RepoRoot} (this folder is inside that repository)" : ""));
StatusStrip.Visibility = Visibility.Visible;
GitStatusPanel.Visibility = Visibility.Visible;
UpdateStatusStripVisibility();
}

/// <summary>Rebuilds the Changes tab's rows from a fresh git snapshot (UI thread).</summary>
Expand Down
1 change: 1 addition & 0 deletions src/MandoCode.Desktop/Controls/ChatTabView.Header.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public void UpdateHeader()
ModelButton.IsEnabled = !processing; // no model switch mid-turn

RefreshBranchChip();
UpdateContextMeter();

HeaderChanged?.Invoke(this);
}
Expand Down
23 changes: 21 additions & 2 deletions src/MandoCode.Desktop/Controls/ChatTabView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -764,8 +764,27 @@
the dot is the one status light: red = merge conflicts, gold = uncommitted
changes, green = clean. Hidden entirely when the folder isn't a git repo. -->
<Grid x:Name="StatusStrip" Grid.Row="6" Padding="16,0,16,6" Visibility="Collapsed">
<StackPanel Orientation="Horizontal" Spacing="7" HorizontalAlignment="Right"
VerticalAlignment="Center"
<!-- Context meter: how full this agent's context window is, from the last request's
prompt tokens. Colors come from ContextMeterColors (lifted per theme to clear 3:1
against the page AND the empty track); the percentage is text, so color is never
the only signal. Shown once the agent has made a request. -->
<StackPanel x:Name="ContextMeterPanel" Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Left" VerticalAlignment="Center" Visibility="Collapsed">
<!-- Names the bar: alone, it could read as any progress (a download, a task). -->
<TextBlock Text="Context" FontSize="12" Opacity="0.8" VerticalAlignment="Center"/>
<Grid x:Name="ContextMeterTrack" Width="120" Height="6" VerticalAlignment="Center"
CornerRadius="3">
<Border x:Name="ContextMeterFill" HorizontalAlignment="Left" CornerRadius="3" Width="0"/>
</Grid>
<!-- The percentage wears the bar's color (lifted to the text floor), so it stands
out and needs no parentheses; the token counts stay quiet beside it. -->
<TextBlock x:Name="ContextMeterPercent" FontSize="12" FontWeight="SemiBold"
VerticalAlignment="Center" FontFamily="Cascadia Code,Consolas"/>
<TextBlock x:Name="ContextMeterText" FontSize="12" Opacity="0.8" VerticalAlignment="Center"
FontFamily="Cascadia Code,Consolas"/>
</StackPanel>
<StackPanel x:Name="GitStatusPanel" Orientation="Horizontal" Spacing="7" HorizontalAlignment="Right"
VerticalAlignment="Center" Visibility="Collapsed"
ToolTipService.ToolTip="Local git status for this tab's project folder">
<Canvas Width="14" Height="14" VerticalAlignment="Center" Opacity="0.7">
<!-- classic branch shape: main line with two nodes, side node joined by a curve -->
Expand Down
1 change: 1 addition & 0 deletions src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ public async void ApplyTheme()
if (_shutDown) return;
var theme = ThemeManager.Current;
TranscriptView.DefaultBackgroundColor = ThemeManager.C(theme.Background);
UpdateContextMeter(); // its fill is derived from the theme

var core = CanScript ? TranscriptView.CoreWebView2 : null; // captured: see AppendRawAsync
if (core == null) return;
Expand Down
36 changes: 36 additions & 0 deletions src/MandoCode.Desktop/Services/ContextMeterColors.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using MandoCode.Services;

namespace MandoCode.Desktop.Services;

/// <summary>
/// Colors for the context meter in a tab's status strip. The fill takes the level's theme color
/// (green, gold, red) but is lifted toward body text until it clears two floors: the text floor
/// against the page, because the percentage is printed in this same color, and the non-text floor
/// against the empty track, because the bar's length is the reading, so the filled part has to
/// stand apart from the unfilled part. Measured across the shipped themes, the level colors alone
/// missed even 3:1 against the page in a few light and retro palettes, and a dim-colored track sat
/// within 1.0–1.5:1 of the fill in most of them.
/// </summary>
public static class ContextMeterColors
{
/// <summary>WCAG 1.4.11: graphical objects need 3:1 against adjacent colors.</summary>
public const double NonTextFloor = 3.0;

/// <summary>WCAG 1.4.3: the percentage is small text in the fill color.</summary>
public const double TextFloor = ColorMath.AaNormalText;

/// <summary>The empty track: the theme's border color, a quiet outline of the capacity.</summary>
public static string Track(UiTheme theme) => theme.Border;

public static string Fill(UiTheme theme, ContextMeter.Level level)
{
var color = level switch
{
ContextMeter.Level.Full => theme.Red,
ContextMeter.Level.Warm => theme.Gold,
_ => theme.Green,
};
var onPage = ColorMath.Readable(color, theme.Background, theme.Text, TextFloor);
return ColorMath.Readable(onPage, Track(theme), theme.Text, NonTextFloor);
}
}
Loading