From 0a1d44af16e35f6ea54b95b2ba58f567022c97b9 Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Tue, 15 Sep 2026 15:08:59 +0800 Subject: [PATCH 1/3] feat: check GitHub for newer releases on startup Adds a standalone AIUsageMonitor.UpdateCheck project that queries GitHub's latest-release API and compares it against the running version. Every command checks once after finishing; watch checks once before entering its refresh loop and keeps the notice pinned to the bottom of the view for the whole session. --- AIUsageMonitor.slnx | 2 + Directory.Packages.props | 1 + .../AIUsageMonitor.Cli.csproj | 2 + .../Commands/WatchCommand.cs | 49 ++++++++---- src/AIUsageMonitor.Cli/Program.cs | 21 ++++- src/AIUsageMonitor.Cli/UpdateNotice.cs | 40 ++++++++++ .../AIUsageMonitor.UpdateCheck.csproj | 12 +++ src/AIUsageMonitor.UpdateCheck/AppVersion.cs | 23 ++++++ .../GitHubReleaseResponse.cs | 19 +++++ .../IUpdateChecker.cs | 26 ++++++ .../UpdateCheckJsonContext.cs | 10 +++ .../UpdateChecker.cs | 78 ++++++++++++++++++ .../VersionParser.cs | 30 +++++++ .../AIUsageMonitor.UpdateCheck.Tests.csproj | 23 ++++++ .../UpdateCheckerTests.cs | 79 +++++++++++++++++++ 15 files changed, 396 insertions(+), 19 deletions(-) create mode 100644 src/AIUsageMonitor.Cli/UpdateNotice.cs create mode 100644 src/AIUsageMonitor.UpdateCheck/AIUsageMonitor.UpdateCheck.csproj create mode 100644 src/AIUsageMonitor.UpdateCheck/AppVersion.cs create mode 100644 src/AIUsageMonitor.UpdateCheck/GitHubReleaseResponse.cs create mode 100644 src/AIUsageMonitor.UpdateCheck/IUpdateChecker.cs create mode 100644 src/AIUsageMonitor.UpdateCheck/UpdateCheckJsonContext.cs create mode 100644 src/AIUsageMonitor.UpdateCheck/UpdateChecker.cs create mode 100644 src/AIUsageMonitor.UpdateCheck/VersionParser.cs create mode 100644 tests/AIUsageMonitor.UpdateCheck.Tests/AIUsageMonitor.UpdateCheck.Tests.csproj create mode 100644 tests/AIUsageMonitor.UpdateCheck.Tests/UpdateCheckerTests.cs diff --git a/AIUsageMonitor.slnx b/AIUsageMonitor.slnx index f4c2095..6450fd5 100644 --- a/AIUsageMonitor.slnx +++ b/AIUsageMonitor.slnx @@ -16,9 +16,11 @@ + + diff --git a/Directory.Packages.props b/Directory.Packages.props index d9b773f..26a478d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,6 +7,7 @@ + diff --git a/src/AIUsageMonitor.Cli/AIUsageMonitor.Cli.csproj b/src/AIUsageMonitor.Cli/AIUsageMonitor.Cli.csproj index 233e1a0..cac86ba 100644 --- a/src/AIUsageMonitor.Cli/AIUsageMonitor.Cli.csproj +++ b/src/AIUsageMonitor.Cli/AIUsageMonitor.Cli.csproj @@ -2,10 +2,12 @@ + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/AIUsageMonitor.Cli/Commands/WatchCommand.cs b/src/AIUsageMonitor.Cli/Commands/WatchCommand.cs index 4f2551f..f730ff6 100644 --- a/src/AIUsageMonitor.Cli/Commands/WatchCommand.cs +++ b/src/AIUsageMonitor.Cli/Commands/WatchCommand.cs @@ -1,6 +1,7 @@ using AIUsageMonitor.Cli.Rendering; using AIUsageMonitor.Core.Models; using AIUsageMonitor.Core.Services; +using AIUsageMonitor.UpdateCheck; using Spectre.Console; using Spectre.Console.Rendering; using System.CommandLine; @@ -16,8 +17,9 @@ public static class WatchCommand /// Creates the "watch" command with its options and action. /// /// The data service used to retrieve usage data for the specified view. + /// The update checker used to show a persistent notice when a newer version is available. /// A configured instance for continuously refreshing a usage view. - public static Command Create(DataService dataService) + public static Command Create(DataService dataService, IUpdateChecker updateChecker) { var command = new Command("watch", "Continuously refresh a usage view at a fixed interval"); var viewOption = new Option("--view") @@ -99,22 +101,32 @@ public static Command Create(DataService dataService) var recalibrationEnabled = view == "limits" && !Console.IsInputRedirected; - IRenderable BuildCurrent(IProgress? progress = null) => view switch + // Checked once, up front - watch is a long-running loop, so the notice needs to be + // visible while it runs rather than only after it exits. + var updateInfo = await updateChecker.CheckForUpdateAsync(ct); + var updateNotice = UpdateNotice.BuildRenderable(updateInfo); + + IRenderable BuildCurrent(IProgress? progress = null) { - "today" => new Rows( - SpectreRenderer.BuildDailySummary( - dataService.GetDailySummary(DateOnly.FromDateTime(DateTime.Today), progress) - ?? DailySummary.Empty(DateOnly.FromDateTime(DateTime.Today))), - new Rule().RuleStyle("grey"), - SpectreRenderer.BuildHourlyTokenChart(dataService.GetRecentActivity(DateTimeOffset.Now - DateTimeOffset.Now.Date, progress).HourlyTrend)), - "week" => SpectreRenderer.BuildPeriodSummary( - dataService.GetPeriodSummary(DateOnly.FromDateTime(DateTime.Today).AddDays(-6), DateOnly.FromDateTime(DateTime.Today), progress)), - "models" => SpectreRenderer.BuildModelDistribution(dataService.GetModelDistribution(progress)), - "sessions" => SpectreRenderer.BuildSessionStats(dataService.GetSessionStats(progress)), - "hours" => SpectreRenderer.BuildHourlyActivity(dataService.GetHourlyActivity(progress)), - "limits" => BuildLimits(progress), - _ => new Markup($"[red]Unknown view: {view}. Use today|week|models|sessions|hours|limits.[/]") - }; + IRenderable content = view switch + { + "today" => new Rows( + SpectreRenderer.BuildDailySummary( + dataService.GetDailySummary(DateOnly.FromDateTime(DateTime.Today), progress) + ?? DailySummary.Empty(DateOnly.FromDateTime(DateTime.Today))), + new Rule().RuleStyle("grey"), + SpectreRenderer.BuildHourlyTokenChart(dataService.GetRecentActivity(DateTimeOffset.Now - DateTimeOffset.Now.Date, progress).HourlyTrend)), + "week" => SpectreRenderer.BuildPeriodSummary( + dataService.GetPeriodSummary(DateOnly.FromDateTime(DateTime.Today).AddDays(-6), DateOnly.FromDateTime(DateTime.Today), progress)), + "models" => SpectreRenderer.BuildModelDistribution(dataService.GetModelDistribution(progress)), + "sessions" => SpectreRenderer.BuildSessionStats(dataService.GetSessionStats(progress)), + "hours" => SpectreRenderer.BuildHourlyActivity(dataService.GetHourlyActivity(progress)), + "limits" => BuildLimits(progress), + _ => new Markup($"[red]Unknown view: {view}. Use today|week|models|sessions|hours|limits.[/]") + }; + + return WithUpdateNotice(content); + } IRenderable BuildLimits(IProgress? progress) { @@ -131,8 +143,11 @@ IRenderable BuildLimits(IProgress? progress) ClearScreen(); + IRenderable WithUpdateNotice(IRenderable content) => + updateNotice is null ? content : new Rows(content, updateNotice); + var current = view == "limits" - ? SpectreRenderer.BuildUsageLimits(lastSessionWindow!, lastWeekWindow!, effectiveSessionTokenLimit, effectiveWeekTokenLimit, recalibrationEnabled, effectiveSessionResetAt is not null) + ? WithUpdateNotice(SpectreRenderer.BuildUsageLimits(lastSessionWindow!, lastWeekWindow!, effectiveSessionTokenLimit, effectiveWeekTokenLimit, recalibrationEnabled, effectiveSessionResetAt is not null)) : ProgressReporter.Run("Loading usage data...", BuildCurrent); while (!ct.IsCancellationRequested) diff --git a/src/AIUsageMonitor.Cli/Program.cs b/src/AIUsageMonitor.Cli/Program.cs index 44cb2b5..b6b77e7 100644 --- a/src/AIUsageMonitor.Cli/Program.cs +++ b/src/AIUsageMonitor.Cli/Program.cs @@ -1,6 +1,8 @@ using System.CommandLine; +using AIUsageMonitor.Cli; using AIUsageMonitor.Cli.Commands; using AIUsageMonitor.Core.Services; +using AIUsageMonitor.UpdateCheck; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -11,9 +13,11 @@ var builder = Host.CreateApplicationBuilder(args); builder.Logging.ClearProviders(); builder.Services.AddClaudeUsageCore(); + builder.Services.AddHttpClient(); var host = builder.Build(); var dataService = host.Services.GetRequiredService(); + var updateChecker = host.Services.GetRequiredService(); var rootCommand = new RootCommand("aimon - AI Usage Monitor"); @@ -24,9 +28,22 @@ rootCommand.Subcommands.Add(SessionsCommand.Create(dataService)); rootCommand.Subcommands.Add(HoursCommand.Create(dataService)); rootCommand.Subcommands.Add(ExportCommand.Create(dataService)); - rootCommand.Subcommands.Add(WatchCommand.Create(dataService)); + var watchCommand = WatchCommand.Create(dataService, updateChecker); + rootCommand.Subcommands.Add(watchCommand); - return await rootCommand.Parse(args).InvokeAsync(); + var parseResult = rootCommand.Parse(args); + var isWatch = parseResult.CommandResult.Command == watchCommand; + var exitCode = await parseResult.InvokeAsync(); + + // watch runs its own update check up front (it's a long-running loop, so the notice needs to + // show while it's running, not after it exits) - every other command checks once here, after + // its own output, so the check never delays the command's actual result. + if (!isWatch) + { + await UpdateNotice.PrintIfAvailableAsync(updateChecker); + } + + return exitCode; } catch (Exception ex) { diff --git a/src/AIUsageMonitor.Cli/UpdateNotice.cs b/src/AIUsageMonitor.Cli/UpdateNotice.cs new file mode 100644 index 0000000..4bf0b77 --- /dev/null +++ b/src/AIUsageMonitor.Cli/UpdateNotice.cs @@ -0,0 +1,40 @@ +using AIUsageMonitor.UpdateCheck; +using Spectre.Console; + +namespace AIUsageMonitor.Cli; + +/// +/// Prints a notice pointing to the latest GitHub release when a newer version than the one +/// currently running is available. +/// +public static class UpdateNotice +{ + /// + /// Checks for an update and, if one is available, prints a notice. + /// + /// The update checker to query. + public static async Task PrintIfAvailableAsync(IUpdateChecker updateChecker) + { + var result = await updateChecker.CheckForUpdateAsync(); + if (result.IsUpdateAvailable) + { + // Written to stderr, not stdout, so it never mixes into piped command output (e.g. + // `aimon export --format json` writing JSON to stdout for a script to consume). + var errorConsole = AnsiConsole.Create(new AnsiConsoleSettings { Out = new AnsiConsoleOutput(Console.Error) }); + errorConsole.MarkupLine( + $"[yellow]A new version ({result.LatestVersion}) of aimon is available. Download it at {result.ReleaseUrl}[/]"); + } + } + + /// + /// Builds the update notice as a renderable line, for embedding into a continuously refreshed view. + /// + /// The update check result to render. + /// A markup line if an update is available; otherwise . + public static Spectre.Console.Rendering.IRenderable? BuildRenderable(UpdateCheckResult? result) + { + return result is { IsUpdateAvailable: true } + ? new Markup($"[yellow]A new version ({result.LatestVersion}) of aimon is available. Download it at {result.ReleaseUrl}[/]") + : null; + } +} diff --git a/src/AIUsageMonitor.UpdateCheck/AIUsageMonitor.UpdateCheck.csproj b/src/AIUsageMonitor.UpdateCheck/AIUsageMonitor.UpdateCheck.csproj new file mode 100644 index 0000000..051ff28 --- /dev/null +++ b/src/AIUsageMonitor.UpdateCheck/AIUsageMonitor.UpdateCheck.csproj @@ -0,0 +1,12 @@ + + + + + + + + + net10.0 + + + diff --git a/src/AIUsageMonitor.UpdateCheck/AppVersion.cs b/src/AIUsageMonitor.UpdateCheck/AppVersion.cs new file mode 100644 index 0000000..c913d7b --- /dev/null +++ b/src/AIUsageMonitor.UpdateCheck/AppVersion.cs @@ -0,0 +1,23 @@ +using System.Reflection; + +namespace AIUsageMonitor.UpdateCheck; + +/// +/// Reads the running application's version, as embedded by MinVer at build time. +/// +public static class AppVersion +{ + /// + /// Gets the current application version, or if it could not be determined + /// (e.g. running from an unpublished build with no informational version attribute). + /// + /// The current semantic version, with any MinVer commit-hash suffix stripped. + public static Version? GetCurrent() + { + var informationalVersion = Assembly.GetEntryAssembly() + ?.GetCustomAttribute() + ?.InformationalVersion; + + return VersionParser.Parse(informationalVersion); + } +} \ No newline at end of file diff --git a/src/AIUsageMonitor.UpdateCheck/GitHubReleaseResponse.cs b/src/AIUsageMonitor.UpdateCheck/GitHubReleaseResponse.cs new file mode 100644 index 0000000..78d15cb --- /dev/null +++ b/src/AIUsageMonitor.UpdateCheck/GitHubReleaseResponse.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace AIUsageMonitor.UpdateCheck; + +/// +/// Represents the subset of GitHub's "get the latest release" API response used to determine +/// the latest published version. +/// +public sealed class GitHubReleaseResponse +{ + /// + /// Gets or sets the release's git tag name, e.g. v1.2.3. + /// + [JsonPropertyName("tag_name")] + public string? TagName + { + get; set; + } +} \ No newline at end of file diff --git a/src/AIUsageMonitor.UpdateCheck/IUpdateChecker.cs b/src/AIUsageMonitor.UpdateCheck/IUpdateChecker.cs new file mode 100644 index 0000000..2c472b9 --- /dev/null +++ b/src/AIUsageMonitor.UpdateCheck/IUpdateChecker.cs @@ -0,0 +1,26 @@ +namespace AIUsageMonitor.UpdateCheck; + +/// +/// Checks GitHub for a newer published release than the one currently running. +/// +public interface IUpdateChecker +{ + /// + /// Checks whether a newer release than the current one is available on GitHub. + /// + /// A token to cancel the check. + /// + /// The check result. Any network, timeout, or parsing failure is treated as "no update + /// available" rather than propagated, since this check must never break the command it runs + /// alongside. + /// + Task CheckForUpdateAsync(CancellationToken cancellationToken = default); +} + +/// +/// Represents the outcome of an check. +/// +/// Whether a newer release than the current one was found. +/// The latest published version, if it could be determined. +/// The page a user should visit to download the latest release. +public sealed record UpdateCheckResult(bool IsUpdateAvailable, string? LatestVersion, string ReleaseUrl); \ No newline at end of file diff --git a/src/AIUsageMonitor.UpdateCheck/UpdateCheckJsonContext.cs b/src/AIUsageMonitor.UpdateCheck/UpdateCheckJsonContext.cs new file mode 100644 index 0000000..99a1aa8 --- /dev/null +++ b/src/AIUsageMonitor.UpdateCheck/UpdateCheckJsonContext.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace AIUsageMonitor.UpdateCheck; + +/// +/// Represents the JSON serialization context used to deserialize GitHub release API responses +/// without reflection. +/// +[JsonSerializable(typeof(GitHubReleaseResponse))] +public sealed partial class UpdateCheckJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/src/AIUsageMonitor.UpdateCheck/UpdateChecker.cs b/src/AIUsageMonitor.UpdateCheck/UpdateChecker.cs new file mode 100644 index 0000000..c98bebc --- /dev/null +++ b/src/AIUsageMonitor.UpdateCheck/UpdateChecker.cs @@ -0,0 +1,78 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Microsoft.Extensions.Logging; + +namespace AIUsageMonitor.UpdateCheck; + +/// +/// Checks GitHub's "latest release" API for a newer published version of the application. +/// +public sealed class UpdateChecker : IUpdateChecker +{ + /// + /// The GitHub page users should visit to download the latest release. + /// + public const string ReleaseUrl = $"https://github.com/{RepositoryOwner}/{RepositoryName}/releases/latest"; + + private const string ReleasesApiUrl = $"https://api.github.com/repos/{RepositoryOwner}/{RepositoryName}/releases/latest"; + private const string RepositoryName = "AIUsageMonitor"; + private const string RepositoryOwner = "coldhighsun"; + private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(5); + + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP client used to call the GitHub API. + /// The logger used to record failed update checks. + public UpdateChecker(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + /// + public async Task CheckForUpdateAsync(CancellationToken cancellationToken = default) + { + var noUpdate = new UpdateCheckResult(false, null, ReleaseUrl); + + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(RequestTimeout); + + using var request = new HttpRequestMessage(HttpMethod.Get, ReleasesApiUrl); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue(RepositoryName, "1.0")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + + using var response = await _httpClient.SendAsync(request, cts.Token); + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Update check failed with status {StatusCode}", response.StatusCode); + return noUpdate; + } + + var release = await response.Content.ReadFromJsonAsync( + UpdateCheckJsonContext.Default.GitHubReleaseResponse, cts.Token); + + var latestVersion = VersionParser.Parse(release?.TagName); + var currentVersion = AppVersion.GetCurrent(); + + if (latestVersion is null || currentVersion is null) + { + return noUpdate; + } + + return latestVersion > currentVersion + ? new UpdateCheckResult(true, latestVersion.ToString(), ReleaseUrl) + : noUpdate; + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException) + { + _logger.LogWarning(ex, "Update check could not reach GitHub"); + return noUpdate; + } + } +} \ No newline at end of file diff --git a/src/AIUsageMonitor.UpdateCheck/VersionParser.cs b/src/AIUsageMonitor.UpdateCheck/VersionParser.cs new file mode 100644 index 0000000..2ee1f17 --- /dev/null +++ b/src/AIUsageMonitor.UpdateCheck/VersionParser.cs @@ -0,0 +1,30 @@ +using System.Text.RegularExpressions; + +namespace AIUsageMonitor.UpdateCheck; + +/// +/// Parses loose semantic-version strings (an optional leading v, an optional +/// pre-release/build-metadata suffix such as MinVer's +commitsha) into a plain +/// usable for comparison. +/// +internal static partial class VersionParser +{ + /// + /// Parses the leading major.minor.patch portion of . + /// + /// The version string to parse, e.g. v1.2.3 or 1.2.3-preview.1+abcdef. + /// The parsed version, or if is not a recognizable version. + public static Version? Parse(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var match = CoreVersionRegex().Match(value); + return match.Success && Version.TryParse(match.Value, out var version) ? version : null; + } + + [GeneratedRegex(@"\d+\.\d+\.\d+")] + private static partial Regex CoreVersionRegex(); +} \ No newline at end of file diff --git a/tests/AIUsageMonitor.UpdateCheck.Tests/AIUsageMonitor.UpdateCheck.Tests.csproj b/tests/AIUsageMonitor.UpdateCheck.Tests/AIUsageMonitor.UpdateCheck.Tests.csproj new file mode 100644 index 0000000..eb06bd3 --- /dev/null +++ b/tests/AIUsageMonitor.UpdateCheck.Tests/AIUsageMonitor.UpdateCheck.Tests.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + false + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/tests/AIUsageMonitor.UpdateCheck.Tests/UpdateCheckerTests.cs b/tests/AIUsageMonitor.UpdateCheck.Tests/UpdateCheckerTests.cs new file mode 100644 index 0000000..91f15ae --- /dev/null +++ b/tests/AIUsageMonitor.UpdateCheck.Tests/UpdateCheckerTests.cs @@ -0,0 +1,79 @@ +using System.Net; +using AIUsageMonitor.UpdateCheck; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AIUsageMonitor.UpdateCheck.Tests; + +public class UpdateCheckerTests +{ + [Fact] + public async Task CheckForUpdateAsync_NewerTagPublished_ReturnsUpdateAvailable() + { + var handler = new StubHttpMessageHandler( + HttpStatusCode.OK, """{"tag_name": "v99.0.0"}"""); + var checker = new UpdateChecker(new HttpClient(handler), NullLogger.Instance); + + var result = await checker.CheckForUpdateAsync(TestContext.Current.CancellationToken); + + Assert.True(result.IsUpdateAvailable); + Assert.Equal("99.0.0", result.LatestVersion); + Assert.Equal(AIUsageMonitor.UpdateCheck.UpdateChecker.ReleaseUrl, result.ReleaseUrl); + } + + [Fact] + public async Task CheckForUpdateAsync_NoCurrentVersionKnown_ReturnsNoUpdateAvailable() + { + // AppVersion.GetCurrent() returns null in the test host (no AssemblyInformationalVersion + // attribute set by MinVer during a test run), so a real tag always compares as "no update". + var handler = new StubHttpMessageHandler( + HttpStatusCode.OK, """{"tag_name": "v1.0.0"}"""); + var checker = new UpdateChecker(new HttpClient(handler), NullLogger.Instance); + + var result = await checker.CheckForUpdateAsync(TestContext.Current.CancellationToken); + + Assert.False(result.IsUpdateAvailable); + Assert.Null(result.LatestVersion); + } + + [Fact] + public async Task CheckForUpdateAsync_HttpRequestFails_ReturnsNoUpdateAvailable() + { + var handler = new StubHttpMessageHandler(exception: new HttpRequestException("network down")); + var checker = new UpdateChecker(new HttpClient(handler), NullLogger.Instance); + + var result = await checker.CheckForUpdateAsync(TestContext.Current.CancellationToken); + + Assert.False(result.IsUpdateAvailable); + Assert.Null(result.LatestVersion); + } + + [Fact] + public async Task CheckForUpdateAsync_NonSuccessStatusCode_ReturnsNoUpdateAvailable() + { + var handler = new StubHttpMessageHandler(HttpStatusCode.ServiceUnavailable, string.Empty); + var checker = new UpdateChecker(new HttpClient(handler), NullLogger.Instance); + + var result = await checker.CheckForUpdateAsync(TestContext.Current.CancellationToken); + + Assert.False(result.IsUpdateAvailable); + } + + private sealed class StubHttpMessageHandler( + HttpStatusCode statusCode = HttpStatusCode.OK, string content = "", Exception? exception = null) + : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (exception is not null) + { + throw exception; + } + + return Task.FromResult(new HttpResponseMessage(statusCode) + { + Content = new StringContent(content) + }); + } + } +} From bedc40b691cf90fc575508d889c5ea5af46f7835 Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Tue, 15 Sep 2026 15:13:32 +0800 Subject: [PATCH 2/3] fix: derive watch limits token limit from anchor-pinned window totals The token limit was being derived from TotalTokens fetched before the real session/week reset times were resolved, while every subsequent render used TotalTokens from the anchor-pinned window. This mismatch made the progress bar show a different percentage than the one just entered, most visibly for the week window. --- src/AIUsageMonitor.Cli/Commands/WatchCommand.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/AIUsageMonitor.Cli/Commands/WatchCommand.cs b/src/AIUsageMonitor.Cli/Commands/WatchCommand.cs index f730ff6..802a697 100644 --- a/src/AIUsageMonitor.Cli/Commands/WatchCommand.cs +++ b/src/AIUsageMonitor.Cli/Commands/WatchCommand.cs @@ -81,6 +81,14 @@ public static Command Create(DataService dataService, IUpdateChecker updateCheck // reusing the pre-prompt snapshot here would overwrite them with their stale values. saved = LimitsSettingsStore.Load(); + // Re-fetch pinned to the now-resolved reset times: the anchor-less fetch above can + // report a different TotalTokens than the pinned window used for every render below + // (most visibly for the week window, whose 7-day span makes the two diverge a lot), + // so deriving the token limit from the anchor-less totals would make the very next + // render show a different percentage than the one just entered. + lastSessionWindow = dataService.GetCurrentSessionWindow(effectiveSessionResetAt); + lastWeekWindow = dataService.GetWeekWindow(effectiveWeekResetAt); + if (!LimitsAnchors.ResolveTokenLimit( sessionTokenProgressArg, saved.SessionTokenLimit, lastSessionWindow.TotalTokens, "Session", out effectiveSessionTokenLimit, out error) From 226d78bda8a2f027a8c74ddeb5b8e341b56b72a7 Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Tue, 15 Sep 2026 15:15:09 +0800 Subject: [PATCH 3/3] docs: add update-checks section to README --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 6d0c021..974d249 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,10 @@ Press `r` while `watch` is running to re-enter any of the four (Enter keeps the ![watch --view limits](docs/images/watch-limits-screenshot.png) +### Update checks + +Every command checks GitHub for a newer release once after it finishes (`watch` checks once before entering its refresh loop and keeps the notice pinned to the bottom of the view for the whole session). If a newer version is available, a one-line notice with the new version and a link to the release is printed — this never blocks or fails the command, and no data is sent beyond the standard GitHub API request for the latest release. + ### WPF dashboard (Windows only) > **⚠️ Work in progress — not ready for use yet.** The WPF project is still under active development; expect missing features and rough edges. Use the CLI (`aimon`) for now. @@ -165,6 +169,10 @@ aimon <命令> ![watch --view limits](docs/images/watch-limits-screenshot.png) +### 更新检查 + +每个命令执行结束后都会检查一次 GitHub 上是否有新版本发布(`watch` 会在进入刷新循环前检查一次,并在整个运行期间将提示固定显示在视图底部)。如果有新版本,会打印一行提示,附带新版本号和发布页链接——这不会阻塞或影响命令本身的执行,除了标准的 GitHub 最新发布查询请求外不会发送任何其他数据。 + ### WPF 仪表盘(仅 Windows) > **⚠️ 尚在开发中,暂不建议使用。** WPF 项目目前仍在积极开发,功能不完整,可能存在明显问题。请先使用 CLI(`aimon`)。