diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0f568f0..80ae779 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -68,6 +68,21 @@ Backups, history from a second PC, real Windows notifications and a much deeper
- The token usage window no longer clips text in its smallest size, and its charts use the "Terminal" theme's font.
- Dates use the same month abbreviations everywhere.
- The 1.1.0 weekday or hour chart choice carries over into the new groupings.
+- Token usage counts Codex again: real Codex logs carry no cache write total, and every line without one was skipped. Codex logs read so far without a single counted line are read once more.
+- Codex reads its sessions from `CODEX_HOME` when that is set.
+- Started into the notification area, the widget fetches at the slower hidden-window pace, and the first time it is shown it fetches right away.
+- A quick double click on "Add account" for Copilot no longer adds the same GitHub account twice.
+- Dragging the top edge of the widget stops at the content and the screen height like the bottom edge, instead of sliding the window off the screen.
+- A settings file with an empty entry in the hidden windows no longer stops the app from starting, and a backup with a damaged token index is refused instead of closing the app.
+- The widget no longer hides itself as if a full-screen app were running when no window has the focus.
+- The update notice, the chart legend of a tile and the percentages in the top projects follow a language and number format change at once.
+- Hovering or clicking the value labels of a token usage chart no longer picks the first column.
+- A custom token usage range far in the past no longer freezes the window or throws, and ranges longer than a year are drawn per week.
+- The history export names each window, so several Cursor bars can be told apart.
+- An account name cut to its maximum length no longer ends in half an emoji.
+- Settings and history are flushed to disk before they replace the old file, so a power cut cannot leave an empty file behind.
+- After an in-app update the app starts again even when it closed before the setup looked for it, and uninstalling removes the desktop shortcut of a per-machine install.
+- The portable setup no longer writes a setup log to the PC, and `build.bat` no longer stops at the installer step when Inno Setup is installed under "Program Files (x86)".
### Security
diff --git a/build.bat b/build.bat
index 185ad2f..e734bf2 100644
--- a/build.bat
+++ b/build.bat
@@ -78,13 +78,13 @@ rem after a "for /f" command-substitution loop like the one above it (a known cm
rem GOTO :error, used everywhere else in this file, is unaffected and keeps working normally.
if exist "%STAGE%" rmdir /s /q "%STAGE%"
if exist "%STAGE%" (
- echo ERROR: could not clear %STAGE%
+ echo ERROR: could not clear "%STAGE%"
echo Something is still using it - close any running app or Explorer window there.
goto :error
)
mkdir "%STAGE%"
if errorlevel 1 (
- echo ERROR: could not create %STAGE%
+ echo ERROR: could not create "%STAGE%"
goto :error
)
@@ -98,7 +98,7 @@ if exist "%DIST%" (
mkdir "%DIST%"
)
if not exist "%DIST%" (
- echo ERROR: could not create %DIST%
+ echo ERROR: could not create "%DIST%"
goto :error
)
for %%f in ("%DIST%\AI-Usage.exe" "%DIST%\SHA256SUMS.txt") do (
@@ -171,7 +171,8 @@ if not defined ISCC if exist "%ProgramFiles%\Inno Setup 6\ISCC.exe" set "ISCC=%P
if not defined ISCC (
echo WARNING: Inno Setup ^(ISCC.exe^) not found - skipping the installer.
) else (
- echo Using %ISCC%
+ rem Quoted: the default "Program Files (x86)" path ends this block early at its ")" otherwise.
+ echo Using "%ISCC%"
"%ISCC%" /DAppVersion=%VERSION% "%ROOT%installer\AiUsage.iss"
if errorlevel 1 goto :error
)
diff --git a/installer/AiUsage.iss b/installer/AiUsage.iss
index 3e1720c..0aad90b 100644
--- a/installer/AiUsage.iss
+++ b/installer/AiUsage.iss
@@ -215,7 +215,9 @@ Filename: "{code:GetAppExePath}"; Flags: nowait runasoriginaluser; \
; Same hive limitation as autostart above: on a per-machine install approved by a different
; administrator account, the shortcut was written to the installing user's desktop, which an
; elevated uninstall running under that administrator's own account cannot see. Not worked around.
-Type: files; Name: "{autodesktop}\{#AppName}.lnk"
+; {userdesktop}, not {autodesktop}: on a per-machine install the latter is the Public desktop, where
+; the app never writes its shortcut.
+Type: files; Name: "{userdesktop}\{#AppName}.lnk"
[Registry]
; Creates nothing - the app writes the value itself (see [Run]). This entry exists only so
@@ -650,9 +652,24 @@ end;
{ The auto-relaunch Check in [Run] reads this - only relevant to the standard (non-portable),
silent path where no "launch now" checkbox was ever shown. }
+function HasSwitch(const Name: String): Boolean;
+var
+ I: Integer;
+begin
+ Result := False;
+ for I := 1 to ParamCount do
+ if CompareText(ParamStr(I), Name) = 0 then
+ begin
+ Result := True;
+ Exit;
+ end;
+end;
+
+{ /RELAUNCH comes from the app's own in-app update: the app has usually ended before the window
+ lookup in InitializeSetup runs, so that lookup alone would leave the tray icon gone. }
function WasAppRunning: Boolean;
begin
- Result := AppWasRunning;
+ Result := AppWasRunning or HasSwitch('/RELAUNCH');
end;
{ Deletes every file in Folder matching "setup-*.log" beyond the newest KeepCount, sorted by name
@@ -692,7 +709,8 @@ procedure CurStepChanged(CurStep: TSetupStep);
var
LogFolder, DestFile: String;
begin
- if CurStep = ssDone then
+ { Portable mode promises that nothing else is written to this PC. }
+ if (CurStep = ssDone) and not IsPortableMode then
begin
LogFolder := ExpandConstant('{localappdata}\{#AppName}\setup');
if ForceDirectories(LogFolder) and FileExists(ExpandConstant('{log}')) then
diff --git a/src/AiUsage/App.xaml.cs b/src/AiUsage/App.xaml.cs
index 5f7cfdf..eb720fc 100644
--- a/src/AiUsage/App.xaml.cs
+++ b/src/AiUsage/App.xaml.cs
@@ -250,6 +250,12 @@ void DeleteLeftoverOnce(object? sender, EventArgs args)
if (ShouldShowMainWindow(Dispatcher.HasShutdownStarted))
_mainWindow.Show();
}
+ else
+ {
+ // The view model starts out as "window visible"; left that way, a tray-only start polls
+ // at the open-window pace and the first show from the tray skips its catch-up refresh.
+ _mainWindow.ViewModel.WindowVisible = false;
+ }
// The one visible trace of a restore: where the previous data went, or that nothing changed.
if (_restoreOutcome != RestoreOutcome.NoPending)
diff --git a/src/AiUsage/Io/AtomicFile.cs b/src/AiUsage/Io/AtomicFile.cs
index eaa7ee3..469470a 100644
--- a/src/AiUsage/Io/AtomicFile.cs
+++ b/src/AiUsage/Io/AtomicFile.cs
@@ -11,7 +11,14 @@ public static class AtomicFile
public static void WriteAllBytes(string path, byte[] bytes)
{
var tempPath = $"{path}.{Environment.ProcessId}.tmp";
- File.WriteAllBytes(tempPath, bytes);
+ using (var stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None))
+ {
+ stream.Write(bytes);
+ // The rename is journaled but the content is not: without this, a power loss right after
+ // the move can leave the target empty instead of holding either version.
+ stream.Flush(flushToDisk: true);
+ }
+
File.Move(tempPath, path, overwrite: true);
}
}
diff --git a/src/AiUsage/Models/AppSettings.cs b/src/AiUsage/Models/AppSettings.cs
index 4a0d144..2fa3d62 100644
--- a/src/AiUsage/Models/AppSettings.cs
+++ b/src/AiUsage/Models/AppSettings.cs
@@ -347,7 +347,12 @@ public sealed class ProviderSettings
public static string NormalizeAccountName(string? name)
{
var trimmed = (name ?? "").Trim();
- return trimmed.Length > MaxAccountNameLength ? trimmed[..MaxAccountNameLength].TrimEnd() : trimmed;
+ if (trimmed.Length <= MaxAccountNameLength)
+ return trimmed;
+
+ // Never half of a character made of two code units (an emoji): that half is no character.
+ var cut = char.IsHighSurrogate(trimmed[MaxAccountNameLength - 1]) ? MaxAccountNameLength - 1 : MaxAccountNameLength;
+ return trimmed[..cut].TrimEnd();
}
public ThresholdSettings Thresholds { get; set; } = new();
diff --git a/src/AiUsage/Providers/CodexPaths.cs b/src/AiUsage/Providers/CodexPaths.cs
index a5b662d..6fb2823 100644
--- a/src/AiUsage/Providers/CodexPaths.cs
+++ b/src/AiUsage/Providers/CodexPaths.cs
@@ -4,7 +4,15 @@ namespace AiUsage.Providers;
/// folder for its rate limits and the statistics index reads both.
internal static class CodexPaths
{
- private static string Root => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex");
+ private static string Root => ResolveRoot(
+ Environment.GetEnvironmentVariable("CODEX_HOME"), Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), Directory.Exists);
+
+ /// Codex itself keeps everything under CODEX_HOME when that is set; ~/.codex otherwise,
+ /// and also when the variable names a folder that does not exist.
+ internal static string ResolveRoot(string? codexHome, string userProfile, Func folderExists) =>
+ !string.IsNullOrWhiteSpace(codexHome) && folderExists(codexHome.Trim())
+ ? codexHome.Trim()
+ : Path.Combine(userProfile, ".codex");
public static string Sessions => Path.Combine(Root, "sessions");
diff --git a/src/AiUsage/Providers/WebUsageSource.cs b/src/AiUsage/Providers/WebUsageSource.cs
index 7bbd3aa..04fe90d 100644
--- a/src/AiUsage/Providers/WebUsageSource.cs
+++ b/src/AiUsage/Providers/WebUsageSource.cs
@@ -403,7 +403,9 @@ private bool IsPathAndStatus(string? entry)
{
envelopeJson = await _executeScript(script, ct);
}
- catch (Exception ex) when (ex is not OperationCanceledException)
+ // Only the caller's own cancellation propagates: a hidden session closed under a running read
+ // throws one of its own, which is a failed read like any other.
+ catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
return (null, (WebUsageResult.Failed, null));
}
diff --git a/src/AiUsage/Services/FullscreenWatcher.cs b/src/AiUsage/Services/FullscreenWatcher.cs
index 196f6f6..cc28f78 100644
--- a/src/AiUsage/Services/FullscreenWatcher.cs
+++ b/src/AiUsage/Services/FullscreenWatcher.cs
@@ -83,7 +83,9 @@ internal static bool IsFullscreen(bool foregroundIsOwn, string? className, Scree
if (foregroundIsOwn || IsShellWindowClass(className))
return false;
- return foreground.Covers(monitor);
+ // An empty monitor rectangle (no foreground window, or the monitor lookup failed) is no screen
+ // to cover - the empty foreground rectangle would otherwise "cover" it.
+ return monitor.Right > monitor.Left && monitor.Bottom > monitor.Top && foreground.Covers(monitor);
}
/// The desktop and the taskbar: they fill the screen without being a full-screen app.
diff --git a/src/AiUsage/Services/HistoryExport.cs b/src/AiUsage/Services/HistoryExport.cs
index 918486e..656e9c6 100644
--- a/src/AiUsage/Services/HistoryExport.cs
+++ b/src/AiUsage/Services/HistoryExport.cs
@@ -11,7 +11,7 @@ namespace AiUsage.Services;
///
public static class HistoryExport
{
- private const string Header = "provider,window,timestamp_utc,percent,resets_at_utc,tokens";
+ private const string Header = "provider,window,timestamp_utc,percent,resets_at_utc,tokens,label";
public static string ToCsv(IReadOnlyDictionary> byProvider)
{
@@ -28,7 +28,9 @@ public static string ToCsv(IReadOnlyDictionaryThe setup's command line. The setup does not reuse the previous install scope on its
/// own, so the scope of the running copy is passed along. The uninstall entries the setup wrote
/// say which scope owns this folder (it works for a folder the user picked); only when neither
- /// names it does a copy under Program Files count as per-machine and any other as per-user.
+ /// names it does a copy under Program Files count as per-machine and any other as per-user. A
+ /// per-user setup also gets : this copy has usually ended by the time the
+ /// setup looks for its window, so the setup would not know to start the new version again. A
+ /// per-machine setup runs elevated from the start and would start it elevated, so it gets none.
internal static string BuildSetupArguments(string? exePath) => BuildSetupArguments(exePath,
[
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),
@@ -167,16 +170,21 @@ internal static string BuildSetupArguments(string? exePath, IEnumerable
if (SameFolder(readInstallLocation(true), directory))
return "/SILENT /ALLUSERS";
if (SameFolder(readInstallLocation(false), directory))
- return "/SILENT /CURRENTUSER";
+ return PerUserArguments;
}
var perMachine = directory is not null && programFilesRoots
.Where(root => !string.IsNullOrEmpty(root))
.Any(root => (directory + Path.DirectorySeparatorChar).StartsWith(
root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase));
- return perMachine ? "/SILENT /ALLUSERS" : "/SILENT /CURRENTUSER";
+ return perMachine ? "/SILENT /ALLUSERS" : PerUserArguments;
}
+ /// Read by the setup script (WasAppRunning): start the app again once the update is done.
+ internal const string RelaunchSwitch = "/RELAUNCH";
+
+ private const string PerUserArguments = "/SILENT /CURRENTUSER " + RelaunchSwitch;
+
private static bool SameFolder(string? recorded, string directory) =>
!string.IsNullOrWhiteSpace(recorded)
&& string.Equals(recorded.Trim().TrimEnd(Path.DirectorySeparatorChar), directory.TrimEnd(Path.DirectorySeparatorChar), StringComparison.OrdinalIgnoreCase);
diff --git a/src/AiUsage/Stats/CodexUsageLogParser.cs b/src/AiUsage/Stats/CodexUsageLogParser.cs
index cabfd06..33243af 100644
--- a/src/AiUsage/Stats/CodexUsageLogParser.cs
+++ b/src/AiUsage/Stats/CodexUsageLogParser.cs
@@ -112,15 +112,18 @@ public bool TryParseLine(string line, out UsageEvent result)
timestampProperty.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var timestamp))
return false;
- // A line missing any of the four running totals says nothing reliable: reading the gap
- // as zero would make the next complete line count its whole total as new. Such a line
- // is skipped and the previous totals stay.
+ // A line missing any of the three running totals Codex writes says nothing reliable:
+ // reading the gap as zero would make the next complete line count its whole total as new.
+ // Such a line is skipped and the previous totals stay.
if (!TryGetLong(total, "input_tokens", out var input)
|| !TryGetLong(total, "output_tokens", out var output)
- || !TryGetLong(total, "cache_write_input_tokens", out var cacheCreation)
|| !TryGetLong(total, "cached_input_tokens", out var cacheRead))
return false;
+ // Codex's own rollouts carry no cache-write total at all; requiring it dropped every real
+ // line. Where it is missing the previous total stands, so it adds nothing either way.
+ var cacheCreation = TryGetLong(total, "cache_write_input_tokens", out var cacheWrite) ? cacheWrite : _cumulativeCacheCreation;
+
// Codex resets its running totals not only across files but also mid file, after a
// context compaction - each of the four counters is checked against its own previous
// value independently, never against the others' sum, so one counter dropping while the
diff --git a/src/AiUsage/Stats/StatsIndexer.cs b/src/AiUsage/Stats/StatsIndexer.cs
index 40ce167..6a539ee 100644
--- a/src/AiUsage/Stats/StatsIndexer.cs
+++ b/src/AiUsage/Stats/StatsIndexer.cs
@@ -347,6 +347,14 @@ private static void WalkDirectory(
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
openLength = stream.Length;
+ if (IsShorterThanRead(existing, openLength))
+ {
+ // Replaced or cut between the stat above and this open: handled like a shrunk file
+ // rather than read again from the start (the reader falls back to 0 past the end).
+ _store.SetSourceFile(existing with { Offset = openLength, Size = openLength, WriteTimeUtc = info.LastWriteTimeUtc });
+ return (false, 0, 0);
+ }
+
consumedOffset = CompleteLineReader.Read(stream, offset, acceptTrailingLineWithoutNewline, MaxRecordBytes, LogOversizedRecord, line =>
{
if (ClaudeUsageLogParser.TryParse(line, out var usageEvent))
@@ -447,6 +455,14 @@ private static void WalkDirectory(
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
openLength = stream.Length;
+ if (IsShorterThanRead(existing, openLength))
+ {
+ // Replaced or cut between the stat above and this open: handled like a shrunk file
+ // rather than read again from the start (the reader falls back to 0 past the end).
+ _store.SetSourceFile(existing with { Offset = openLength, Size = openLength, WriteTimeUtc = info.LastWriteTimeUtc });
+ return (false, 0, 0);
+ }
+
consumedOffset = CompleteLineReader.Read(stream, offset, acceptTrailingLineWithoutNewline, MaxRecordBytes, LogOversizedRecord, line =>
{
if (parser.TryParseLine(line, out var usageEvent))
@@ -768,6 +784,11 @@ private bool TryFindState(string path, Dictionary
return _store.TryGetSourceFile(path, out state);
}
+ /// True when the opened file is shorter than what was already read of it - checked on the
+ /// open stream, since the file can change after it was listed.
+ internal static bool IsShorterThanRead([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] StatsSourceFileState? existing, long openLength) =>
+ existing is not null && openLength < existing.Offset;
+
private static FileInfo? TryStat(string path)
{
try
diff --git a/src/AiUsage/Stats/StatsStore.cs b/src/AiUsage/Stats/StatsStore.cs
index e79a34b..26f33a1 100644
--- a/src/AiUsage/Stats/StatsStore.cs
+++ b/src/AiUsage/Stats/StatsStore.cs
@@ -112,7 +112,9 @@ internal static void SnapshotIndex(string sourcePath, string destinationPath)
using var version = connection.CreateCommand();
version.CommandText = "SELECT MAX(version) FROM schema_version";
var value = version.ExecuteScalar();
- return value is null or DBNull ? null : (int)Math.Min(int.MaxValue, Convert.ToInt64(value, CultureInfo.InvariantCulture));
+ // Only an integer is a version; a file from elsewhere (a backup zip is never trusted) can
+ // hold text, a real or a blob there, which Convert would throw on past every caller's catch.
+ return value is long number ? (int)Math.Clamp(number, int.MinValue, int.MaxValue) : null;
}
catch (SqliteException)
{
@@ -702,6 +704,19 @@ value TEXT NOT NULL
if (originalVersion is null or < 4)
Execute(connection, $"INSERT OR IGNORE INTO meta (key, value) VALUES ('{SessionsBackfilledKey}', '1')");
+ // Builds before this one required a cache-write total Codex never writes, so every Codex file
+ // was walked to its end without counting a line. Such a file still has all four running
+ // totals at zero and is read again from the start, once - only while this PC has no Codex
+ // usage at all, so nothing that was counted can be counted a second time.
+ Execute(connection, $"""
+ DELETE FROM source_file WHERE provider = 'codex'
+ AND cumulative_input_tokens = 0 AND cumulative_output_tokens = 0
+ AND cumulative_cache_creation_tokens = 0 AND cumulative_cache_read_tokens = 0
+ AND NOT EXISTS (SELECT 1 FROM usage WHERE provider = 'codex' AND machine = '')
+ AND NOT EXISTS (SELECT 1 FROM meta WHERE key = '{CodexRereadKey}');
+ INSERT OR IGNORE INTO meta (key, value) VALUES ('{CodexRereadKey}', '1');
+ """);
+
using (var writeVersion = connection.CreateCommand())
{
writeVersion.CommandText = "INSERT OR REPLACE INTO schema_version (id, version) VALUES (1, $version)";
@@ -713,6 +728,7 @@ value TEXT NOT NULL
}
private const string SessionsBackfilledKey = "sessions_backfilled";
+ private const string CodexRereadKey = "codex_uncounted_files_reread";
private static void Execute(SqliteConnection connection, string sql)
{
diff --git a/src/AiUsage/Stats/StatsViewModel.cs b/src/AiUsage/Stats/StatsViewModel.cs
index 90f3b9c..3eb3e60 100644
--- a/src/AiUsage/Stats/StatsViewModel.cs
+++ b/src/AiUsage/Stats/StatsViewModel.cs
@@ -113,8 +113,8 @@ public void RestoreSelection(
{
if (customFrom is { } first && customTo is { } last)
{
- _customFrom = first <= last ? first : last;
- _customTo = first <= last ? last : first;
+ _customFrom = ClampCustomDay(first <= last ? first : last);
+ _customTo = ClampCustomDay(first <= last ? last : first);
}
if (RangeChoices.Any(choice => choice.Value == range) && (range != CustomRange || (customFrom is not null && customTo is not null)))
@@ -167,8 +167,10 @@ private async Task ApplyCustomRange()
/// Applies a custom range of the two days, whichever way round they come.
internal async Task SetCustomRange(DateOnly from, DateOnly to)
{
- _customFrom = from;
- _customTo = to;
+ // A date typed into the picker can be anything down to the year 1, where the previous period
+ // (from.AddDays(-1)) throws and the chart would draw a bar for every day since.
+ _customFrom = ClampCustomDay(from);
+ _customTo = ClampCustomDay(to);
SelectedDay = null;
SelectedRange = CustomRange;
UpdateCustomChoiceLabel();
@@ -300,7 +302,7 @@ public Choice? SelectedColorByChoice
private StatsGrouping selectedGrouping = StatsGrouping.Day;
/// True when the day grouping draws one column per calendar week instead of one per
- /// day: only for a whole history that spans more than a year. Up to 366 days every day gets its
+ /// day: only for a whole history or a custom range that spans more than a year. Up to 366 days every day gets its
/// own column, the 12 month range included. The table under the day grouping stays per day
/// regardless.
[ObservableProperty]
@@ -1136,7 +1138,7 @@ private void RecomputeFrom(IReadOnlyList source)
// up. The earliest day actually stored is the first one worth drawing.
var gapFillFrom = GapFillStart(from, inRange, to);
- var weeklyPerDay = SelectedRange == "All" && to.DayNumber - gapFillFrom.DayNumber + 1 > WeeklyPerDayThresholdDays;
+ var weeklyPerDay = SelectedRange is "All" or CustomRange && to.DayNumber - gapFillFrom.DayNumber + 1 > WeeklyPerDayThresholdDays;
IsWeeklyPerDay = weeklyPerDay;
// The table under the day grouping stays per day; only the bars are bundled per week.
@@ -1599,6 +1601,12 @@ internal static StatsPeriod ResolvePeriod(string range, DateOnly today, DateOnly
}
}
+ internal static readonly DateOnly EarliestCustomDay = new(2000, 1, 1);
+ internal static readonly DateOnly LatestCustomDay = new(2099, 12, 31);
+
+ internal static DateOnly ClampCustomDay(DateOnly day) =>
+ day < EarliestCustomDay ? EarliestCustomDay : day > LatestCustomDay ? LatestCustomDay : day;
+
private static StatsPeriod SameLengthBefore(DateOnly from, DateOnly to)
{
var previousTo = from.AddDays(-1);
diff --git a/src/AiUsage/Storage/LegacyWindowLabels.cs b/src/AiUsage/Storage/LegacyWindowLabels.cs
index 9b30de0..aacb9c6 100644
--- a/src/AiUsage/Storage/LegacyWindowLabels.cs
+++ b/src/AiUsage/Storage/LegacyWindowLabels.cs
@@ -36,8 +36,11 @@ internal static void Migrate(AppSettings settings, string trayLabelPrefix)
{
foreach (var (accountKey, provider) in settings.Providers)
{
+ // A hand-edited or damaged file can hold a null entry ("hiddenWindows": [null]); it names no
+ // window and would otherwise throw in the lookup below, on every start.
if (provider.HiddenWindows is { Count: > 0 } hidden)
- provider.HiddenWindows = hidden.Select(label => Migrate(accountKey, label)).Distinct(StringComparer.Ordinal).ToList();
+ provider.HiddenWindows = hidden.Where(label => label is not null).Select(label => Migrate(accountKey, label))
+ .Distinct(StringComparer.Ordinal).ToList();
}
// A "Label:Chat" choice belongs to Copilot only when the icon follows a Copilot account or the
diff --git a/src/AiUsage/ViewModels/MainViewModel.cs b/src/AiUsage/ViewModels/MainViewModel.cs
index d5e1446..0adb9f3 100644
--- a/src/AiUsage/ViewModels/MainViewModel.cs
+++ b/src/AiUsage/ViewModels/MainViewModel.cs
@@ -741,6 +741,7 @@ private void RefreshLocalizedText()
if (displayText is not null)
choice.Label = displayText;
}
+ Update.RefreshLocalizedText();
OnPropertyChanged(nameof(EyeButtonAccessibleName));
OnPropertyChanged(nameof(NewerVersionWarningText));
}
diff --git a/src/AiUsage/ViewModels/ProviderTileViewModel.cs b/src/AiUsage/ViewModels/ProviderTileViewModel.cs
index 98b5f0c..00dd0f1 100644
--- a/src/AiUsage/ViewModels/ProviderTileViewModel.cs
+++ b/src/AiUsage/ViewModels/ProviderTileViewModel.cs
@@ -954,8 +954,16 @@ public void RefreshLocalizedText()
OnPropertyChanged(nameof(SettingsRowText));
OnPropertyChanged(nameof(DetailsMenuHeader));
OnPropertyChanged(nameof(LastSuccessAgeText));
+ // The chart's legend and hover caption are otherwise only rebuilt with the next history load.
+ FiveHourLabelText = StatusTextMap.Resolve(_slotOneLabelKey);
+ WeeklyLabelText = StatusTextMap.Resolve(_slotTwoLabelKey);
+ OnPropertyChanged(nameof(ChartSummaryText));
}
+ // The keys the chart's two slot captions were resolved from, kept for a language switch.
+ private string _slotOneLabelKey = "Window_FiveHour";
+ private string _slotTwoLabelKey = "Window_Weekly";
+
/// is optional (defaults to null, meaning "no marker on
/// any row") purely so every existing caller that has no thresholds handy keeps compiling
/// unchanged; a caller that does hold the provider's passes them
@@ -1413,8 +1421,10 @@ public void UpdateHistory(
FiveHourValues = slotOneValues;
WeeklyValues = slotTwoValues;
- FiveHourLabelText = StatusTextMap.Resolve(slotOneLabel ?? "Window_FiveHour");
- WeeklyLabelText = StatusTextMap.Resolve(slotTwoLabel ?? "Window_Weekly");
+ _slotOneLabelKey = slotOneLabel ?? "Window_FiveHour";
+ _slotTwoLabelKey = slotTwoLabel ?? "Window_Weekly";
+ FiveHourLabelText = StatusTextMap.Resolve(_slotOneLabelKey);
+ WeeklyLabelText = StatusTextMap.Resolve(_slotTwoLabelKey);
RangeStart = rangeStart;
RangeEnd = rangeEnd;
// Mirrors the weekly series when it has anything to show, otherwise the five-hour one -
diff --git a/src/AiUsage/ViewModels/SettingsViewModel.cs b/src/AiUsage/ViewModels/SettingsViewModel.cs
index 4e4cfec..5fd0f62 100644
--- a/src/AiUsage/ViewModels/SettingsViewModel.cs
+++ b/src/AiUsage/ViewModels/SettingsViewModel.cs
@@ -434,7 +434,13 @@ private static string LevelPercent(double level) =>
/// does, same split as every other provider-card control that binds
/// to directly.
[RelayCommand]
- private void AddAccount(string providerId) => Main.AddAccountCommand.Execute(providerId);
+ private void AddAccount(string providerId)
+ {
+ // Execute alone skips the async command's own "still running" check, and a Copilot add waits
+ // on the GitHub CLI first - a quick second click would add the same login twice.
+ if (Main.AddAccountCommand.CanExecute(providerId))
+ Main.AddAccountCommand.Execute(providerId);
+ }
/// Asks whether to remove an account and whether its recorded history goes too. The
/// Settings window sets the real confirm dialog; without one nothing is confirmed, so a missing
diff --git a/src/AiUsage/ViewModels/UpdateNoticeViewModel.cs b/src/AiUsage/ViewModels/UpdateNoticeViewModel.cs
index 5a1f0b3..9c344e4 100644
--- a/src/AiUsage/ViewModels/UpdateNoticeViewModel.cs
+++ b/src/AiUsage/ViewModels/UpdateNoticeViewModel.cs
@@ -74,6 +74,9 @@ public UpdateNoticeViewModel(AppSettings settings, Action save, Fun
public void Dismiss() => Dismissed = true;
+ /// A language switch: the notice text is built from a resource string.
+ public void RefreshLocalizedText() => OnPropertyChanged(nameof(NoticeText));
+
/// Runs the daily check (see ) and refreshes
/// the notice from what it found. Returns the release the feed answered with, null when the
/// check was off, not due or failed.
diff --git a/src/AiUsage/Views/Controls/StatsBarChart.cs b/src/AiUsage/Views/Controls/StatsBarChart.cs
index 1e42685..8a7b124 100644
--- a/src/AiUsage/Views/Controls/StatsBarChart.cs
+++ b/src/AiUsage/Views/Controls/StatsBarChart.cs
@@ -438,12 +438,13 @@ internal static IReadOnlyList YAxisTicks(long maxTotal)
/// The bar index under pixel of a chart area wide holding equal slots (the same slot width itself divides the area into) - clamped to a valid index rather than -1, since
- /// every pixel column belongs to exactly one slot. Pure so hover hit testing is unit testable
- /// without a visual tree or a live mouse.
+ /// cref="Columns"/> itself divides the area into) - clamped to a valid index, since every pixel
+ /// column of the area belongs to exactly one slot. A negative x lies left of the area, over the
+ /// value axis labels, and is no bar (-1). Pure so hover hit testing is unit testable without a
+ /// visual tree or a live mouse.
internal static int NearestBarIndex(double x, double width, int barCount)
{
- if (barCount <= 0 || width <= 0)
+ if (barCount <= 0 || width <= 0 || x < 0)
return -1;
var slot = width / barCount;
diff --git a/src/AiUsage/Views/Controls/StatsHorizontalBarChart.cs b/src/AiUsage/Views/Controls/StatsHorizontalBarChart.cs
index e8becd8..94d3da3 100644
--- a/src/AiUsage/Views/Controls/StatsHorizontalBarChart.cs
+++ b/src/AiUsage/Views/Controls/StatsHorizontalBarChart.cs
@@ -717,7 +717,7 @@ private void DrawRichRow(
}
var figureText = new FormattedText(
- $"{StatsAggregator.ShortenTokenCount(row.Total, ThousandSuffix, MillionSuffix, BillionSuffix)} · {StatsBarChart.PercentLabel(row.Percent, culture)}",
+ $"{StatsAggregator.ShortenTokenCount(row.Total, ThousandSuffix, MillionSuffix, BillionSuffix)} · {StatsBarChart.PercentLabel(row.Percent, CultureInfo.CurrentCulture)}",
culture, FlowDirection.LeftToRight, regular, 11, TextBrush, dpi);
var figureX = Math.Max(textX, width - figureText.Width);
drawingContext.DrawText(figureText, new Point(figureX, y + (textAreaHeight - figureText.Height) / 2));
diff --git a/src/AiUsage/Views/MainWindow.xaml.cs b/src/AiUsage/Views/MainWindow.xaml.cs
index 494cf78..70c6526 100644
--- a/src/AiUsage/Views/MainWindow.xaml.cs
+++ b/src/AiUsage/Views/MainWindow.xaml.cs
@@ -1261,9 +1261,11 @@ private void GrowUp(double dy)
_heightIsAutomatic = false;
ViewModel.HeightAutomatic = false;
}
- // Same stale-ceiling fix as GrowDown - see its own comment.
- MaxHeight = CurrentArea().Height;
- var newHeight = Math.Max(MinHeight, Height - dy);
+ // Same stale-ceiling fix and the same limits as GrowDown - see its own comment. Top moves
+ // only by what the height actually changed, so a capped drag never slides the window away.
+ var workAreaHeight = CurrentArea().Height;
+ MaxHeight = workAreaHeight;
+ var newHeight = WindowPlacementService.GrowManualHeight(Height, -dy, MinHeight, workAreaHeight, MeasureTallestContentHeight(), _zoom);
Top += Height - newHeight;
Height = newHeight;
}
diff --git a/src/AiUsage/Web/AuthenticodeSignature.cs b/src/AiUsage/Web/AuthenticodeSignature.cs
index f1e168b..d4c020d 100644
--- a/src/AiUsage/Web/AuthenticodeSignature.cs
+++ b/src/AiUsage/Web/AuthenticodeSignature.cs
@@ -40,7 +40,7 @@ private static List ReadSignerNames(string path)
try
{
#pragma warning disable SYSLIB0057 // No replacement reads the signer of a signed file; WinVerifyTrust above has already proven the signature.
- var certificate = X509Certificate.CreateFromSignedFile(path);
+ using var certificate = X509Certificate.CreateFromSignedFile(path);
#pragma warning restore SYSLIB0057
using var certificate2 = new X509Certificate2(certificate);
var names = new List();
diff --git a/tests/AiUsage.Tests/CodexPathsTests.cs b/tests/AiUsage.Tests/CodexPathsTests.cs
new file mode 100644
index 0000000..04a70d0
--- /dev/null
+++ b/tests/AiUsage.Tests/CodexPathsTests.cs
@@ -0,0 +1,30 @@
+using AiUsage.Providers;
+using Xunit;
+
+namespace AiUsage.Tests;
+
+public class CodexPathsTests
+{
+ private const string Profile = @"D:\Profiles\someone";
+
+ [Fact]
+ public void CODEX_HOME_is_where_Codex_keeps_its_sessions_when_it_is_set()
+ {
+ Assert.Equal(@"D:\codex", CodexPaths.ResolveRoot(@"D:\codex", Profile, _ => true));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Without_CODEX_HOME_the_folder_in_the_profile_is_read(string? codexHome)
+ {
+ Assert.Equal(Path.Combine(Profile, ".codex"), CodexPaths.ResolveRoot(codexHome, Profile, _ => true));
+ }
+
+ [Fact]
+ public void A_CODEX_HOME_naming_a_missing_folder_falls_back_to_the_profile()
+ {
+ Assert.Equal(Path.Combine(Profile, ".codex"), CodexPaths.ResolveRoot(@"D:\gone", Profile, _ => false));
+ }
+}
diff --git a/tests/AiUsage.Tests/FullscreenWatcherTests.cs b/tests/AiUsage.Tests/FullscreenWatcherTests.cs
index 8cbb521..bb07d71 100644
--- a/tests/AiUsage.Tests/FullscreenWatcherTests.cs
+++ b/tests/AiUsage.Tests/FullscreenWatcherTests.cs
@@ -91,4 +91,11 @@ public void Hiding_on_full_screen_is_on_by_default_and_survives_a_round_trip()
Assert.False(reload.Load().HideOnFullscreen);
}
+
+ // Without a foreground window both rectangles are empty, and an empty one used to "cover" the other.
+ [Fact]
+ public void Without_a_foreground_window_or_a_monitor_nothing_is_full_screen()
+ {
+ Assert.False(FullscreenWatcher.IsFullscreen(false, null, default, default, AcceptsNotifications));
+ }
}
diff --git a/tests/AiUsage.Tests/HistoryExportTests.cs b/tests/AiUsage.Tests/HistoryExportTests.cs
index 5a0ee85..8cb598a 100644
--- a/tests/AiUsage.Tests/HistoryExportTests.cs
+++ b/tests/AiUsage.Tests/HistoryExportTests.cs
@@ -13,7 +13,7 @@ public class HistoryExportTests
[Fact]
public void ToCsv_writes_the_header_row_first() =>
Assert.StartsWith(
- "provider,window,timestamp_utc,percent,resets_at_utc,tokens\r\n",
+ "provider,window,timestamp_utc,percent,resets_at_utc,tokens,label\r\n",
HistoryExport.ToCsv(new Dictionary>()));
[Fact]
@@ -26,7 +26,7 @@ public void ToCsv_writes_one_fully_populated_row()
var row = HistoryExport.ToCsv(byProvider).Split("\r\n")[1];
- Assert.Equal("codex,FiveHour,2026-03-15T14:30:00.0000000Z,42.5,2026-03-15T16:30:00.0000000Z,12345", row);
+ Assert.Equal("codex,FiveHour,2026-03-15T14:30:00.0000000Z,42.5,2026-03-15T16:30:00.0000000Z,12345,", row);
}
[Fact]
@@ -40,7 +40,7 @@ public void ToCsv_leaves_reset_and_tokens_empty_rather_than_the_word_null()
var csv = HistoryExport.ToCsv(byProvider);
var row = csv.Split("\r\n")[1];
- Assert.Equal("codex,Weekly,2026-03-15T14:30:00.0000000Z,10,,", row);
+ Assert.Equal("codex,Weekly,2026-03-15T14:30:00.0000000Z,10,,,", row);
Assert.DoesNotContain("null", csv);
}
@@ -79,4 +79,22 @@ public void ToCsv_escapes_a_provider_id_with_quotes_and_a_formula_prefix()
Assert.StartsWith("\"'=cmd\"\"x\",Weekly,", row);
}
+
+ [Fact]
+ public void ToCsv_writes_the_window_label_so_two_other_series_stay_apart()
+ {
+ var byProvider = new Dictionary>
+ {
+ ["cursor"] =
+ [
+ new HistoryPoint(1, Timestamp, WindowKind.Other, 10, null, Label: "Window_CursorModels"),
+ new HistoryPoint(1, Timestamp, WindowKind.Other, 20, null, Label: "Window_CursorGrok"),
+ ],
+ };
+
+ var rows = HistoryExport.ToCsv(byProvider).Split("\r\n");
+
+ Assert.EndsWith(",Window_CursorModels", rows[1]);
+ Assert.EndsWith(",Window_CursorGrok", rows[2]);
+ }
}
diff --git a/tests/AiUsage.Tests/SettingsStoreTests.cs b/tests/AiUsage.Tests/SettingsStoreTests.cs
index 48eac2e..87db2cb 100644
--- a/tests/AiUsage.Tests/SettingsStoreTests.cs
+++ b/tests/AiUsage.Tests/SettingsStoreTests.cs
@@ -1402,4 +1402,28 @@ public ValueTask DisposeAsync()
}
}
}
+
+ [Fact]
+ public void Load_drops_a_null_hidden_window_instead_of_failing_to_start()
+ {
+ var directory = TempDirectory();
+ File.WriteAllText(Path.Combine(directory, "settings.json"),
+ """{"schemaVersion":1,"providers":{"copilot":{"hiddenWindows":[null,"Chat"]}}}""");
+ using var store = new SettingsStore(directory);
+
+ var loaded = store.Load();
+
+ Assert.Equal(["Window_CopilotChat"], loaded.Providers["copilot"].HiddenWindows);
+ }
+
+ [Fact]
+ public void An_account_name_is_never_cut_in_the_middle_of_an_emoji()
+ {
+ var name = new string('a', ProviderSettings.MaxAccountNameLength - 1) + "\U0001F600";
+
+ var normalized = ProviderSettings.NormalizeAccountName(name);
+
+ Assert.Equal(new string('a', ProviderSettings.MaxAccountNameLength - 1), normalized);
+ Assert.Equal("Work", ProviderSettings.NormalizeAccountName(" Work "));
+ }
}
diff --git a/tests/AiUsage.Tests/SettingsViewModelTests.cs b/tests/AiUsage.Tests/SettingsViewModelTests.cs
index ac32de1..bd7c79c 100644
--- a/tests/AiUsage.Tests/SettingsViewModelTests.cs
+++ b/tests/AiUsage.Tests/SettingsViewModelTests.cs
@@ -710,7 +710,7 @@ public void ExportHistoryWritesACsvFileStartingWithTheHeaderThroughTheFakePathPr
vm.ExportHistoryCommand.Execute(null);
Assert.True(File.Exists(exportPath));
- Assert.StartsWith("provider,window,timestamp_utc,percent,resets_at_utc,tokens\r\n", File.ReadAllText(exportPath));
+ Assert.StartsWith("provider,window,timestamp_utc,percent,resets_at_utc,tokens,label\r\n", File.ReadAllText(exportPath));
}
[Fact]
@@ -1769,4 +1769,23 @@ public void A_reset_never_changes_this_PCs_id_or_the_imported_list()
Assert.Equal(OwnPc, settings.MachineId);
Assert.Equal(OtherPc, Assert.Single(settings.ImportedMachines).Id);
}
+
+ // The Copilot add waits on the GitHub CLI first; a second click meanwhile added the same login twice.
+ [Fact]
+ public async Task A_second_add_account_click_while_the_first_still_runs_adds_nothing()
+ {
+ var (vm, _) = Build();
+ var accounts = new TaskCompletionSource>();
+ vm.Main.GetGitHubAccountsAsync = _ => accounts.Task;
+ var before = vm.Main.Tiles.Count;
+
+ vm.AddAccountCommand.Execute("copilot");
+ vm.AddAccountCommand.Execute("copilot");
+ accounts.SetResult([new("github.com", "second-user", Active: false)]);
+ if (vm.Main.AddAccountCommand.ExecutionTask is { } running)
+ await running;
+
+ Assert.Equal(before + 1, vm.Main.Tiles.Count);
+ vm.Dispose();
+ }
}
diff --git a/tests/AiUsage.Tests/StatsIndexTests.cs b/tests/AiUsage.Tests/StatsIndexTests.cs
index 8dffde3..4028555 100644
--- a/tests/AiUsage.Tests/StatsIndexTests.cs
+++ b/tests/AiUsage.Tests/StatsIndexTests.cs
@@ -1319,4 +1319,117 @@ private static string ClaudeLine(string timestamp, string model, long input, lon
"{\"type\":\"assistant\",\"timestamp\":\"" + timestamp + "\",\"message\":{\"model\":\"" + model +
"\",\"usage\":{\"input_tokens\":" + input + ",\"output_tokens\":" + output +
",\"cache_creation_input_tokens\":" + cacheCreation + ",\"cache_read_input_tokens\":" + cacheRead + "}}}";
+
+ // Codex's own rollouts write no cache-write total; requiring one dropped every real line.
+ [Fact]
+ public void CodexUsageLogParser_counts_a_real_line_without_a_cache_write_total()
+ {
+ const string first = """
+ {"timestamp":"2026-09-01T09:30:00Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1200,"cached_input_tokens":200,"output_tokens":90,"reasoning_output_tokens":10,"total_tokens":1290}}}}
+ """;
+ const string second = """
+ {"timestamp":"2026-09-01T09:31:00Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1500,"cached_input_tokens":300,"output_tokens":100,"reasoning_output_tokens":10,"total_tokens":1600}}}}
+ """;
+
+ var parser = new CodexUsageLogParser();
+ Assert.True(parser.TryParseLine(first, out var one));
+ Assert.Equal(1000, one.InputTokens);
+ Assert.Equal(90, one.OutputTokens);
+ Assert.Equal(200, one.CacheReadTokens);
+ Assert.Equal(0, one.CacheCreationTokens);
+
+ Assert.True(parser.TryParseLine(second, out var two));
+ Assert.Equal(200, two.InputTokens);
+ Assert.Equal(10, two.OutputTokens);
+ Assert.Equal(100, two.CacheReadTokens);
+ }
+
+ [Fact]
+ public void CodexUsageLogParser_keeps_the_previous_cache_write_total_when_a_line_lacks_it()
+ {
+ const string withField = """
+ {"timestamp":"2026-09-01T09:30:00Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":100,"cached_input_tokens":0,"cache_write_input_tokens":40,"output_tokens":10}}}}
+ """;
+ const string without = """
+ {"timestamp":"2026-09-01T09:31:00Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":150,"cached_input_tokens":0,"output_tokens":20}}}}
+ """;
+ const string withAgain = """
+ {"timestamp":"2026-09-01T09:32:00Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":160,"cached_input_tokens":0,"cache_write_input_tokens":45,"output_tokens":25}}}}
+ """;
+
+ var parser = new CodexUsageLogParser();
+ Assert.True(parser.TryParseLine(withField, out _));
+ Assert.True(parser.TryParseLine(without, out var gap));
+ Assert.Equal(0, gap.CacheCreationTokens);
+ Assert.True(parser.TryParseLine(withAgain, out var after));
+ Assert.Equal(5, after.CacheCreationTokens); // 45 - 40, never the whole 45 again
+ }
+
+ // Files an older build walked without counting a line (all four totals still zero) are read
+ // again once; a file that did count something, and any Claude file, keep their place. The v4 and
+ // v5 migration tests above cover an index that holds Codex usage, where nothing is read again.
+ [Fact]
+ public void Opening_an_older_index_rereads_Codex_files_that_counted_nothing_once()
+ {
+ using var source = TestPaths.CreateDisposableDirectory("stats-codex-reread-src");
+ var store = new StatsStore(source);
+ store.SetSourceFile(new StatsSourceFileState("codex-uncounted", "codex", 500, 500, DateTime.UtcNow));
+ store.SetSourceFile(new StatsSourceFileState("codex-counted", "codex", 500, 500, DateTime.UtcNow, CumulativeInputTokens: 10));
+ store.SetSourceFile(new StatsSourceFileState("claude-file", "claude", 500, 500, DateTime.UtcNow));
+ SqliteConnection.ClearAllPools();
+ var sourceDb = Path.Combine(source, "stats.db");
+ using (var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = sourceDb, Pooling = false }.ToString()))
+ {
+ connection.Open();
+ using var forget = connection.CreateCommand();
+ forget.CommandText = "DELETE FROM meta WHERE key = 'codex_uncounted_files_reread'";
+ forget.ExecuteNonQuery();
+ }
+
+ // A copy at a new path, so this process opens it as an older build's file.
+ using var target = TestPaths.CreateDisposableDirectory("stats-codex-reread");
+ File.Copy(sourceDb, Path.Combine(target, "stats.db"));
+ var reopened = new StatsStore(target);
+
+ Assert.Null(reopened.GetSourceFile("codex-uncounted"));
+ Assert.Equal(500, reopened.GetSourceFile("codex-counted")!.Offset);
+ Assert.Equal(500, reopened.GetSourceFile("claude-file")!.Offset);
+
+ // Once only: a file walked by this build keeps its place on every later open.
+ reopened.SetSourceFile(new StatsSourceFileState("codex-new", "codex", 300, 300, DateTime.UtcNow));
+ SqliteConnection.ClearAllPools();
+ using var again = TestPaths.CreateDisposableDirectory("stats-codex-reread-again");
+ File.Copy(Path.Combine(target, "stats.db"), Path.Combine(again, "stats.db"));
+ Assert.Equal(300, new StatsStore(again).GetSourceFile("codex-new")!.Offset);
+ }
+
+ [Fact]
+ public void A_file_shorter_than_its_read_offset_when_opened_is_not_read_from_the_start()
+ {
+ var state = new StatsSourceFileState("file-a", "codex", 8000, 8000, DateTime.UtcNow);
+
+ Assert.True(StatsIndexer.IsShorterThanRead(state, 5000));
+ Assert.False(StatsIndexer.IsShorterThanRead(state, 8000));
+ Assert.False(StatsIndexer.IsShorterThanRead(state, 10000));
+ Assert.False(StatsIndexer.IsShorterThanRead(null, 0));
+ }
+
+ [Theory]
+ [InlineData("'x'")]
+ [InlineData("1e300")]
+ [InlineData("x'00'")]
+ public void A_schema_version_that_is_no_integer_reads_as_unknown_instead_of_throwing(string value)
+ {
+ using var directory = TestPaths.CreateDisposableDirectory("stats-bad-version");
+ var path = Path.Combine(directory, "stats.db");
+ using (var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = false }.ToString()))
+ {
+ connection.Open();
+ using var create = connection.CreateCommand();
+ create.CommandText = $"CREATE TABLE schema_version (version); INSERT INTO schema_version VALUES ({value});";
+ create.ExecuteNonQuery();
+ }
+
+ Assert.Null(StatsStore.TryReadSchemaVersion(path));
+ }
}
diff --git a/tests/AiUsage.Tests/StatsViewModelTests.cs b/tests/AiUsage.Tests/StatsViewModelTests.cs
index 3175bbe..5dd3f5a 100644
--- a/tests/AiUsage.Tests/StatsViewModelTests.cs
+++ b/tests/AiUsage.Tests/StatsViewModelTests.cs
@@ -1705,4 +1705,12 @@ public async Task SessionRowsFollowTheRangeAndLeaveTheOtherGroupingsEmpty()
Assert.True(viewModel.IsBarChart);
Assert.True(viewModel.ShowPlainTable);
}
+
+ [Fact]
+ public void A_custom_day_is_kept_within_a_range_the_charts_can_draw()
+ {
+ Assert.Equal(StatsViewModel.EarliestCustomDay, StatsViewModel.ClampCustomDay(DateOnly.MinValue));
+ Assert.Equal(StatsViewModel.LatestCustomDay, StatsViewModel.ClampCustomDay(DateOnly.MaxValue));
+ Assert.Equal(new DateOnly(2026, 9, 1), StatsViewModel.ClampCustomDay(new DateOnly(2026, 9, 1)));
+ }
}
diff --git a/tests/AiUsage.Tests/StatsWindowTests.cs b/tests/AiUsage.Tests/StatsWindowTests.cs
index 48fd448..3aa9157 100644
--- a/tests/AiUsage.Tests/StatsWindowTests.cs
+++ b/tests/AiUsage.Tests/StatsWindowTests.cs
@@ -1461,4 +1461,12 @@ public void The_bundled_other_projects_bar_takes_the_muted_brush()
Assert.Same(muted, brushes[0]);
}
+
+ // Left of the bars lie the value axis labels: hovering or clicking them is no bar.
+ [Fact]
+ public void NearestBarIndexFindsNoBarLeftOfTheChartArea()
+ {
+ Assert.Equal(-1, StatsBarChart.NearestBarIndex(x: -5, width: 200, barCount: 4));
+ Assert.Equal(0, StatsBarChart.NearestBarIndex(x: 0, width: 200, barCount: 4));
+ }
}
diff --git a/tests/AiUsage.Tests/UpdateHostTests.cs b/tests/AiUsage.Tests/UpdateHostTests.cs
index 56961a9..9e05de1 100644
--- a/tests/AiUsage.Tests/UpdateHostTests.cs
+++ b/tests/AiUsage.Tests/UpdateHostTests.cs
@@ -22,15 +22,15 @@ public void A_per_user_copy_updates_for_the_current_user()
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var exe = Path.Combine(local, "Programs", "AI-Usage", "AI-Usage.exe");
- Assert.Equal("/SILENT /CURRENTUSER", UpdateHost.BuildSetupArguments(exe));
- Assert.Equal("/SILENT /CURRENTUSER", UpdateHost.BuildSetupArguments(exe, Roots));
+ Assert.Equal("/SILENT /CURRENTUSER /RELAUNCH", UpdateHost.BuildSetupArguments(exe));
+ Assert.Equal("/SILENT /CURRENTUSER /RELAUNCH", UpdateHost.BuildSetupArguments(exe, Roots));
}
[Fact]
public void A_folder_that_only_shares_the_Program_Files_prefix_is_per_user()
{
- Assert.Equal("/SILENT /CURRENTUSER", UpdateHost.BuildSetupArguments(@"C:\Program Files Extra\AI-Usage\AI-Usage.exe", Roots));
- Assert.Equal("/SILENT /CURRENTUSER", UpdateHost.BuildSetupArguments(null, Roots));
+ Assert.Equal("/SILENT /CURRENTUSER /RELAUNCH", UpdateHost.BuildSetupArguments(@"C:\Program Files Extra\AI-Usage\AI-Usage.exe", Roots));
+ Assert.Equal("/SILENT /CURRENTUSER /RELAUNCH", UpdateHost.BuildSetupArguments(null, Roots));
}
[Fact]
@@ -46,14 +46,14 @@ public void A_per_user_install_in_a_Program_Files_folder_stays_per_user_when_onl
{
var exe = @"C:\Program Files\Own\AI-Usage.exe";
- Assert.Equal("/SILENT /CURRENTUSER", UpdateHost.BuildSetupArguments(exe, Roots, perMachine => perMachine ? null : @"C:\Program Files\Own"));
+ Assert.Equal("/SILENT /CURRENTUSER /RELAUNCH", UpdateHost.BuildSetupArguments(exe, Roots, perMachine => perMachine ? null : @"C:\Program Files\Own"));
}
[Fact]
public void Without_a_registry_entry_the_Program_Files_prefix_decides()
{
Assert.Equal("/SILENT /ALLUSERS", UpdateHost.BuildSetupArguments(@"C:\Program Files\AI-Usage\AI-Usage.exe", Roots, _ => null));
- Assert.Equal("/SILENT /CURRENTUSER", UpdateHost.BuildSetupArguments(@"D:\Tools\AI-Usage\AI-Usage.exe", Roots, _ => null));
+ Assert.Equal("/SILENT /CURRENTUSER /RELAUNCH", UpdateHost.BuildSetupArguments(@"D:\Tools\AI-Usage\AI-Usage.exe", Roots, _ => null));
}
private static byte[] PeFile(ushort machine, int peOffset = 0x80, int length = 0x200)
diff --git a/tests/AiUsage.Tests/UpdateNoticeTests.cs b/tests/AiUsage.Tests/UpdateNoticeTests.cs
index 14b1eba..dd4c5c1 100644
--- a/tests/AiUsage.Tests/UpdateNoticeTests.cs
+++ b/tests/AiUsage.Tests/UpdateNoticeTests.cs
@@ -172,6 +172,18 @@ public async Task InstallingWithoutAReachableOrNewerReleaseRunsNothing()
Assert.Equal(UpdateOutcome.DownloadFailed, (await notNewer.InstallAsync(CancellationToken.None)).Outcome);
Assert.Empty(host.Ran);
}
+
+ [Fact]
+ public void A_language_switch_rebuilds_the_notice_text()
+ {
+ var update = new UpdateNoticeViewModel(new AppSettings { KnownLatestTag = "v9.0.0" }, _ => { });
+ var changed = new List();
+ update.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
+
+ update.RefreshLocalizedText();
+
+ Assert.Contains(nameof(UpdateNoticeViewModel.NoticeText), changed);
+ }
}
public class UpdateSigningKeyGuardTests
diff --git a/tests/AiUsage.Tests/WebUsageSourceTests.cs b/tests/AiUsage.Tests/WebUsageSourceTests.cs
index 8d6b5eb..6d8503a 100644
--- a/tests/AiUsage.Tests/WebUsageSourceTests.cs
+++ b/tests/AiUsage.Tests/WebUsageSourceTests.cs
@@ -402,4 +402,30 @@ public async Task No_email_field_at_all_leaves_the_account_label_null()
Assert.Null(result.AccountLabel);
}
+
+ // A hidden session closed under a running read throws a cancellation of its own; only the
+ // caller's own cancellation may leave the source, anything else is a failed read.
+ [Fact]
+ public async Task A_cancellation_the_caller_did_not_ask_for_is_a_failed_read()
+ {
+ var source = Source((_, _) => throw new OperationCanceledException("The hidden session was closed."));
+
+ var result = await source.FetchAsync(new AppSettings(), _ => { }, CancellationToken.None);
+
+ Assert.Equal(WebUsageOutcome.Failed, result.Outcome);
+ }
+
+ [Fact]
+ public async Task The_callers_own_cancellation_still_propagates()
+ {
+ using var cts = new CancellationTokenSource();
+ var source = Source((_, ct) =>
+ {
+ cts.Cancel();
+ ct.ThrowIfCancellationRequested();
+ return Task.FromResult("");
+ });
+
+ await Assert.ThrowsAnyAsync(() => source.FetchAsync(new AppSettings(), _ => { }, cts.Token));
+ }
}