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
2 changes: 2 additions & 0 deletions AIUsageMonitor.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
<Folder Name="/src/">
<Project Path="src/AIUsageMonitor.Cli/AIUsageMonitor.Cli.csproj" />
<Project Path="src/AIUsageMonitor.Core/AIUsageMonitor.Core.csproj" />
<Project Path="src/AIUsageMonitor.UpdateCheck/AIUsageMonitor.UpdateCheck.csproj" />
<Project Path="src/AIUsageMonitor.WPF/AIUsageMonitor.WPF.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/AIUsageMonitor.Core.Tests/AIUsageMonitor.Core.Tests.csproj" />
<Project Path="tests/AIUsageMonitor.UpdateCheck.Tests/AIUsageMonitor.UpdateCheck.Tests.csproj" />
</Folder>
</Solution>
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<PackageVersion Include="LiveChartsCore.SkiaSharpView.WPF" Version="2.0.5" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.11" />
<PackageVersion Include="MinVer" Version="8.0.0" />
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -165,6 +169,10 @@ aimon <命令>

![watch --view limits](docs/images/watch-limits-screenshot.png)

### 更新检查

每个命令执行结束后都会检查一次 GitHub 上是否有新版本发布(`watch` 会在进入刷新循环前检查一次,并在整个运行期间将提示固定显示在视图底部)。如果有新版本,会打印一行提示,附带新版本号和发布页链接——这不会阻塞或影响命令本身的执行,除了标准的 GitHub 最新发布查询请求外不会发送任何其他数据。

### WPF 仪表盘(仅 Windows)

> **⚠️ 尚在开发中,暂不建议使用。** WPF 项目目前仍在积极开发,功能不完整,可能存在明显问题。请先使用 CLI(`aimon`)。
Expand Down
2 changes: 2 additions & 0 deletions src/AIUsageMonitor.Cli/AIUsageMonitor.Cli.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

<ItemGroup>
<ProjectReference Include="..\AIUsageMonitor.Core\AIUsageMonitor.Core.csproj" />
<ProjectReference Include="..\AIUsageMonitor.UpdateCheck\AIUsageMonitor.UpdateCheck.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="MinVer">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
57 changes: 40 additions & 17 deletions src/AIUsageMonitor.Cli/Commands/WatchCommand.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,8 +17,9 @@ public static class WatchCommand
/// Creates the "watch" command with its options and action.
/// </summary>
/// <param name="dataService">The data service used to retrieve usage data for the specified view.</param>
/// <param name="updateChecker">The update checker used to show a persistent notice when a newer version is available.</param>
/// <returns>A configured <see cref="Command"/> instance for continuously refreshing a usage view.</returns>
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<string>("--view")
Expand Down Expand Up @@ -79,6 +81,14 @@ public static Command Create(DataService dataService)
// 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)
Expand All @@ -99,22 +109,32 @@ public static Command Create(DataService dataService)

var recalibrationEnabled = view == "limits" && !Console.IsInputRedirected;

IRenderable BuildCurrent(IProgress<int>? 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<int>? 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<int>? progress)
{
Expand All @@ -131,8 +151,11 @@ IRenderable BuildLimits(IProgress<int>? 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)
Expand Down
21 changes: 19 additions & 2 deletions src/AIUsageMonitor.Cli/Program.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,9 +13,11 @@
var builder = Host.CreateApplicationBuilder(args);
builder.Logging.ClearProviders();
builder.Services.AddClaudeUsageCore();
builder.Services.AddHttpClient<IUpdateChecker, UpdateChecker>();
var host = builder.Build();

var dataService = host.Services.GetRequiredService<DataService>();
var updateChecker = host.Services.GetRequiredService<IUpdateChecker>();

var rootCommand = new RootCommand("aimon - AI Usage Monitor");

Expand All @@ -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)
{
Expand Down
40 changes: 40 additions & 0 deletions src/AIUsageMonitor.Cli/UpdateNotice.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using AIUsageMonitor.UpdateCheck;
using Spectre.Console;

namespace AIUsageMonitor.Cli;

/// <summary>
/// Prints a notice pointing to the latest GitHub release when a newer version than the one
/// currently running is available.
/// </summary>
public static class UpdateNotice
{
/// <summary>
/// Checks for an update and, if one is available, prints a notice.
/// </summary>
/// <param name="updateChecker">The update checker to query.</param>
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}[/]");
}
}

/// <summary>
/// Builds the update notice as a renderable line, for embedding into a continuously refreshed view.
/// </summary>
/// <param name="result">The update check result to render.</param>
/// <returns>A markup line if an update is available; otherwise <see langword="null"/>.</returns>
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;
}
}
12 changes: 12 additions & 0 deletions src/AIUsageMonitor.UpdateCheck/AIUsageMonitor.UpdateCheck.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>

</Project>
23 changes: 23 additions & 0 deletions src/AIUsageMonitor.UpdateCheck/AppVersion.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System.Reflection;

namespace AIUsageMonitor.UpdateCheck;

/// <summary>
/// Reads the running application's version, as embedded by MinVer at build time.
/// </summary>
public static class AppVersion
{
/// <summary>
/// Gets the current application version, or <see langword="null"/> if it could not be determined
/// (e.g. running from an unpublished build with no informational version attribute).
/// </summary>
/// <returns>The current semantic version, with any MinVer commit-hash suffix stripped.</returns>
public static Version? GetCurrent()
{
var informationalVersion = Assembly.GetEntryAssembly()
?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion;

return VersionParser.Parse(informationalVersion);
}
}
19 changes: 19 additions & 0 deletions src/AIUsageMonitor.UpdateCheck/GitHubReleaseResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System.Text.Json.Serialization;

namespace AIUsageMonitor.UpdateCheck;

/// <summary>
/// Represents the subset of GitHub's "get the latest release" API response used to determine
/// the latest published version.
/// </summary>
public sealed class GitHubReleaseResponse
{
/// <summary>
/// Gets or sets the release's git tag name, e.g. <c>v1.2.3</c>.
/// </summary>
[JsonPropertyName("tag_name")]
public string? TagName
{
get; set;
}
}
26 changes: 26 additions & 0 deletions src/AIUsageMonitor.UpdateCheck/IUpdateChecker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace AIUsageMonitor.UpdateCheck;

/// <summary>
/// Checks GitHub for a newer published release than the one currently running.
/// </summary>
public interface IUpdateChecker
{
/// <summary>
/// Checks whether a newer release than the current one is available on GitHub.
/// </summary>
/// <param name="cancellationToken">A token to cancel the check.</param>
/// <returns>
/// 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.
/// </returns>
Task<UpdateCheckResult> CheckForUpdateAsync(CancellationToken cancellationToken = default);
}

/// <summary>
/// Represents the outcome of an <see cref="IUpdateChecker"/> check.
/// </summary>
/// <param name="IsUpdateAvailable">Whether a newer release than the current one was found.</param>
/// <param name="LatestVersion">The latest published version, if it could be determined.</param>
/// <param name="ReleaseUrl">The page a user should visit to download the latest release.</param>
public sealed record UpdateCheckResult(bool IsUpdateAvailable, string? LatestVersion, string ReleaseUrl);
10 changes: 10 additions & 0 deletions src/AIUsageMonitor.UpdateCheck/UpdateCheckJsonContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;

namespace AIUsageMonitor.UpdateCheck;

/// <summary>
/// Represents the JSON serialization context used to deserialize GitHub release API responses
/// without reflection.
/// </summary>
[JsonSerializable(typeof(GitHubReleaseResponse))]
public sealed partial class UpdateCheckJsonContext : JsonSerializerContext;
Loading
Loading