diff --git a/CHANGELOG.md b/CHANGELOG.md index 021a482..2e8827c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,31 +10,35 @@ Each entry links to the commit or pull request that introduced the change. ### Added -- An opt-in Claude pilot with an independent Dock band and a local statusline capture script that preserves an existing formatter or supplies a standalone quota line. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) -- Named local/Windows-accessible WSL source profiles and a text alternative for quota, reset, trend, and local token data. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) +- A text alternative for Codex quota, reset, trend, and local token data. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) - Optional account-scoped quota history with 7/30/90-day retention, explicit CSV/JSON exports, and confirmed deletion. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) -- A workday planner with per-day and per-hour quota budgets, measurement evidence, and held-out recent-pace checks. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) - Task-level server usage estimates and explicitly confirmed earned resets with account verification and persistent request IDs for safe retries. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) - Optional quiet usage alerts from fresh, identified accounts, compact Dock labels, and separate pinnable quota and credit entries with stable identifiers. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19)) - Account-wide daily token activity on compatible Codex versions, with independent refresh and account-identity verification. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19)) -- Explicit executable and Codex home settings, with invalid-source errors and protection against results from a previous profile. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19)) - Separate quota categories and arbitrary window durations from modern Codex responses, while preserving legacy five-hour and weekly limits. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) - Safe diagnostics with running-build version, source, freshness, refresh attempts, and reset-field availability. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) ### Fixed -- Skip inaccessible session subdirectories during local fallback discovery and recheck Claude capture freshness when the refresh interval changes. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) -- Keep provider updates independent and serialize source-sensitive presentation changes so delayed updates cannot restore old account or Claude values. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) -- Calculate planner workday budgets from reset and current dates in the same local time zone. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) +- Use the regional calendar consistently for the day and month in Dock reset and expiry dates. ([PR #23](https://github.com/TheBeems/CodexUsageDock/pull/23)) +- Switching Dock modes or hiding a metric no longer restores inactive saved bands. Existing band objects are retained, and usage refreshes update their items without reloading the whole provider. ([PR #22](https://github.com/TheBeems/CodexUsageDock/pull/22)) +- Skip inaccessible session subdirectories during local fallback discovery. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) +- Serialize source-sensitive presentation changes so delayed updates cannot restore old account values. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) - Keep the last confirmed live measurement during outages, without resetting its age or continuing projections and learning. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) - Apply one freshness policy across the Dock, details, and forecasts, and keep account/category history isolated. Unidentified legacy history is no longer imported into verified accounts. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) ### Changed +- Show the local reset date and next earned-reset expiry directly in fresh Dock subtitles, with short regional month names and minute-precise times. ([PR #23](https://github.com/TheBeems/CodexUsageDock/pull/23)) - Cache local quota fallback read positions with bounded content reads and memory, while reporting incomplete scans and preserving event-time selection. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) - Expanded the release skill to cover scoped commit/push, Store submission, resumable certification tracking, and verified installation, with a repository-local Codex entry point. ([commit e54709c](https://github.com/TheBeems/CodexUsageDock/commit/e54709ce6e26b9aaa072d6f88625a9a3aa067494)) - Distinguish source releases, the running extension build, and Microsoft Store rollout in installation guidance. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) +### Removed + +- Remove the workday planner and its workday-end and remaining-workdays settings. Existing planner preferences are ignored and dropped on the next settings save; usage displays, reset times, forecasts, and history remain available. ([commit 263c22a](https://github.com/TheBeems/CodexUsageDock/commit/263c22a650f2b7062515f94e983023e337dc7610)) +- Remove the experimental Claude integration and capture script, manual Codex path settings, and source profiles to keep the extension focused on automatically detected Codex usage. Older source preferences are ignored while other saved Codex choices are preserved. ([commit 39bf74c](https://github.com/TheBeems/CodexUsageDock/commit/39bf74cd476920441146f37737ad1216a9c0b8ad)) + ## [0.6.1] - 2026-09-09 ### Fixed diff --git a/CodexUsageDock.Tests/ClaudeUsageReaderTests.cs b/CodexUsageDock.Tests/ClaudeUsageReaderTests.cs deleted file mode 100644 index 32d1dbc..0000000 --- a/CodexUsageDock.Tests/ClaudeUsageReaderTests.cs +++ /dev/null @@ -1,367 +0,0 @@ -using System.Diagnostics; -using System.Globalization; -using System.Text; -using System.Text.Json; -using Xunit; - -namespace CodexUsageDock.Tests; - -public sealed class ClaudeUsageReaderTests -{ - private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); - private static readonly TimeSpan RefreshInterval = TimeSpan.FromMinutes(1); - - [Fact] - public void ReaderParsesDocumentedWindowsAndIgnoresUnrelatedFields() - { - var snapshot = ReadJson( - CaptureJson( - Now, - WindowJson(23.5, Now.AddHours(1)), - WindowJson(41.25, Now.AddDays(3)), - extra: "\"private\":{\"account\":\"secret\",\"model\":\"private-model\"}")); - - Assert.Equal(ClaudeUsageReadStatus.Available, snapshot.Status); - Assert.True(snapshot.IsAvailable); - Assert.Equal(23.5, snapshot.Primary!.UsedPercent); - Assert.Equal(300, snapshot.Primary!.WindowMinutes); - Assert.Equal(76.5, snapshot.Primary!.RemainingPercent); - Assert.Equal(Now.AddHours(1), snapshot.Primary!.ResetsAt); - Assert.Equal(41.25, snapshot.Weekly!.UsedPercent); - Assert.Equal(10080, snapshot.Weekly!.WindowMinutes); - Assert.Equal(Now, snapshot.ObservedAt); - } - - [Fact] - public void ReaderRequiresTheBridgeSchemaAndDoesNotAcceptUnspecifiedAliases() - { - var aliases = """ - { - "schemaVersion": 1, - "provider": "claude", - "observedAtUTC": "2026-09-09T12:00:00.0000000Z", - "fiveHour": { "usedPercentage": 20, "resetsAt": 1788958800 }, - "sevenDay": { "usedPercentage": 30, "resetsAt": 1789214400 } - } - """; - - var snapshot = ReadJson(aliases); - - Assert.Equal(ClaudeUsageReadStatus.Unavailable, snapshot.Status); - Assert.Null(snapshot.Primary); - Assert.Null(snapshot.Weekly); - } - - [Fact] - public void ReaderKeepsWindowsIndependentWhenOneIsMissingOrInvalid() - { - var missingWeekly = ReadJson(CaptureJson(Now, WindowJson(20, Now.AddHours(1)), null)); - var invalidPrimary = ReadJson(CaptureJson(Now, WindowJson(-1, Now.AddHours(1)), WindowJson(30, Now.AddDays(3)))); - - Assert.Equal(ClaudeUsageReadStatus.Partial, missingWeekly.Status); - Assert.NotNull(missingWeekly.Primary); - Assert.Null(missingWeekly.Weekly); - Assert.Equal(ClaudeUsageReadStatus.Partial, invalidPrimary.Status); - Assert.Null(invalidPrimary.Primary); - Assert.NotNull(invalidPrimary.Weekly); - } - - [Fact] - public void ReaderMarksFutureAndStaleObservationsWithoutCallingThemAvailable() - { - var future = ReadJson(CaptureJson(Now.AddSeconds(1), WindowJson(20, Now.AddHours(1)), WindowJson(30, Now.AddDays(3)))); - var stale = ReadJson(CaptureJson(Now.AddMinutes(-6), WindowJson(20, Now.AddHours(1)), WindowJson(30, Now.AddDays(3)))); - - Assert.Equal(ClaudeUsageReadStatus.Future, future.Status); - Assert.False(future.IsAvailable); - Assert.NotNull(future.Primary); - Assert.Equal(ClaudeUsageReadStatus.Stale, stale.Status); - Assert.False(stale.IsAvailable); - Assert.NotNull(stale.Weekly); - } - - [Fact] - public void ReaderRejectsZoneLessObservationTimes() - { - var zoneLess = """ - { - "schemaVersion": 1, - "provider": "claude", - "observedAtUTC": "2026-09-09T12:00:00", - "rate_limits": { - "five_hour": { "used_percentage": 20, "resets_at": 1788958800 }, - "seven_day": { "used_percentage": 30, "resets_at": 1789214400 } - } - } - """; - - Assert.Equal(ClaudeUsageReadStatus.Unavailable, ReadJson(zoneLess).Status); - } - - [Fact] - public void ReaderRejectsExpiredAndOutOfRangeWindowValues() - { - var expiredPrimary = ReadJson(CaptureJson(Now, WindowJson(20, Now.AddSeconds(-1)), WindowJson(30, Now.AddDays(3)))); - var expiredBoth = ReadJson(CaptureJson(Now, WindowJson(20, Now.AddSeconds(-1)), WindowJson(30, Now.AddSeconds(-1)))); - var outOfRange = ReadJson(CaptureJson(Now, WindowJson(101, Now.AddHours(1)), WindowJson(30, Now.AddDays(3)))); - - Assert.Equal(ClaudeUsageReadStatus.Partial, expiredPrimary.Status); - Assert.Null(expiredPrimary.Primary); - Assert.NotNull(expiredPrimary.Weekly); - Assert.Equal(ClaudeUsageReadStatus.Unavailable, expiredBoth.Status); - Assert.Equal(ClaudeUsageReadStatus.Partial, outOfRange.Status); - Assert.Null(outOfRange.Primary); - } - - [Fact] - public void ReaderRejectsMissingMalformedAndOversizedFilesSafely() - { - Assert.Equal( - ClaudeUsageReadStatus.Unavailable, - ClaudeUsageReader.Read( - Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "missing.json"), - Now, - RefreshInterval).Status); - Assert.Equal(ClaudeUsageReadStatus.Unavailable, ReadJson("not-json").Status); - - var path = Path.Combine(Path.GetTempPath(), $"claude-{Guid.NewGuid():N}.json"); - try - { - File.WriteAllBytes(path, new byte[ClaudeUsageReader.MaximumFileBytes + 1]); - var snapshot = ClaudeUsageReader.Read(path, Now, RefreshInterval); - Assert.Equal(ClaudeUsageReadStatus.Unavailable, snapshot.Status); - } - finally - { - File.Delete(path); - } - } - - [Fact] - public void ReaderRequiresAQualifiedCapturePath() - { - var snapshot = ClaudeUsageReader.Read("relative-claude-usage.json", Now, RefreshInterval); - - Assert.Equal(ClaudeUsageReadStatus.Unavailable, snapshot.Status); - Assert.Contains("fully qualified", snapshot.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void ScriptPassesThroughStatuslineAndWritesOnlyAggregateWindows() - { - var input = "{\"model\":\"private-model\",\"api_key\":\"do-not-copy\",\"rate_limits\":{\"five_hour\":{\"used_percentage\":23.5,\"resets_at\":4102444800},\"seven_day\":{\"used_percentage\":41,\"resets_at\":4102444800}},\"workspace\":{\"path\":\"private\"}}"; - var result = RunCaptureScript(input); - - Assert.Equal(0, result.ExitCode); - Assert.Equal(input, result.StandardOutput); - Assert.DoesNotContain("do-not-copy", result.SnapshotJson, StringComparison.Ordinal); - using var document = JsonDocument.Parse(result.SnapshotJson); - var root = document.RootElement; - Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32()); - Assert.Equal("claude", root.GetProperty("provider").GetString()); - Assert.EndsWith("+00:00", root.GetProperty("observedAtUTC").GetString()!, StringComparison.Ordinal); - var limits = root.GetProperty("rate_limits"); - Assert.Equal(23.5, limits.GetProperty("five_hour").GetProperty("used_percentage").GetDouble()); - Assert.Equal(41, limits.GetProperty("seven_day").GetProperty("used_percentage").GetDouble()); - Assert.DoesNotContain("model", result.SnapshotJson, StringComparison.Ordinal); - Assert.DoesNotContain("workspace", result.SnapshotJson, StringComparison.Ordinal); - } - - [Fact] - public void ScriptStandaloneModeSuppressesRawStatuslineMetadata() - { - const string input = "{\"model\":\"private-model\",\"api_key\":\"do-not-copy\",\"rate_limits\":{\"five_hour\":{\"used_percentage\":23.5,\"resets_at\":4102444800},\"seven_day\":{\"used_percentage\":41,\"resets_at\":4102444800}}}"; - var result = RunCaptureScript(input, standalone: true); - - Assert.Equal(0, result.ExitCode); - Assert.Contains("Claude 5h 76.5% / week 59%", result.StandardOutput, StringComparison.Ordinal); - Assert.DoesNotContain("private-model", result.StandardOutput, StringComparison.Ordinal); - Assert.DoesNotContain("do-not-copy", result.StandardOutput, StringComparison.Ordinal); - } - - [Fact] - public void ScriptDrainsAndPassesThroughInputWhenDestinationIsRelative() - { - const string input = "{\"rate_limits\":{},\"private\":\"unchanged\"}"; - var result = RunCaptureScript(input, "relative-claude-capture.json"); - - Assert.NotEqual(0, result.ExitCode); - Assert.Equal(input, result.StandardOutput); - Assert.Contains("could not write the snapshot", result.StandardError, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void ScriptWritesUnavailableSnapshotForMissingOrInvalidFields() - { - var input = "{\"rate_limits\":{\"five_hour\":{\"used_percentage\":\"75\",\"resets_at\":0},\"seven_day\":{\"used_percentage\":101,\"resets_at\":0}},\"secret\":\"keep-out\"}"; - var result = RunCaptureScript(input); - - Assert.Equal(0, result.ExitCode); - Assert.Equal(input, result.StandardOutput); - using var document = JsonDocument.Parse(result.SnapshotJson); - var root = document.RootElement; - Assert.Equal("unavailable", root.GetProperty("status").GetString()); - Assert.Equal(JsonValueKind.Null, root.GetProperty("rate_limits").GetProperty("five_hour").ValueKind); - Assert.Equal(JsonValueKind.Null, root.GetProperty("rate_limits").GetProperty("seven_day").ValueKind); - Assert.DoesNotContain("keep-out", result.SnapshotJson, StringComparison.Ordinal); - } - - [Fact] - public void ScriptBoundsCapturedInputButStillPassesThroughOversizedStatuslineData() - { - var input = new string('x', 256 * 1024 + 1); - var result = RunCaptureScript(input); - - Assert.Equal(0, result.ExitCode); - Assert.Equal(input, result.StandardOutput); - using var document = JsonDocument.Parse(result.SnapshotJson); - Assert.Equal("unavailable", document.RootElement.GetProperty("status").GetString()); - } - - [Fact] - public void ScriptRequiresAnAbsoluteDestinationAndReportsWriteFailuresGenerically() - { - var script = FindScript(); - var destinationDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(destinationDirectory); - try - { - var result = RunCaptureScript("{}", destinationDirectory); - - Assert.NotEqual(0, result.ExitCode); - Assert.Contains("could not write the snapshot", result.StandardError, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("{}", result.StandardError, StringComparison.Ordinal); - Assert.NotEmpty(script); - } - finally - { - Directory.Delete(destinationDirectory, recursive: true); - } - } - - private static ClaudeUsageSnapshot ReadJson( - string json, - DateTimeOffset? now = null, - TimeSpan? refreshInterval = null) - { - var path = Path.Combine(Path.GetTempPath(), $"claude-{Guid.NewGuid():N}.json"); - try - { - File.WriteAllText(path, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); - return ClaudeUsageReader.Read(path, now ?? Now, refreshInterval ?? RefreshInterval); - } - finally - { - File.Delete(path); - } - } - - private static string CaptureJson( - DateTimeOffset observedAt, - string? primary, - string? weekly, - string? extra = null) - { - var primaryText = primary ?? "null"; - var weeklyText = weekly ?? "null"; - var suffix = string.IsNullOrWhiteSpace(extra) ? string.Empty : $",{extra}"; - return "{\"schemaVersion\":1,\"provider\":\"claude\",\"observedAtUTC\":\"" - + observedAt.ToString("O", CultureInfo.InvariantCulture) - + "\",\"rate_limits\":{\"five_hour\":" - + primaryText - + ",\"seven_day\":" - + weeklyText - + "}" - + suffix - + "}"; - } - - private static string WindowJson(double usedPercent, DateTimeOffset resetsAt) => - "{\"used_percentage\":" - + usedPercent.ToString(CultureInfo.InvariantCulture) - + ",\"resets_at\":" - + Unix(resetsAt) - + "}"; - - private static long Unix(DateTimeOffset timestamp) => timestamp.ToUnixTimeSeconds(); - - private static ScriptResult RunCaptureScript(string input, string? outputPath = null, bool standalone = false) - { - var destination = outputPath ?? Path.Combine(Path.GetTempPath(), $"claude-capture-{Guid.NewGuid():N}.json"); - try - { - var startInfo = new ProcessStartInfo - { - FileName = FindPowerShell(), - UseShellExecute = false, - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - StandardInputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), - StandardOutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), - StandardErrorEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), - }; - startInfo.ArgumentList.Add("-NoLogo"); - startInfo.ArgumentList.Add("-NoProfile"); - startInfo.ArgumentList.Add("-NonInteractive"); - startInfo.ArgumentList.Add("-File"); - startInfo.ArgumentList.Add(FindScript()); - startInfo.ArgumentList.Add("-OutputPath"); - startInfo.ArgumentList.Add(destination); - if (standalone) - { - startInfo.ArgumentList.Add("-Standalone"); - } - - using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("PowerShell did not start."); - var stdoutTask = process.StandardOutput.ReadToEndAsync(); - var stderrTask = process.StandardError.ReadToEndAsync(); - process.StandardInput.Write(input); - process.StandardInput.Close(); - if (!process.WaitForExit(30_000)) - { - process.Kill(entireProcessTree: true); - throw new TimeoutException("The capture fixture did not finish."); - } - - var stdout = stdoutTask.GetAwaiter().GetResult(); - var stderr = stderrTask.GetAwaiter().GetResult(); - var snapshot = File.Exists(destination) ? File.ReadAllText(destination, Encoding.UTF8) : string.Empty; - return new ScriptResult(process.ExitCode, stdout, stderr, snapshot); - } - finally - { - if (File.Exists(destination)) - { - File.Delete(destination); - } - } - } - - private static string FindScript() - { - DirectoryInfo? directory = new(AppContext.BaseDirectory); - while (directory is not null) - { - var candidate = Path.Combine(directory.FullName, "scripts", "capture-claude-usage.ps1"); - if (File.Exists(candidate)) - { - return candidate; - } - - directory = directory.Parent; - } - - throw new FileNotFoundException("The Claude capture fixture was not found."); - } - - private static string FindPowerShell() - { - var windows = Environment.GetFolderPath(Environment.SpecialFolder.Windows); - var candidate = Path.Combine(windows, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); - return File.Exists(candidate) ? candidate : "pwsh"; - } - - private sealed record ScriptResult(int ExitCode, string StandardOutput, string StandardError, string SnapshotJson); -} diff --git a/CodexUsageDock.Tests/CodexOnlySettingsTests.cs b/CodexUsageDock.Tests/CodexOnlySettingsTests.cs new file mode 100644 index 0000000..5d6cdd0 --- /dev/null +++ b/CodexUsageDock.Tests/CodexOnlySettingsTests.cs @@ -0,0 +1,110 @@ +using System.Text.Json; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class CodexOnlySettingsTests : IDisposable +{ + private readonly TestEnvironment _environment = new(); + + public void Dispose() => _environment.Dispose(); + + [Theory] + [InlineData("codexExecutablePath")] + [InlineData("codexHomePath")] + [InlineData("sourceLabel")] + [InlineData("enableClaude")] + [InlineData("claudeBridgePath")] + [InlineData("workdayEnd")] + [InlineData("remainingWorkdays")] + public void SettingsDoNotOfferRemovedControls(string key) + { + var settings = _environment.CreateSettings(); + var content = string.Join("\n", settings.GetContent().OfType() + .Select(form => form.TemplateJson + form.DataJson)); + + Assert.DoesNotContain(key, content, StringComparison.Ordinal); + } + + [Fact] + public void SavingLegacySettingsKeepsCodexChoicesAndDropsRemovedPreferences() + { + var legacy = LegacySettings(); + File.WriteAllText(_environment.PathFor("settings.json"), JsonSerializer.Serialize(legacy)); + var settings = _environment.CreateSettings(); + + Assert.False(settings.ShowFiveHourLimit); + Assert.True(settings.CompactDock); + Assert.Equal(TimeSpan.FromMinutes(5), settings.RefreshInterval); + Assert.Null(settings.StatusMessage); + + legacy["refreshInterval"] = "15"; + settings.GetContent().OfType().Last().SubmitForm(JsonSerializer.Serialize(legacy), "{}"); + + using var saved = JsonDocument.Parse(File.ReadAllText(_environment.PathFor("settings.json"))); + foreach (var key in new[] { "codexExecutablePath", "codexHomePath", "sourceLabel", "enableClaude", "claudeBridgePath", "workdayEnd", "remainingWorkdays" }) + { + Assert.False(saved.RootElement.TryGetProperty(key, out _), key); + } + + var restarted = _environment.CreateSettings(); + Assert.False(restarted.ShowFiveHourLimit); + Assert.True(restarted.CompactDock); + Assert.Equal(TimeSpan.FromMinutes(15), restarted.RefreshInterval); + Assert.Null(restarted.StatusMessage); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task LegacyPreferencesDoNotBlockCodexOrRestoreRemovedCommands(bool separate) + { + var legacy = LegacySettings(); + legacy["separateDockItems"] = separate ? "true" : "false"; + File.WriteAllText(_environment.PathFor("settings.json"), JsonSerializer.Serialize(legacy)); + var now = new DateTimeOffset(2026, 9, 13, 12, 0, 0, TimeSpan.Zero); + var quota = new CodexUsageSnapshot(new(25, 300, now.AddHours(4)), new(40, 10080, now.AddDays(3)), + null, null, null, now, UsageDataSource.AppServer, null, AccountKey: "account-a"); + using var service = _environment.CreateService(_ => Task.FromResult(quota), () => CodexUsageSnapshot.Loading, + clock: () => now); + var settings = _environment.CreateSettings(); + using var provider = new CodexUsageDockCommandsProvider(service, settings, _ => { }, () => now); + + await service.RefreshAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(UsageDataSource.AppServer, service.Current.Source); + Assert.Equal(quota.AccountKey, service.Current.AccountKey); + Assert.Equal(quota.Primary, service.Current.Primary); + Assert.Equal(quota.Secondary, service.Current.Secondary); + Assert.Null(settings.StatusMessage); + var removedIds = new[] { "nl.mathijs.codexusage.dock.claude", "nl.mathijs.codexusage.claude", "nl.mathijs.codexusage.profiles", "nl.mathijs.codexusage.planner" }; + foreach (var id in removedIds) + { + Assert.Null(provider.GetCommandItem(id)); + Assert.DoesNotContain(provider.TopLevelCommands(), item => item.Command.Id == id); + } + + var expectedIds = separate + ? new[] { "nl.mathijs.codexusage.dock.weekly", "nl.mathijs.codexusage.dock.credits" } + : ["nl.mathijs.codexusage.dock"]; + Assert.Equal(expectedIds, provider.GetDockBands()!.Select(item => item.Command.Id)); + Assert.Contains(provider.TopLevelCommands(), item => item.Command.Id == "nl.mathijs.codexusage.table"); + Assert.All(provider.GetDockBands()!, item => Assert.IsAssignableFrom(item.Command)); + } + + private static Dictionary LegacySettings() => new() + { + ["showFiveHourLimit"] = "false", + ["compactDock"] = "true", + ["refreshInterval"] = "5", + ["codexExecutablePath"] = "removed-invalid-executable-path", + ["codexHomePath"] = "removed-invalid-home-path", + ["sourceLabel"] = "Old source", + ["enableClaude"] = "true", + ["claudeBridgePath"] = "removed-invalid-capture-path", + ["workdayEnd"] = "18:30", + ["remainingWorkdays"] = "5", + }; +} diff --git a/CodexUsageDock.Tests/CodexProfileStoreTests.cs b/CodexUsageDock.Tests/CodexProfileStoreTests.cs deleted file mode 100644 index ddbf137..0000000 --- a/CodexUsageDock.Tests/CodexProfileStoreTests.cs +++ /dev/null @@ -1,226 +0,0 @@ -using System.Text.Json; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; -using Microsoft.CmdPal.Common.Commands; -using Xunit; - -namespace CodexUsageDock.Tests; - -public sealed class CodexProfileStoreTests : IDisposable -{ - private readonly TestEnvironment _environment = new(); - - public void Dispose() => _environment.Dispose(); - - [Fact] - public void UpsertPersistsAndReplacesNamesCaseInsensitively() - { - var executable = _environment.PathFor("codex.exe"); - var home = _environment.PathFor("codex-home"); - File.WriteAllText(executable, string.Empty); - Directory.CreateDirectory(home); - var store = new CodexProfileStore(_environment.PathFor("profiles.json")); - - Assert.True(store.TryUpsert("Work", executable, home, out var first, out var firstError), firstError); - Assert.NotNull(first); - Assert.Equal(32, first!.Id.ToString("N").Length); - Assert.Equal(executable, first.SourceOptions.ExecutablePath); - Assert.Equal(home, first.SourceOptions.HomePath); - - Assert.True(store.TryUpsert("work", null, null, out var replacement, out var replacementError), replacementError); - Assert.NotNull(replacement); - Assert.Equal(first.Id, replacement!.Id); - Assert.Equal("work", replacement.DisplayName); - Assert.Null(replacement.SourceOptions.ExecutablePath); - Assert.Null(replacement.SourceOptions.HomePath); - - var reloaded = new CodexProfileStore(_environment.PathFor("profiles.json")); - var saved = Assert.Single(reloaded.Profiles); - Assert.Equal(replacement.Id, saved.Id); - Assert.Equal("work", saved.DisplayName); - Assert.Null(saved.SourceOptions.ExecutablePath); - Assert.Null(saved.SourceOptions.HomePath); - } - - [Fact] - public void InvalidNamesAndPathsAreRejectedAndTheProfileCountIsBounded() - { - var store = new CodexProfileStore(_environment.PathFor("profiles.json")); - - Assert.False(store.TryUpsert(string.Empty, null, null, out _, out var emptyNameError)); - Assert.Contains("1 to 40", emptyNameError, StringComparison.Ordinal); - Assert.False(store.TryUpsert(new string('x', 41), null, null, out _, out _)); - Assert.False(store.TryUpsert("bad\u0001name", null, null, out _, out _)); - - var relativeExecutable = Path.Combine("relative", "codex.exe"); - Assert.False(store.TryUpsert("Relative", relativeExecutable, null, out _, out var relativeError)); - Assert.DoesNotContain(relativeExecutable, relativeError, StringComparison.Ordinal); - - var missingHome = Path.Combine(_environment.PathFor("missing"), "home"); - Assert.False(store.TryUpsert("Missing home", null, missingHome, out _, out var missingHomeError)); - Assert.DoesNotContain(missingHome, missingHomeError, StringComparison.Ordinal); - - for (var index = 0; index < CodexProfileStore.MaximumProfiles; index++) - { - Assert.True(store.TryUpsert($"Profile {index}", null, null, out _, out var error), error); - } - - Assert.False(store.TryUpsert("Profile 8", null, null, out _, out var limitError)); - Assert.Contains("eight", limitError, StringComparison.OrdinalIgnoreCase); - Assert.Equal(CodexProfileStore.MaximumProfiles, store.Profiles.Count); - Assert.Equal(CodexProfileStore.MaximumProfiles, store.Profiles.Select(profile => profile.Id).Distinct().Count()); - } - - [Fact] - public void ReloadKeepsOfflinePathsButPersistsOnlyProfileFields() - { - var unavailableExecutable = Path.Combine(_environment.PathFor("offline"), "codex.exe"); - var unavailableHome = _environment.PathFor("offline-home"); - var path = _environment.PathFor("profiles.json"); - File.WriteAllText(path, JsonSerializer.Serialize(new - { - schemaVersion = CodexProfileStore.SchemaVersion, - profiles = new[] - { - new - { - id = Guid.NewGuid().ToString("N"), - displayName = "Offline WSL", - executablePath = unavailableExecutable, - homePath = unavailableHome, - accountEmail = "private@example.com", - }, - }, - })); - - var store = new CodexProfileStore(path); - var loaded = Assert.Single(store.Profiles); - Assert.Equal(unavailableExecutable, loaded.SourceOptions.ExecutablePath); - Assert.Equal(unavailableHome, loaded.SourceOptions.HomePath); - - Assert.True(store.TryUpsert("Offline WSL", null, null, out _, out var error), error); - var saved = File.ReadAllText(path); - Assert.DoesNotContain("private@example.com", saved, StringComparison.Ordinal); - Assert.DoesNotContain("accountEmail", saved, StringComparison.Ordinal); - } - - [Fact] - public void MalformedAndOversizedProfileDocumentsFailSafelyAndLoadAtMostEight() - { - var path = _environment.PathFor("profiles.json"); - File.WriteAllText(path, "null"); - - var malformed = new CodexProfileStore(path); - Assert.Empty(malformed.Profiles); - Assert.Contains("could not be read", malformed.StorageError, StringComparison.Ordinal); - Assert.DoesNotContain("null", malformed.StorageError, StringComparison.OrdinalIgnoreCase); - - File.WriteAllText(path, JsonSerializer.Serialize(new - { - schemaVersion = CodexProfileStore.SchemaVersion, - profiles = Enumerable.Range(0, CodexProfileStore.MaximumProfiles + 4) - .Select(index => new - { - id = Guid.NewGuid().ToString("N"), - displayName = $"Profile {index}", - executablePath = (string?)null, - homePath = (string?)null, - }) - .ToArray(), - })); - - var bounded = new CodexProfileStore(path); - Assert.Equal(CodexProfileStore.MaximumProfiles, bounded.Profiles.Count); - } - - [Fact] - public void RemoveUsesStableIdentityAndPersistsTheDeletion() - { - var path = _environment.PathFor("profiles.json"); - var store = new CodexProfileStore(path); - Assert.True(store.TryUpsert("Work", null, null, out var profile, out var error), error); - Assert.NotNull(profile); - Assert.True(store.TryGet(profile!.Id, out var found)); - Assert.Equal(profile, found); - - Assert.False(store.TryRemove(Guid.Empty, out var invalidIdError)); - Assert.Contains("identifier", invalidIdError, StringComparison.OrdinalIgnoreCase); - Assert.True(store.TryRemove(profile.Id, out var removeError), removeError); - Assert.Empty(store.Profiles); - Assert.Empty(new CodexProfileStore(path).Profiles); - } - - [Fact] - public void ProfilesPageOffersFormUseAndConfirmedRemoval() - { - var executable = _environment.PathFor("codex.exe"); - var home = _environment.PathFor("codex-home"); - File.WriteAllText(executable, string.Empty); - Directory.CreateDirectory(home); - var store = new CodexProfileStore(_environment.PathFor("profiles.json")); - Assert.True(store.TryUpsert("Work", executable, home, out _, out var error), error); - - using var page = new CodexProfilesPage(store); - var items = page.GetItems(); - Assert.Equal(2, items.Length); - var add = Assert.Single(items, item => item.Title == "Add or replace profile"); - var formPage = Assert.IsType(add.Command); - var form = Assert.IsAssignableFrom(Assert.Single(formPage.GetContent().OfType())); - Assert.Contains("\"id\":\"name\"", form.TemplateJson, StringComparison.Ordinal); - Assert.Contains("\"id\":\"executablePath\"", form.TemplateJson, StringComparison.Ordinal); - Assert.Contains("\"id\":\"homePath\"", form.TemplateJson, StringComparison.Ordinal); - - var selected = new List(); - page.ProfileSelected += (_, args) => selected.Add(args); - var profileItem = Assert.Single(items, item => item.Title == "Work"); - var use = Assert.IsAssignableFrom(profileItem.Command); - use.Invoke(page); - var selection = Assert.Single(selected); - Assert.Equal("Work", selection.Name); - Assert.Equal(executable, selection.Options.ExecutablePath); - Assert.Equal(home, selection.Options.HomePath); - - var deleteContext = Assert.IsType(Assert.Single(profileItem.MoreCommands)); - var confirmation = Assert.IsType(deleteContext.Command); - Assert.Equal("Delete profile", deleteContext.Title); - confirmation.Command.Invoke(page); - Assert.Empty(store.Profiles); - Assert.Single(page.GetItems()); - } - - [Fact] - public void NewProfileFormUpsertsAProfileWithoutApplyingIt() - { - var store = new CodexProfileStore(_environment.PathFor("profiles.json")); - using var page = new NewProfileFormPage(store); - var form = Assert.IsAssignableFrom(Assert.Single(page.GetContent().OfType())); - var result = form.SubmitForm(JsonSerializer.Serialize(new - { - name = "Work", - executablePath = string.Empty, - homePath = string.Empty, - }), "{}"); - - Assert.NotNull(result); - var profile = Assert.Single(store.Profiles); - Assert.Equal("Work", profile.DisplayName); - Assert.Null(profile.SourceOptions.ExecutablePath); - Assert.Null(profile.SourceOptions.HomePath); - } - - [Theory] - [InlineData("{\"name\":\"Work\",\"executablePath\":42}")] - [InlineData("{\"name\":\"Work\",\"homePath\":false}")] - [InlineData("{\"name\":\"Work\",\"homePath\":{}}")] - public void NewProfileFormRejectsNonStringPathValues(string payload) - { - var store = new CodexProfileStore(_environment.PathFor("profiles.json")); - using var page = new NewProfileFormPage(store); - var form = Assert.IsAssignableFrom(Assert.Single(page.GetContent().OfType())); - - var result = form.SubmitForm(payload, "{}"); - - Assert.NotNull(result); - Assert.Empty(store.Profiles); - } -} diff --git a/CodexUsageDock.Tests/CodexUsageTableTests.cs b/CodexUsageDock.Tests/CodexUsageTableTests.cs new file mode 100644 index 0000000..2ebf050 --- /dev/null +++ b/CodexUsageDock.Tests/CodexUsageTableTests.cs @@ -0,0 +1,24 @@ +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class CodexUsageTableTests +{ + [Fact] + public void TextViewKeepsUnknownZeroExpiredAndUnconfirmedStatesDistinct() + { + var now = new DateTimeOffset(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + var quota = new CodexUsageSnapshot(new(100, 300, now.AddHours(-1)), null, null, null, new(0, null), + now.AddHours(-2), UsageDataSource.LastConfirmed, null, + [new("extra", "Extra | quota", new(0, 60, now.AddHours(1)), null)], DefaultBucketId: "codex"); + var view = new UsagePresentation(quota, [], [], new([], null), LocalTokenUsageSnapshot.Unavailable, false); + var body = CodexUsageTablePage.Format(view, now, TimeSpan.FromMinutes(1)); + Assert.Contains("LastConfirmed", body, StringComparison.Ordinal); + Assert.Contains("Reset passed; refresh required", body, StringComparison.Ordinal); + Assert.Contains("Not reported", body, StringComparison.Ordinal); + Assert.Contains("0%", body, StringComparison.Ordinal); + Assert.Contains("100%", body, StringComparison.Ordinal); + Assert.Contains("Extra \\| quota", body, StringComparison.Ordinal); + Assert.DoesNotContain("![", body, StringComparison.Ordinal); + } +} diff --git a/CodexUsageDock.Tests/DockDateSubtitleTests.cs b/CodexUsageDock.Tests/DockDateSubtitleTests.cs new file mode 100644 index 0000000..7a5c880 --- /dev/null +++ b/CodexUsageDock.Tests/DockDateSubtitleTests.cs @@ -0,0 +1,62 @@ +using System.Globalization; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class DockDateSubtitleTests +{ + [Theory] + [InlineData("ar-SA")] + [InlineData("fa-IR")] + public void DatesUseTheRegionalCalendarForBothDayAndMonth(string cultureName) + { + var culture = CultureInfo.GetCultureInfo(cultureName); + var local = new DateTimeOffset(2026, 9, 15, 12, 56, 0, TimeSpan.FromHours(2)); + var calendarDay = culture.DateTimeFormat.Calendar.GetDayOfMonth(local.DateTime); + Assert.NotEqual(local.Day, calendarDay); + var expected = $"{calendarDay.ToString(culture)} {local.ToString("MMM", culture).TrimEnd('.')} 12:56"; + + Assert.Equal(expected, UsageDockItem.FormatLocalDateTime(local, culture)); + } + + [Theory] + [InlineData(9, 15, 12, 56, "15 sept 12:56")] + [InlineData(10, 4, 4, 0, "4 okt 4:00")] + [InlineData(1, 1, 0, 5, "1 jan 0:05")] + public void DutchDatesUseAbbreviatedMonthsAndUnpaddedHours(int month, int day, int hour, int minute, string expected) + { + var local = new DateTimeOffset(2026, month, day, hour, minute, 0, TimeSpan.FromHours(2)); + Assert.Equal(expected, UsageDockItem.FormatLocalDateTime(local, CultureInfo.GetCultureInfo("nl-NL"))); + } + + [Theory] + [InlineData("Reset - 15 sept 12:56")] + [InlineData("Expires - 4 okt 4:00")] + public void FreshLiveSubtitleShowsOnlyTheDateDetail(string detail) + { + var now = DateTimeOffset.UnixEpoch.AddDays(1); + var snapshot = CodexUsageSnapshot.Loading with { Source = UsageDataSource.AppServer, UpdatedAt = now.AddMinutes(-10) }; + Assert.Equal(detail, UsageDockItem.FormatLiveDetailOrStatus(snapshot, now, TimeSpan.FromMinutes(15), detail)); + } + + [Fact] + public void StaleAndFallbackSubtitlesRetainTheirWarning() + { + var now = DateTimeOffset.UnixEpoch.AddDays(1); + var stale = CodexUsageSnapshot.Loading with { Source = UsageDataSource.AppServer, UpdatedAt = now.AddHours(-1) }; + Assert.StartsWith("Stale", UsageDockItem.FormatLiveDetailOrStatus(stale, now, TimeSpan.FromMinutes(1), "Reset - 2 jan 4:00")); + var fallback = stale with { Source = UsageDataSource.LastConfirmed }; + Assert.StartsWith("Last confirmed", UsageDockItem.FormatLiveDetailOrStatus(fallback, now, TimeSpan.FromMinutes(1), "Expires - 2 jan 4:00")); + var local = stale with { Source = UsageDataSource.LocalSession, UpdatedAt = now }; + Assert.StartsWith("Fallback", UsageDockItem.FormatLiveDetailOrStatus(local, now, TimeSpan.FromMinutes(1), "Reset - 2 jan 4:00")); + } + + [Fact] + public void HiddenDateRetainsExistingStatusText() + { + var now = DateTimeOffset.UnixEpoch; + var snapshot = CodexUsageSnapshot.Loading with { Source = UsageDataSource.AppServer, UpdatedAt = now }; + Assert.Equal(UsageDockItem.FormatSourceFreshness(snapshot, now), + UsageDockItem.FormatLiveDetailOrStatus(snapshot, now, TimeSpan.FromMinutes(1), string.Empty)); + } +} diff --git a/CodexUsageDock.Tests/PlanningHistoryIntegrationTests.cs b/CodexUsageDock.Tests/HistoryIntegrationTests.cs similarity index 89% rename from CodexUsageDock.Tests/PlanningHistoryIntegrationTests.cs rename to CodexUsageDock.Tests/HistoryIntegrationTests.cs index b560819..4cd6965 100644 --- a/CodexUsageDock.Tests/PlanningHistoryIntegrationTests.cs +++ b/CodexUsageDock.Tests/HistoryIntegrationTests.cs @@ -4,7 +4,7 @@ namespace CodexUsageDock.Tests; -public sealed class PlanningHistoryIntegrationTests : IDisposable +public sealed class HistoryIntegrationTests : IDisposable { private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); private readonly TestEnvironment _environment = new(); @@ -12,49 +12,37 @@ public sealed class PlanningHistoryIntegrationTests : IDisposable public void Dispose() => _environment.Dispose(); [Fact] - public void PlanningPreferencesRoundTripAndExposeSafeDefaults() + public void RetentionPreferencesRoundTripAndExposeSafeDefaults() { var first = _environment.CreateSettings(); SubmitSettings(first, new Dictionary { ["historyRetentionDays"] = "90", - ["workdayEnd"] = "18:30", - ["remainingWorkdays"] = "5", }); Assert.Equal(90, first.HistoryRetentionDays); - Assert.Equal(new TimeOnly(18, 30), first.WorkdayEnd); - Assert.Equal(5, first.RemainingWorkdays); var restarted = _environment.CreateSettings(); Assert.Equal(90, restarted.HistoryRetentionDays); - Assert.Equal(new TimeOnly(18, 30), restarted.WorkdayEnd); - Assert.Equal(5, restarted.RemainingWorkdays); File.Delete(_environment.PathFor("settings.json")); var defaults = _environment.CreateSettings(); Assert.Equal(0, defaults.HistoryRetentionDays); - Assert.Equal(new TimeOnly(17, 0), defaults.WorkdayEnd); - Assert.Equal(1, defaults.RemainingWorkdays); } [Fact] - public void InvalidPlanningPreferencesUseSafeDefaults() + public void InvalidRetentionPreferencesUseSafeDefaults() { File.WriteAllText(_environment.PathFor("settings.json"), JsonSerializer.Serialize( new Dictionary { ["historyRetentionDays"] = "365", - ["workdayEnd"] = "25:61", - ["remainingWorkdays"] = "0", })); var settings = _environment.CreateSettings(); Assert.Equal(0, settings.HistoryRetentionDays); - Assert.Equal(new TimeOnly(17, 0), settings.WorkdayEnd); - Assert.Equal(1, settings.RemainingWorkdays); } [Fact] @@ -200,8 +188,6 @@ private static void SubmitSettings( var payload = new Dictionary { ["historyRetentionDays"] = "0", - ["workdayEnd"] = "17:00", - ["remainingWorkdays"] = "1", }; foreach (var pair in values) diff --git a/CodexUsageDock.Tests/ProviderDockTests.cs b/CodexUsageDock.Tests/ProviderDockTests.cs index 6fdea69..de41742 100644 --- a/CodexUsageDock.Tests/ProviderDockTests.cs +++ b/CodexUsageDock.Tests/ProviderDockTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using Xunit; @@ -50,11 +51,332 @@ public void SeparateDockBandsHaveStableRestorableIdentities() foreach (var band in bands) { Assert.Single(Assert.IsAssignableFrom(band.Command).GetItems()); - Assert.Equal(band.Command.Id, provider.GetCommandItem(band.Command.Id)!.Command.Id); + Assert.Same(band, provider.GetCommandItem(band.Command.Id)); } Assert.Null(provider.GetCommandItem("unknown")); Assert.Null(provider.GetCommandItem(string.Empty)); - var combined = provider.GetCommandItem("nl.mathijs.codexusage.dock"); - Assert.Equal(3, Assert.IsAssignableFrom(combined!.Command).GetItems().Length); + Assert.Null(provider.GetCommandItem(CombinedDockId)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DockModeTransitionsKeepStableBandObjectsAndPersist(bool initiallySeparate) + { + File.WriteAllText( + _environment.PathFor("settings.json"), + JsonSerializer.Serialize(new Dictionary + { + [SeparateDockItemsKey] = initiallySeparate.ToString().ToLowerInvariant(), + })); + + var initialId = initiallySeparate ? FiveHourDockId : CombinedDockId; + var settings = _environment.CreateSettings(); + using (var service = _environment.CreateService()) + using (var provider = new CodexUsageDockCommandsProvider(service, settings, _ => { })) + { + var initialBand = FindBand(provider, initialId); + Assert.Same(initialBand, provider.GetCommandItem(initialId)); + + SubmitSettings(settings, + (SeparateDockItemsKey, (!initiallySeparate).ToString().ToLowerInvariant())); + Assert.Equal(initiallySeparate ? 1 : 3, provider.GetDockBands()!.Length); + + SubmitSettings(settings, + (SeparateDockItemsKey, initiallySeparate.ToString().ToLowerInvariant())); + Assert.Same(initialBand, FindBand(provider, initialId)); + Assert.Same(initialBand, provider.GetCommandItem(initialId)); + } + + using var restartedService = _environment.CreateService(); + using var restartedProvider = new CodexUsageDockCommandsProvider( + restartedService, + _environment.CreateSettings(), + _ => { }); + Assert.Equal(initiallySeparate ? 3 : 1, restartedProvider.GetDockBands()!.Length); + Assert.Same( + FindBand(restartedProvider, initialId), + restartedProvider.GetCommandItem(initialId)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DockModeSwitchPersistsTheOppositeModeAcrossRestart(bool initiallySeparate) + { + File.WriteAllText( + _environment.PathFor("settings.json"), + JsonSerializer.Serialize(new Dictionary + { + [SeparateDockItemsKey] = initiallySeparate.ToString().ToLowerInvariant(), + })); + + var settings = _environment.CreateSettings(); + var oldIds = initiallySeparate + ? new[] { FiveHourDockId, WeeklyDockId, CreditsDockId } + : new[] { CombinedDockId }; + var newIds = initiallySeparate + ? new[] { CombinedDockId } + : new[] { FiveHourDockId, WeeklyDockId, CreditsDockId }; + using (var service = _environment.CreateService()) + using (var provider = new CodexUsageDockCommandsProvider(service, settings, _ => { })) + { + SubmitSettings(settings, (SeparateDockItemsKey, (!initiallySeparate).ToString().ToLowerInvariant())); + foreach (var id in oldIds) + { + Assert.Null(provider.GetCommandItem(id)); + } + } + + using var restartedService = _environment.CreateService(); + using var restartedProvider = new CodexUsageDockCommandsProvider( + restartedService, + _environment.CreateSettings(), + _ => { }); + Assert.Equal(newIds, restartedProvider.GetDockBands()!.Select(band => band.Command.Id)); + foreach (var id in oldIds) + { + Assert.Null(restartedProvider.GetCommandItem(id)); + } + foreach (var id in newIds) + { + Assert.Same(FindBand(restartedProvider, id), restartedProvider.GetCommandItem(id)); + } + } + + [Fact] + public void RestorableLookupUsesOnlyTheActiveDockSelection() + { + File.WriteAllText(_environment.PathFor("settings.json"), $"{{\"{SeparateDockItemsKey}\":\"true\"}}"); + using var service = _environment.CreateService(); + var settings = _environment.CreateSettings(); + using var provider = new CodexUsageDockCommandsProvider(service, settings, _ => { }); + + foreach (var band in provider.GetDockBands()!) + { + Assert.Same(band, provider.GetCommandItem(band.Command.Id)); + } + + Assert.Null(provider.GetCommandItem(CombinedDockId)); + SubmitSettings(settings, (SeparateDockItemsKey, "false")); + + var combined = FindBand(provider, CombinedDockId); + Assert.Same(combined, provider.GetCommandItem(CombinedDockId)); + Assert.Null(provider.GetCommandItem(FiveHourDockId)); + Assert.Null(provider.GetCommandItem(WeeklyDockId)); + Assert.Null(provider.GetCommandItem(CreditsDockId)); + } + + [Fact] + public void HiddenAndDisabledDockIdsAreNotRestorableInBothModes() + { + using var service = _environment.CreateService(); + var settings = _environment.CreateSettings(); + using var provider = new CodexUsageDockCommandsProvider(service, settings, _ => { }); + + Assert.NotNull(provider.GetCommandItem(CombinedDockId)); + Assert.Null(provider.GetCommandItem(FiveHourDockId)); + Assert.Null(provider.GetCommandItem(ClaudeDockId)); + + SubmitSettings(settings, (ShowFiveHourLimitKey, "false")); + Assert.Null(provider.GetCommandItem(FiveHourDockId)); + Assert.Equal(2, Assert.IsAssignableFrom(FindBand(provider, CombinedDockId).Command).GetItems().Length); + + SubmitSettings(settings, + (ShowWeeklyLimitKey, "false"), + (ShowResetsAndCreditsKey, "false")); + Assert.Empty(provider.GetDockBands()!); + foreach (var id in AllDockIds) + { + Assert.Null(provider.GetCommandItem(id)); + } + + SubmitSettings(settings, (SeparateDockItemsKey, "true"), (ShowWeeklyLimitKey, "true")); + Assert.Single(provider.GetDockBands()!); + Assert.Null(provider.GetCommandItem(CombinedDockId)); + Assert.Null(provider.GetCommandItem(FiveHourDockId)); + Assert.Same( + FindBand(provider, WeeklyDockId), + provider.GetCommandItem(WeeklyDockId)); + Assert.Null(provider.GetCommandItem(CreditsDockId)); + Assert.Null(provider.GetCommandItem(ClaudeDockId)); + Assert.Null(provider.GetCommandItem("testhost-owned-pin")); + + SubmitSettings(settings, (ShowWeeklyLimitKey, "false")); + Assert.Empty(provider.GetDockBands()!); + Assert.Null(provider.GetCommandItem(WeeklyDockId)); + } + + [Fact] + public void RetainedBandPagesAreClearedWhenTheirBandBecomesInactive() + { + using var service = _environment.CreateService(); + var settings = _environment.CreateSettings(); + using var provider = new CodexUsageDockCommandsProvider(service, settings, _ => { }); + + var combinedBand = FindBand(provider, CombinedDockId); + var combinedPage = Assert.IsAssignableFrom(combinedBand.Command); + Assert.Equal(3, combinedPage.GetItems().Length); + var combinedEmptyNotifications = 0; + combinedPage.ItemsChanged += (_, _) => + { + if (combinedPage.GetItems().Length == 0) + { + combinedEmptyNotifications++; + } + }; + + SubmitSettings(settings, + (ShowFiveHourLimitKey, "false"), + (ShowWeeklyLimitKey, "false"), + (ShowResetsAndCreditsKey, "false")); + Assert.Empty(combinedPage.GetItems()); + Assert.True(combinedEmptyNotifications > 0); + Assert.Empty(provider.GetDockBands()!); + + SubmitSettings(settings, (ShowWeeklyLimitKey, "true")); + Assert.Same(combinedBand, FindBand(provider, CombinedDockId)); + Assert.Single(combinedPage.GetItems()); + + var emptyNotificationsBeforeSeparate = combinedEmptyNotifications; + SubmitSettings(settings, (SeparateDockItemsKey, "true")); + Assert.Empty(combinedPage.GetItems()); + Assert.True(combinedEmptyNotifications > emptyNotificationsBeforeSeparate); + var weeklyBand = FindBand(provider, WeeklyDockId); + var weeklyPage = Assert.IsAssignableFrom(weeklyBand.Command); + Assert.Single(weeklyPage.GetItems()); + var weeklyEmptyNotifications = 0; + weeklyPage.ItemsChanged += (_, _) => + { + if (weeklyPage.GetItems().Length == 0) + { + weeklyEmptyNotifications++; + } + }; + + SubmitSettings(settings, (ShowWeeklyLimitKey, "false")); + Assert.Empty(weeklyPage.GetItems()); + Assert.True(weeklyEmptyNotifications > 0); + Assert.Empty(provider.GetDockBands()!); + } + + [Fact] + public async Task QuotaRefreshKeepsBandIdentityAndNotifiesOnlyItsBandList() + { + var now = new DateTimeOffset(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var snapshot = CodexUsageSnapshot.Loading with + { + Primary = new RateLimitWindow(25, 300, now.AddHours(4)), + Secondary = new RateLimitWindow(40, 10080, now.AddDays(5)), + UpdatedAt = now, + Source = UsageDataSource.AppServer, + Error = null, + AccountKey = "test-account", + DefaultBucketId = "codex", + }; + using var service = _environment.CreateService( + _ => pending.Task, + () => CodexUsageSnapshot.Loading, + clock: () => now); + using var provider = new CodexUsageDockCommandsProvider( + service, + _environment.CreateSettings(), + _ => { }, + () => now); + var band = FindBand(provider, CombinedDockId); + var list = Assert.IsAssignableFrom(band.Command); + var providerInvalidations = 0; + var bandInvalidations = 0; + provider.ItemsChanged += (_, _) => providerInvalidations++; + list.ItemsChanged += (_, _) => bandInvalidations++; + + var refresh = service.RefreshAsync(); + pending.SetResult(snapshot); + await refresh.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Same(band, FindBand(provider, CombinedDockId)); + Assert.Same(band, provider.GetCommandItem(CombinedDockId)); + Assert.Equal(0, providerInvalidations); + Assert.True(bandInvalidations > 0); + } + + [Fact] + public async Task RepeatedCompletedQuotaRefreshNotifiesAPreviouslyReadItem() + { + var now = new DateTimeOffset(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + var firstRead = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondRead = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var readNumber = 0; + var snapshot = CodexUsageSnapshot.Loading with + { + Primary = new RateLimitWindow(25, 300, now.AddHours(4)), + Secondary = new RateLimitWindow(40, 10080, now.AddDays(5)), + UpdatedAt = now, + Source = UsageDataSource.AppServer, + Error = null, + AccountKey = "test-account", + DefaultBucketId = "codex", + ResetCredits = new RateLimitResetCredits(2, null), + }; + Task Read(CancellationToken _) => + Interlocked.Increment(ref readNumber) == 1 ? firstRead.Task : secondRead.Task; + using var service = _environment.CreateService( + Read, + () => CodexUsageSnapshot.Loading, + clock: () => now); + using var provider = new CodexUsageDockCommandsProvider( + service, + _environment.CreateSettings(), + _ => { }, + () => now); + + var band = FindBand(provider, CombinedDockId); + // Reset-credit text is independent of the real-time window validity clock. + var item = Assert.IsAssignableFrom(band.Command).GetItems()[2]; + var cachedTitle = item.Title; + var firstRefresh = service.RefreshAsync(); + firstRead.SetResult(snapshot); + await firstRefresh.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.NotEqual(item.Title, cachedTitle); + item.PropChanged += (_, args) => + { + if (args.PropertyName == nameof(ICommandItem.Title)) cachedTitle = item.Title; + }; + + var secondRefresh = service.RefreshAsync(); + secondRead.SetResult(snapshot); + await secondRefresh.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(item.Title, cachedTitle); + } + + private const string CombinedDockId = "nl.mathijs.codexusage.dock"; + private const string FiveHourDockId = "nl.mathijs.codexusage.dock.five-hour"; + private const string WeeklyDockId = "nl.mathijs.codexusage.dock.weekly"; + private const string CreditsDockId = "nl.mathijs.codexusage.dock.credits"; + private const string ClaudeDockId = "nl.mathijs.codexusage.dock.claude"; + private const string SeparateDockItemsKey = "separateDockItems"; + private const string ShowFiveHourLimitKey = "showFiveHourLimit"; + private const string ShowWeeklyLimitKey = "showWeeklyLimit"; + private const string ShowResetsAndCreditsKey = "showResetsAndCredits"; + private static readonly string[] AllDockIds = [ + CombinedDockId, + FiveHourDockId, + WeeklyDockId, + CreditsDockId, + ClaudeDockId, + ]; + + private static ICommandItem FindBand(CodexUsageDockCommandsProvider provider, string id) => + Assert.Single(provider.GetDockBands() ?? Array.Empty(), item => item.Command.Id == id); + + private static void SubmitSettings( + CodexUsageDockSettingsPage page, + params (string Key, string Value)[] values) + { + var payload = values.ToDictionary(pair => pair.Key, pair => pair.Value); + page.GetContent().OfType().Last().SubmitForm(JsonSerializer.Serialize(payload), "{}"); } } diff --git a/CodexUsageDock.Tests/ProviderPilotIntegrationTests.cs b/CodexUsageDock.Tests/ProviderPilotIntegrationTests.cs deleted file mode 100644 index 9af260a..0000000 --- a/CodexUsageDock.Tests/ProviderPilotIntegrationTests.cs +++ /dev/null @@ -1,140 +0,0 @@ -using System.Globalization; -using System.Text.Json.Nodes; -using Microsoft.CommandPalette.Extensions; -using Xunit; - -namespace CodexUsageDock.Tests; - -public sealed class ProviderPilotIntegrationTests : IDisposable -{ - private readonly TestEnvironment _environment = new(); - private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); - public void Dispose() => _environment.Dispose(); - - [Theory] - [InlineData(1, 15, "Stale", "Available")] - [InlineData(15, 1, "Available", "Stale")] - public async Task ChangingOnlyTheRefreshIntervalReclassifiesClaudeAndNotifiesPresentation( - int previousMinutes, int nextMinutes, string before, string after) - { - var capture = WriteCapture(); - using var service = _environment.CreateService( - _ => Task.FromResult(CodexUsageSnapshot.Loading), () => CodexUsageSnapshot.Loading, - clock: () => Now.AddMinutes(6)); - service.SetRefreshInterval(TimeSpan.FromMinutes(previousMinutes)); - service.ConfigureClaude(true, capture); - await service.ClaudeRefreshTask.WaitAsync(TimeSpan.FromSeconds(5)); - Assert.Equal(before, service.GetClaudeUsage().Status.ToString()); - var updated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - service.ClaudeUpdated += (_, _) => - { - if (service.GetClaudeUsage().Status.ToString() == after) updated.TrySetResult(); - }; - - service.SetRefreshInterval(TimeSpan.FromMinutes(nextMinutes)); - service.ConfigureClaude(true, capture); - await service.ClaudeRefreshTask.WaitAsync(TimeSpan.FromSeconds(5)); - - Assert.Equal(after, service.GetClaudeUsage().Status.ToString()); - await updated.Task.WaitAsync(TimeSpan.FromSeconds(5)); - } - - [Fact] - public void ApplyingAProfilePersistsPathsAndLabelBeforeTheNextStart() - { - var executable = _environment.PathFor("codex.exe"); - File.WriteAllText(executable, string.Empty); - var source = new CodexSourceOptions(executable, Path.GetDirectoryName(executable)); - var settings = _environment.CreateSettings(); - var changes = 0; - settings.Changed += (_, _) => changes++; - settings.ApplySourceProfile("Local work", source); - var restored = _environment.CreateSettings(); - Assert.Equal(source.ExecutablePath, restored.CodexExecutablePath); - Assert.Equal(source.HomePath, restored.CodexHomePath); - Assert.Equal("Local work", restored.SourceLabel); - Assert.Equal(1, changes); - } - - [Fact] - public async Task ClaudeCaptureCompletesIndependentlyOfABlockedCodexReadAndDisablingClearsIt() - { - var capture = WriteCapture(); - var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - using var service = _environment.CreateService(_ => pending.Task, () => CodexUsageSnapshot.Loading, clock: () => Now); - var codexRead = service.RefreshAsync(); - service.ConfigureClaude(true, capture); - await service.ClaudeRefreshTask.WaitAsync(TimeSpan.FromSeconds(5)); - Assert.False(codexRead.IsCompleted); - Assert.Equal(ClaudeUsageReadStatus.Available, service.GetClaudeUsage().Status); - Assert.Equal(75, service.GetClaudeUsage().Primary!.RemainingPercent); - var changed = JsonNode.Parse(File.ReadAllText(capture))!.AsObject(); - changed["rate_limits"]!["five_hour"]!["used_percentage"] = 50; - File.WriteAllText(capture, changed.ToJsonString()); - Assert.Same(codexRead, service.RefreshAsync()); - await service.ClaudeRefreshTask.WaitAsync(TimeSpan.FromSeconds(5)); - Assert.Equal(50, service.GetClaudeUsage().Primary!.RemainingPercent); - Assert.False(codexRead.IsCompleted); - service.ConfigureClaude(false, capture); - Assert.Null(service.GetClaudeUsage().Primary); - Assert.Contains("disabled", service.GetClaudeUsage().Message, StringComparison.Ordinal); - pending.SetResult(CodexUsageSnapshot.Loading); - await codexRead; - } - - [Fact] - public async Task ClaudeDockHasItsOwnRestorableBandAndDoesNotAlterCodexQuotas() - { - var capture = WriteCapture(); - File.WriteAllText(_environment.PathFor("settings.json"), new JsonObject - { ["enableClaude"] = "true", ["claudeBridgePath"] = capture }.ToJsonString()); - var quota = new CodexUsageSnapshot(new(10, 300, Now.AddHours(4)), null, null, null, null, - Now, UsageDataSource.AppServer, null, AccountKey: "a"); - using var service = _environment.CreateService(_ => Task.FromResult(quota), () => quota, clock: () => Now); - using var provider = new CodexUsageDockCommandsProvider(service, _environment.CreateSettings(), _ => { }, () => Now); - await service.RefreshAsync(); - await service.ClaudeRefreshTask.WaitAsync(TimeSpan.FromSeconds(5)); - Assert.Equal(90, service.Current.Primary!.RemainingPercent); - Assert.Equal(2, provider.GetDockBands()!.Length); - var band = provider.GetCommandItem("nl.mathijs.codexusage.dock.claude")!; - var items = Assert.IsAssignableFrom(band.Command).GetItems(); - Assert.Equal(2, items.Length); - Assert.Equal("Claude 5h 75%", items[0].Title); - Assert.Equal("Claude week 60%", items[1].Title); - Assert.Contains(provider.TopLevelCommands(), item => item.Command.Id == "nl.mathijs.codexusage.table"); - } - - [Fact] - public void TextViewKeepsUnknownZeroExpiredAndUnconfirmedStatesDistinct() - { - var quota = new CodexUsageSnapshot(new(100, 300, Now.AddHours(-1)), null, null, null, new(0, null), - Now.AddHours(-2), UsageDataSource.LastConfirmed, null, - [new("extra", "Extra | quota", new(0, 60, Now.AddHours(1)), null)], DefaultBucketId: "codex"); - var view = new UsagePresentation(quota, [], [], new([], null), LocalTokenUsageSnapshot.Unavailable, false); - var body = CodexUsageTablePage.Format(view, Now, TimeSpan.FromMinutes(1)); - Assert.Contains("LastConfirmed", body, StringComparison.Ordinal); - Assert.Contains("Reset passed; refresh required", body, StringComparison.Ordinal); - Assert.Contains("Not reported", body, StringComparison.Ordinal); - Assert.Contains("0%", body, StringComparison.Ordinal); - Assert.Contains("100%", body, StringComparison.Ordinal); - Assert.Contains("Extra \\| quota", body, StringComparison.Ordinal); - Assert.DoesNotContain("![", body, StringComparison.Ordinal); - } - - private string WriteCapture() - { - var path = _environment.PathFor("claude.json"); - File.WriteAllText(path, new JsonObject - { - ["schemaVersion"] = 1, - ["provider"] = "claude", - ["observedAtUTC"] = Now.ToString("O", CultureInfo.InvariantCulture), - ["rate_limits"] = new JsonObject - { - ["five_hour"] = new JsonObject { ["used_percentage"] = 25, ["resets_at"] = Now.AddHours(4).ToUnixTimeSeconds() }, - ["seven_day"] = new JsonObject { ["used_percentage"] = 40, ["resets_at"] = Now.AddDays(4).ToUnixTimeSeconds() }, - }, - }.ToJsonString()); - return path; - } -} diff --git a/CodexUsageDock.Tests/UsageDataTests.cs b/CodexUsageDock.Tests/UsageDataTests.cs index 66598a2..26bef81 100644 --- a/CodexUsageDock.Tests/UsageDataTests.cs +++ b/CodexUsageDock.Tests/UsageDataTests.cs @@ -445,7 +445,7 @@ public async Task DetailsPageRefreshUpdatesMainContentAndDetailsPane() } [Fact] - public async Task CompletedRefreshRebuildsAndInvalidatesDockBands() + public async Task CompletedRefreshUpdatesExistingDockBandWithoutReloadingProvider() { var now = DateTimeOffset.Now; var result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -457,6 +457,10 @@ public async Task CompletedRefreshRebuildsAndInvalidatesDockBands() { var invalidationCount = 0; provider.ItemsChanged += (_, _) => invalidationCount++; + var band = Assert.Single(provider.GetDockBands()!); + var list = Assert.IsAssignableFrom(band.Command); + var bandInvalidations = 0; + list.ItemsChanged += (_, _) => bandInvalidations++; var refresh = service.RefreshAsync(); result.SetResult(CodexUsageSnapshot.Loading with @@ -468,9 +472,9 @@ public async Task CompletedRefreshRebuildsAndInvalidatesDockBands() }); await refresh.WaitAsync(AsyncTestTimeout); - Assert.Equal(1, invalidationCount); - var band = Assert.Single(provider.GetDockBands()!); - var list = Assert.IsAssignableFrom(band.Command); + Assert.Equal(0, invalidationCount); + Assert.True(bandInvalidations > 0); + Assert.Same(band, Assert.Single(provider.GetDockBands()!)); Assert.Contains(list.GetItems(), item => item.Title == "5h 75%"); } finally @@ -1366,7 +1370,7 @@ public void UnavailableDockItemsUseConsistentStatus(string kindName, string expe } [Fact] - public void ResetExpiryUsesTheNextFutureExpiryRoundedUpToWholeDays() + public void ResetExpiryUsesTheNextFutureExpiryDate() { var now = new DateTimeOffset(2026, 7, 16, 12, 0, 0, TimeSpan.Zero); var resets = new RateLimitResetCredits( @@ -1377,24 +1381,24 @@ public void ResetExpiryUsesTheNextFutureExpiryRoundedUpToWholeDays() new RateLimitResetCredit("Expired reset", "available", now.AddDays(-1)), ]); - Assert.Equal("expires in 13 days", UsageDockItem.FormatResetExpiry(resets, now)); + Assert.Equal($"Expires - {UsageDockItem.FormatLocalDateTime(now.AddDays(12).AddHours(1).ToLocalTime(), System.Globalization.CultureInfo.CurrentCulture)}", UsageDockItem.FormatResetExpiry(resets, now)); } [Fact] public void ResetExpiryReportsUnavailableWhenNoFutureExpiryIsKnown() { - Assert.Equal("expiration unavailable", UsageDockItem.FormatResetExpiry(null, DateTimeOffset.Now)); + Assert.Equal("Expires - unavailable", UsageDockItem.FormatResetExpiry(null, DateTimeOffset.UnixEpoch)); } [Fact] - public void ResetExpiryUsesWholeHoursWhenLessThanOneDayRemains() + public void ResetExpiryIncludesDateAndMinutesForSameDayExpiry() { var now = new DateTimeOffset(2026, 7, 16, 12, 0, 0, TimeSpan.Zero); var resets = new RateLimitResetCredits( 1, [new RateLimitResetCredit("Next reset", "available", now.AddHours(12).AddMinutes(1))]); - Assert.Equal("expires in 13 hours", UsageDockItem.FormatResetExpiry(resets, now)); + Assert.Equal($"Expires - {UsageDockItem.FormatLocalDateTime(now.AddHours(12).AddMinutes(1).ToLocalTime(), System.Globalization.CultureInfo.CurrentCulture)}", UsageDockItem.FormatResetExpiry(resets, now)); } [Fact] diff --git a/CodexUsageDock.Tests/UsagePlanningTests.cs b/CodexUsageDock.Tests/UsagePlanningTests.cs deleted file mode 100644 index 10fdcde..0000000 --- a/CodexUsageDock.Tests/UsagePlanningTests.cs +++ /dev/null @@ -1,345 +0,0 @@ -using Xunit; - -namespace CodexUsageDock.Tests; - -public sealed class UsagePlanningTests -{ - private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); - private static readonly TimeSpan RefreshInterval = TimeSpan.FromMinutes(1); - - [Fact] - public void FreshPlanReportsRemainingQuotaAndRatesUntilTheEarlierBoundary() - { - var desiredEnd = Now.AddHours(5); - var plan = UsagePlanner.Plan( - Presentation( - primaryRemaining: 50, - secondaryRemaining: 80, - primaryReset: Now.AddHours(2), - secondaryReset: Now.AddDays(4)), - Now, - RefreshInterval, - desiredEnd, - remainingWorkdays: 3); - - Assert.True(plan.IsAvailable); - Assert.Equal(50, plan.Primary!.RemainingPercent); - Assert.Equal(TimeSpan.FromHours(2), plan.Primary.Horizon); - Assert.Equal(Now.AddHours(2), plan.Primary.HorizonEnd); - Assert.True(plan.Primary.ResetsBeforeDesiredEnd); - Assert.Equal(1, plan.Primary.Workdays); - Assert.Equal(25, plan.Primary.AvailablePointsPerHour, precision: 8); - Assert.Equal(50, plan.Primary.AvailablePointsPerWorkday, precision: 8); - Assert.Contains("resets before the desired end", plan.Primary.HorizonMessage, StringComparison.Ordinal); - - Assert.Equal(80, plan.Weekly!.RemainingPercent); - Assert.Equal(TimeSpan.FromHours(5), plan.Weekly.Horizon); - Assert.False(plan.Weekly.ResetsBeforeDesiredEnd); - Assert.Equal(3, plan.Weekly.Workdays); - Assert.Equal(80 / 3d / 5d, plan.Weekly.AvailablePointsPerHour, precision: 8); - Assert.Contains("not a guarantee", plan.Disclaimer, StringComparison.Ordinal); - Assert.Contains("not a guarantee", plan.Status, StringComparison.Ordinal); - } - - [Fact] - public void WeeklyWorkdayAssumptionAffectsDailyAndHourlyBudget() - { - var desiredEnd = Now.AddHours(5); - var fiveDayPlan = UsagePlanner.Plan( - Presentation(primaryRemaining: null, secondaryRemaining: 50, secondaryReset: Now.AddDays(7)), - Now, - RefreshInterval, - desiredEnd, - remainingWorkdays: 5); - var oneDayPlan = UsagePlanner.Plan( - Presentation(primaryRemaining: null, secondaryRemaining: 50, secondaryReset: Now.AddDays(7)), - Now, - RefreshInterval, - desiredEnd, - remainingWorkdays: 1); - - Assert.Equal(10, fiveDayPlan.Weekly!.AvailablePointsPerWorkday, precision: 8); - Assert.Equal(2, fiveDayPlan.Weekly.AvailablePointsPerHour, precision: 8); - Assert.Equal(50, oneDayPlan.Weekly!.AvailablePointsPerWorkday, precision: 8); - Assert.Equal(10, oneDayPlan.Weekly.AvailablePointsPerHour, precision: 8); - } - - [Theory] - [InlineData(2, 23, 3)] - [InlineData(-7, 1, 1)] - public void WeeklyBudgetCapsWorkdaysUsingLocalCalendarDates(int offsetHours, int resetHour, int expectedDays) - { - var timeZone = TimeZoneInfo.CreateCustomTimeZone("Planning test", TimeSpan.FromHours(offsetHours), "Planning test", "Planning test"); - var reset = new DateTimeOffset(2026, 9, 11, resetHour, 0, 0, TimeSpan.Zero); - var presentation = Presentation(primaryRemaining: null, secondaryRemaining: 60, secondaryReset: reset); - - var plan = UsagePlanner.Plan(presentation, Now, RefreshInterval, Now.AddHours(5), 7, timeZone); - var equivalentOffsetPlan = UsagePlanner.Plan( - presentation, Now.ToOffset(TimeSpan.FromHours(14)), RefreshInterval, Now.AddHours(5), 7, timeZone); - - Assert.Equal(expectedDays, plan.Weekly!.Workdays); - Assert.Equal(60d / expectedDays, plan.Weekly.AvailablePointsPerWorkday, precision: 8); - Assert.Equal(60d / expectedDays / 5, plan.Weekly.AvailablePointsPerHour, precision: 8); - Assert.Equal(plan.Weekly.Workdays, equivalentOffsetPlan.Weekly!.Workdays); - } - - [Fact] - public void InvalidEndOrWorkdayCountReturnsAnExplanation() - { - var presentation = Presentation(); - - var now = UsagePlanner.Plan(presentation, Now, RefreshInterval, Now); - var zeroDays = UsagePlanner.Plan(presentation, Now, RefreshInterval, Now.AddHours(1), 0); - var eightDays = UsagePlanner.Plan(presentation, Now, RefreshInterval, Now.AddHours(1), 8); - - Assert.False(now.IsAvailable); - Assert.Contains("desired end must be after now", now.Status, StringComparison.Ordinal); - Assert.False(zeroDays.IsAvailable); - Assert.Contains("between 1 and 7", zeroDays.Status, StringComparison.Ordinal); - Assert.False(eightDays.IsAvailable); - Assert.Contains("between 1 and 7", eightDays.Status, StringComparison.Ordinal); - } - - [Fact] - public void StaleFutureUnattributedAndBlockedDataPausePlanning() - { - var desiredEnd = Now.AddHours(2); - var cases = new[] - { - (Presentation(updatedAt: Now.AddMinutes(-6)), "usage data is stale"), - (Presentation(updatedAt: Now.AddMinutes(1)), "timestamp is in the future"), - (Presentation(accountKey: null), "account identity is unavailable"), - (Presentation(ordinaryUsageAllowed: false), "ordinary usage is currently blocked"), - }; - - foreach (var (presentation, expected) in cases) - { - var plan = UsagePlanner.Plan(presentation, Now, RefreshInterval, desiredEnd); - Assert.False(plan.IsAvailable); - Assert.Contains(expected, plan.Status, StringComparison.Ordinal); - Assert.Empty(plan.Windows); - } - } - - [Fact] - public void LastConfirmedAndLoadingDataAreNotTreatedAsFreshPlans() - { - var desiredEnd = Now.AddHours(2); - var lastConfirmed = UsagePlanner.Plan( - Presentation(source: UsageDataSource.LastConfirmed), - Now, - RefreshInterval, - desiredEnd); - var loading = UsagePlanner.Plan( - Presentation(isLoading: true), - Now, - RefreshInterval, - desiredEnd); - - Assert.False(lastConfirmed.IsAvailable); - Assert.Contains("last confirmed", lastConfirmed.Status, StringComparison.Ordinal); - Assert.False(loading.IsAvailable); - Assert.Contains("loading", loading.Status, StringComparison.Ordinal); - } - - [Fact] - public void ExpiredPrimaryCanLeaveAValidWeeklyPlanAndExpiredBothPause() - { - var desiredEnd = Now.AddDays(1); - var primaryExpired = UsagePlanner.Plan( - Presentation(primaryReset: Now.AddMinutes(-1), secondaryReset: Now.AddDays(4)), - Now, - RefreshInterval, - desiredEnd); - var bothExpired = UsagePlanner.Plan( - Presentation(primaryReset: Now.AddMinutes(-1), secondaryReset: Now.AddMinutes(-1)), - Now, - RefreshInterval, - desiredEnd); - - Assert.True(primaryExpired.IsAvailable); - Assert.Null(primaryExpired.Primary); - Assert.NotNull(primaryExpired.Weekly); - Assert.False(bothExpired.IsAvailable); - Assert.Contains("no valid", bothExpired.Status, StringComparison.Ordinal); - } - - [Fact] - public void ForecastUsesOnlyAUsableCurrentPaceAndReportsUnavailableEvidenceWhenInsufficient() - { - var reset = Now.AddHours(4); - UsageHistoryEntry[] history = - [ - new(Now.AddMinutes(-10), 100), - new(Now, 50), - ]; - var plan = UsagePlanner.Plan( - Presentation( - primaryRemaining: 50, - secondaryRemaining: null, - primaryReset: reset, - primaryHistory: history), - Now, - RefreshInterval, - Now.AddHours(1)); - - Assert.True(plan.IsAvailable); - Assert.True(plan.Primary!.Forecast.IsAvailable); - Assert.True(plan.Primary.Forecast.ReachesLimitBeforeReset); - Assert.Contains("current pace", plan.Primary.Forecast.Status, StringComparison.Ordinal); - Assert.Equal(2, plan.Primary.Evidence.MeasurementCount); - Assert.Equal(2, plan.Primary.Evidence.SegmentMeasurementCount); - Assert.False(plan.Primary.Backtest.IsAvailable); - - var insufficient = UsagePlanner.Plan( - Presentation( - primaryRemaining: 50, - secondaryRemaining: null, - primaryReset: reset, - primaryHistory: [new(Now, 50)]), - Now, - RefreshInterval, - Now.AddHours(1)); - Assert.False(insufficient.Primary!.Forecast.IsAvailable); - Assert.Contains("another measurement", insufficient.Primary.Forecast.Status, StringComparison.Ordinal); - } - - [Fact] - public void GapsBreakTheContinuousSegmentAndPauseForecast() - { - UsageHistoryEntry[] history = - [ - new(Now.AddMinutes(-40), 100), - new(Now.AddMinutes(-20), 90), - new(Now, 80), - ]; - var plan = UsagePlanner.Plan( - Presentation( - primaryRemaining: 80, - secondaryRemaining: null, - primaryHistory: history), - Now, - RefreshInterval, - Now.AddHours(1)); - - Assert.Equal(3, plan.Primary!.Evidence.MeasurementCount); - Assert.Equal(1, plan.Primary.Evidence.SegmentMeasurementCount); - Assert.False(plan.Primary.Forecast.IsAvailable); - Assert.Contains("another measurement", plan.Primary.Forecast.Status, StringComparison.Ordinal); - Assert.False(plan.Primary.Backtest.IsAvailable); - } - - [Fact] - public void EvidenceDescribesMeasurementsSegmentsAdaptiveCyclesAndItsLimit() - { - UsageHistoryEntry[] history = - [ - new(Now.AddMinutes(-20), 100), - new(Now.AddMinutes(-10), 90), - new(Now, 80), - ]; - var adaptive = new AdaptiveWeeklyUsageHistory( - [new AdaptiveWeeklyUsageCycle(Now.AddDays(-7), 10080, 60, 10, [])], - new AdaptiveWeeklyUsageCycle(Now.AddDays(7), 10080, 60, 10, [])); - var plan = UsagePlanner.Plan( - Presentation( - primaryRemaining: null, - secondaryRemaining: 80, - weeklyHistory: history, - adaptiveHistory: adaptive), - Now, - RefreshInterval, - Now.AddHours(1)); - - var evidence = plan.Weekly!.Evidence; - Assert.Equal(3, evidence.MeasurementCount); - Assert.Equal(TimeSpan.FromMinutes(20), evidence.MeasurementSpan); - Assert.Equal(3, evidence.SegmentMeasurementCount); - Assert.Equal(TimeSpan.FromMinutes(20), evidence.SegmentSpan); - Assert.Equal(2, evidence.AdaptiveCycleCount); - Assert.Contains("3 measurements", evidence.Summary, StringComparison.Ordinal); - Assert.Contains("continuous segment", evidence.Summary, StringComparison.Ordinal); - Assert.Contains("adaptive weekly cycles: 2", evidence.Summary, StringComparison.Ordinal); - Assert.Contains("not a calibrated reliability probability", evidence.Summary, StringComparison.Ordinal); - } - - [Fact] - public void BacktestHoldsOutTheLatestMeasurementAndDoesNotTrainOnIt() - { - UsageHistoryEntry[] firstHistory = - [ - new(Now.AddMinutes(-20), 100), - new(Now.AddMinutes(-10), 90), - new(Now, 70), - ]; - UsageHistoryEntry[] changedHoldout = - [ - new(Now.AddMinutes(-20), 100), - new(Now.AddMinutes(-10), 90), - new(Now, 60), - ]; - var first = UsagePlanner.Plan( - Presentation(primaryRemaining: 70, secondaryRemaining: null, primaryHistory: firstHistory), - Now, - RefreshInterval, - Now.AddHours(1)).Primary!.Backtest; - var second = UsagePlanner.Plan( - Presentation(primaryRemaining: 60, secondaryRemaining: null, primaryHistory: changedHoldout), - Now, - RefreshInterval, - Now.AddHours(1)).Primary!.Backtest; - - Assert.True(first.IsAvailable); - Assert.Equal(2, first.TrainingSampleCount); - Assert.Equal(Now, first.HeldOutAt); - Assert.Equal(80, first.PredictedRemainingPercent!.Value, precision: 8); - Assert.Equal(70, first.ActualRemainingPercent!.Value, precision: 8); - Assert.Equal(10, first.AbsoluteErrorPercentagePoints!.Value, precision: 8); - Assert.Equal(first.PredictedRemainingPercent, second.PredictedRemainingPercent); - Assert.Equal(20, second.AbsoluteErrorPercentagePoints!.Value, precision: 8); - Assert.Contains("excluded from the preceding-pace calculation", first.Status, StringComparison.Ordinal); - } - - private static UsagePresentation Presentation( - double? primaryRemaining = 80, - double? secondaryRemaining = 80, - int primaryMinutes = 300, - int secondaryMinutes = 10080, - DateTimeOffset? primaryReset = null, - DateTimeOffset? secondaryReset = null, - DateTimeOffset? updatedAt = null, - UsageDataSource source = UsageDataSource.AppServer, - string? accountKey = "account-a", - bool isLoading = false, - bool? ordinaryUsageAllowed = null, - IReadOnlyList? primaryHistory = null, - IReadOnlyList? weeklyHistory = null, - AdaptiveWeeklyUsageHistory? adaptiveHistory = null) - { - var primary = primaryRemaining is { } primaryValue - ? new RateLimitWindow(100 - primaryValue, primaryMinutes, primaryReset ?? Now.AddHours(4)) - : null; - var secondary = secondaryRemaining is { } secondaryValue - ? new RateLimitWindow(100 - secondaryValue, secondaryMinutes, secondaryReset ?? Now.AddDays(6)) - : null; - var snapshot = new CodexUsageSnapshot( - primary, - secondary, - "pro", - null, - null, - updatedAt ?? Now, - source, - null, - AccountKey: accountKey, - OrdinaryUsageAllowed: ordinaryUsageAllowed, - DefaultBucketId: "codex"); - return new UsagePresentation( - snapshot, - primaryHistory ?? Array.Empty(), - weeklyHistory ?? Array.Empty(), - adaptiveHistory ?? new AdaptiveWeeklyUsageHistory([], null), - LocalTokenUsageSnapshot.Unavailable, - isLoading); - } -} diff --git a/CodexUsageDock.Tests/UsagePreferenceTests.cs b/CodexUsageDock.Tests/UsagePreferenceTests.cs index 214a71c..737f1be 100644 --- a/CodexUsageDock.Tests/UsagePreferenceTests.cs +++ b/CodexUsageDock.Tests/UsagePreferenceTests.cs @@ -19,8 +19,6 @@ public void NewPreferencesUseSafeDefaults() Assert.False(settings.CompactDock); Assert.False(settings.SeparateDockItems); Assert.True(settings.ShowAccountActivity); - Assert.Equal(string.Empty, settings.CodexExecutablePath); - Assert.Equal(string.Empty, settings.CodexHomePath); } [Fact] @@ -33,16 +31,12 @@ public void UsagePreferencesPersistAcrossNewPages() ["compactDock"] = "true", ["separateDockItems"] = "true", ["showAccountActivity"] = "false", - ["codexExecutablePath"] = "C:/Tools/codex.cmd", - ["codexHomePath"] = "C:/Users/test/.codex", }); Assert.True(first.EnableUsageAlerts); Assert.True(first.CompactDock); Assert.True(first.SeparateDockItems); Assert.False(first.ShowAccountActivity); - Assert.Equal("C:/Tools/codex.cmd", first.CodexExecutablePath); - Assert.Equal("C:/Users/test/.codex", first.CodexHomePath); var restarted = _environment.CreateSettings(); @@ -50,14 +44,11 @@ public void UsagePreferencesPersistAcrossNewPages() Assert.True(restarted.CompactDock); Assert.True(restarted.SeparateDockItems); Assert.False(restarted.ShowAccountActivity); - Assert.Equal("C:/Tools/codex.cmd", restarted.CodexExecutablePath); - Assert.Equal("C:/Users/test/.codex", restarted.CodexHomePath); } [Fact] - public void SettingsLoadKeepsValidValuesAndRejectsUnsafePathValues() + public void SettingsLoadKeepsValidValuesAndRejectsInvalidChoices() { - var validBoundaryPath = new string('a', 1024); File.WriteAllText(_environment.PathFor("settings.json"), JsonSerializer.Serialize( new Dictionary { @@ -65,8 +56,6 @@ public void SettingsLoadKeepsValidValuesAndRejectsUnsafePathValues() ["compactDock"] = "true", ["separateDockItems"] = "not-a-boolean", ["showAccountActivity"] = "false", - ["codexExecutablePath"] = validBoundaryPath, - ["codexHomePath"] = new string('b', 1025), })); var settings = _environment.CreateSettings(); @@ -75,20 +64,6 @@ public void SettingsLoadKeepsValidValuesAndRejectsUnsafePathValues() Assert.True(settings.CompactDock); Assert.False(settings.SeparateDockItems); Assert.False(settings.ShowAccountActivity); - Assert.Equal(validBoundaryPath, settings.CodexExecutablePath); - Assert.Contains("Invalid source path", settings.CodexHomePath, StringComparison.Ordinal); - - File.WriteAllText(_environment.PathFor("settings.json"), JsonSerializer.Serialize( - new Dictionary - { - ["codexExecutablePath"] = "C:/Codex\u0001/codex.exe", - ["codexHomePath"] = "C:/Users/test/.codex", - })); - - var controlCharacterSettings = _environment.CreateSettings(); - - Assert.Contains("Invalid source path", controlCharacterSettings.CodexExecutablePath, StringComparison.Ordinal); - Assert.Equal("C:/Users/test/.codex", controlCharacterSettings.CodexHomePath); } [Theory] diff --git a/CodexUsageDock/ClaudeUsageReader.cs b/CodexUsageDock/ClaudeUsageReader.cs deleted file mode 100644 index bd6d452..0000000 --- a/CodexUsageDock/ClaudeUsageReader.cs +++ /dev/null @@ -1,335 +0,0 @@ -using System.Globalization; -using System.Text; -using System.Text.Json; - -namespace CodexUsageDock; - -internal enum ClaudeUsageReadStatus -{ - Available, - Partial, - Unavailable, - Stale, - Future, -} - -internal sealed record ClaudeUsageWindow( - double UsedPercent, - int WindowMinutes, - DateTimeOffset ResetsAt) -{ - internal double RemainingPercent => Math.Clamp(100 - UsedPercent, 0, 100); -} - -internal sealed record ClaudeUsageSnapshot( - ClaudeUsageWindow? Primary, - ClaudeUsageWindow? Weekly, - DateTimeOffset ObservedAt, - ClaudeUsageReadStatus Status, - string Message) -{ - internal bool IsAvailable => Status is ClaudeUsageReadStatus.Available or ClaudeUsageReadStatus.Partial; - - internal static ClaudeUsageSnapshot Unavailable(string message) => - new(null, null, DateTimeOffset.MinValue, ClaudeUsageReadStatus.Unavailable, message); -} - -internal static class ClaudeUsageReader -{ - internal const int MaximumFileBytes = 64 * 1024; - private const int PrimaryWindowMinutes = 5 * 60; - private const int WeeklyWindowMinutes = 7 * 24 * 60; - - internal static ClaudeUsageSnapshot Read( - string path, - DateTimeOffset now, - TimeSpan refreshInterval, - CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - if (string.IsNullOrWhiteSpace(path) || !IsFullyQualifiedPath(path)) - { - return ClaudeUsageSnapshot.Unavailable("Claude usage capture path must be a fully qualified file path."); - } - - byte[] bytes; - try - { - bytes = ReadBounded(path, cancellationToken); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException - or NotSupportedException or PathTooLongException or InvalidDataException) - { - return ClaudeUsageSnapshot.Unavailable("Claude usage capture could not be read."); - } - - JsonDocument document; - try - { - var encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); - var json = encoding.GetString(bytes); - document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 16 }); - } - catch (Exception error) when (error is JsonException or DecoderFallbackException or ArgumentException) - { - return ClaudeUsageSnapshot.Unavailable("Claude usage capture is not valid JSON."); - } - - using (document) - { - return Parse(document.RootElement, now, refreshInterval); - } - } - - internal static ClaudeUsageSnapshot Read(string path, DateTimeOffset now) => - Read(path, now, UsageFreshness.MinimumAge); - - internal static ClaudeUsageSnapshot Read(string path) => - Read(path, DateTimeOffset.UtcNow, UsageFreshness.MinimumAge); - - private static ClaudeUsageSnapshot Parse( - JsonElement root, - DateTimeOffset now, - TimeSpan refreshInterval) - { - if (root.ValueKind != JsonValueKind.Object - || !TryGetSchemaVersion(root, out var schemaVersion) - || schemaVersion != 1 - || !TryGetString(root, "provider", out var provider) - || !string.Equals(provider, "claude", StringComparison.OrdinalIgnoreCase) - || !TryGetObservedAt(root, out var observedAt)) - { - return ClaudeUsageSnapshot.Unavailable("Claude usage capture has an unsupported schema or provider."); - } - - if (!TryGetObject(root, "rate_limits", out var rateLimits)) - { - return ClaudeUsageSnapshot.Unavailable("Claude usage capture has no rate-limit data."); - } - - var primary = ParseWindow(rateLimits, "five_hour", PrimaryWindowMinutes, now); - var weekly = ParseWindow(rateLimits, "seven_day", WeeklyWindowMinutes, now); - var freshness = UsageFreshness.Classify(observedAt, now, refreshInterval); - if (freshness == UsageFreshnessState.Future) - { - return new ClaudeUsageSnapshot(primary.Window, weekly.Window, observedAt, ClaudeUsageReadStatus.Future, - "Claude usage capture timestamp is in the future."); - } - - if (freshness == UsageFreshnessState.Stale) - { - return new ClaudeUsageSnapshot(primary.Window, weekly.Window, observedAt, ClaudeUsageReadStatus.Stale, - "Claude usage capture is stale."); - } - - if (freshness != UsageFreshnessState.Fresh) - { - return new ClaudeUsageSnapshot(primary.Window, weekly.Window, observedAt, ClaudeUsageReadStatus.Unavailable, - "Claude usage capture freshness is unavailable."); - } - - var validCount = (primary.Window is null ? 0 : 1) + (weekly.Window is null ? 0 : 1); - var status = validCount switch - { - 2 => ClaudeUsageReadStatus.Available, - 1 => ClaudeUsageReadStatus.Partial, - _ => ClaudeUsageReadStatus.Unavailable, - }; - var message = validCount switch - { - 2 => "Claude rate-limit windows are available.", - 1 => "One Claude rate-limit window is unavailable; windows remain independent.", - _ => "No valid Claude rate-limit windows were provided.", - }; - return new ClaudeUsageSnapshot(primary.Window, weekly.Window, observedAt, status, message); - } - - private static WindowParseResult ParseWindow( - JsonElement parent, - string propertyName, - int windowMinutes, - DateTimeOffset now) - { - if (!parent.TryGetProperty(propertyName, out var value) - || value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) - { - return new WindowParseResult(null); - } - - if (value.ValueKind != JsonValueKind.Object - || !TryGetNumber(value, "used_percentage", out var usedPercent) - || !TryGetReset(value, "resets_at", out var resetsAt)) - { - return new WindowParseResult(null); - } - - var rateWindow = new RateLimitWindow(usedPercent, windowMinutes, resetsAt); - return UsageFreshness.IsValidWindow(rateWindow, now) - ? new WindowParseResult(new ClaudeUsageWindow(usedPercent, windowMinutes, resetsAt.ToUniversalTime())) - : new WindowParseResult(null); - } - - private static bool TryGetSchemaVersion(JsonElement root, out int version) - { - version = 0; - return root.TryGetProperty("schemaVersion", out var value) - && value.ValueKind == JsonValueKind.Number - && value.TryGetInt32(out version); - } - - private static bool TryGetString(JsonElement parent, string propertyName, out string value) - { - value = string.Empty; - return parent.TryGetProperty(propertyName, out var element) - && element.ValueKind == JsonValueKind.String - && (value = element.GetString() ?? string.Empty).Length > 0; - } - - private static bool TryGetObservedAt(JsonElement root, out DateTimeOffset observedAt) - { - observedAt = default; - if (!TryGetString(root, "observedAtUTC", out var value) - || !DateTimeOffset.TryParse( - value, - CultureInfo.InvariantCulture, - DateTimeStyles.RoundtripKind, - out observedAt) - || !HasExplicitOffset(value)) - { - observedAt = default; - return false; - } - - observedAt = observedAt.ToUniversalTime(); - return true; - } - - private static bool TryGetObject(JsonElement parent, string propertyName, out JsonElement value) - { - value = default; - return parent.ValueKind == JsonValueKind.Object - && parent.TryGetProperty(propertyName, out value) - && value.ValueKind == JsonValueKind.Object; - } - - private static bool TryGetNumber( - JsonElement parent, - string propertyName, - out double number) - { - number = 0; - if (parent.TryGetProperty(propertyName, out var value) - && value.ValueKind == JsonValueKind.Number - && value.TryGetDouble(out number) - && double.IsFinite(number) - && number is >= 0 and <= 100) - { - return true; - } - - number = 0; - return false; - } - - private static bool TryGetReset( - JsonElement parent, - string propertyName, - out DateTimeOffset resetsAt) - { - resetsAt = default; - if (parent.TryGetProperty(propertyName, out var value) - && value.ValueKind == JsonValueKind.Number - && value.TryGetInt64(out var seconds)) - { - try - { - resetsAt = DateTimeOffset.FromUnixTimeSeconds(seconds); - return true; - } - catch (ArgumentOutOfRangeException) - { - return false; - } - } - - return false; - } - - private static byte[] ReadBounded(string path, CancellationToken cancellationToken) - { - using var stream = new FileStream( - path, - FileMode.Open, - FileAccess.Read, - FileShare.ReadWrite | FileShare.Delete, - bufferSize: 8192, - options: FileOptions.SequentialScan); - if (stream.Length > MaximumFileBytes) - { - throw new InvalidDataException("Claude usage capture is oversized."); - } - - using var memory = new MemoryStream((int)stream.Length); - var buffer = new byte[8192]; - while (true) - { - cancellationToken.ThrowIfCancellationRequested(); - var read = stream.Read(buffer, 0, buffer.Length); - if (read == 0) - { - break; - } - - if (memory.Length + read > MaximumFileBytes) - { - throw new InvalidDataException("Claude usage capture is oversized."); - } - - memory.Write(buffer, 0, read); - } - - return memory.ToArray(); - } - - private static bool IsFullyQualifiedPath(string path) - { - try - { - return Path.IsPathFullyQualified(path); - } - catch (Exception error) when (error is ArgumentException or NotSupportedException or PathTooLongException) - { - return false; - } - } - - private static bool HasExplicitOffset(string value) - { - var text = value.Trim(); - if (text.EndsWith("Z", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - var timeSeparator = text.IndexOf('T'); - if (timeSeparator < 0) - { - timeSeparator = text.IndexOf(' '); - } - - if (timeSeparator < 0 || timeSeparator == text.Length - 1) - { - return false; - } - - var time = text[(timeSeparator + 1)..]; - return time.Contains('+', StringComparison.Ordinal) - || time.LastIndexOf('-') > 0; - } - - private readonly record struct WindowParseResult(ClaudeUsageWindow? Window); -} diff --git a/CodexUsageDock/CodexProfileStore.cs b/CodexUsageDock/CodexProfileStore.cs deleted file mode 100644 index 7fcd52b..0000000 --- a/CodexUsageDock/CodexProfileStore.cs +++ /dev/null @@ -1,348 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace CodexUsageDock; - -internal sealed record CodexProfile( - Guid Id, - string DisplayName, - CodexSourceOptions SourceOptions); - -internal sealed class CodexProfileStore -{ - internal const int SchemaVersion = 1; - internal const int MaximumProfiles = 8; - internal const int MaximumDisplayNameLength = 40; - internal const int MaximumPathLength = 1024; - internal const int MaximumDocumentBytes = 512 * 1024; - - private const string LoadErrorMessage = "Saved Codex profiles could not be read. No profiles were loaded."; - private const string SaveErrorMessage = "The Codex profile could not be saved. Try again."; - private const string InvalidNameMessage = "Profile names must contain 1 to 40 characters without control characters."; - private const string InvalidSourceMessage = "The Codex executable or home path is invalid or unavailable."; - private const string MaximumProfilesMessage = "You can save up to eight Codex profiles."; - private const string ProfileNotFoundMessage = "The Codex profile was not found."; - private const string InvalidProfileIdMessage = "The Codex profile identifier is invalid."; - private readonly object _gate = new(); - private readonly string _path; - private List _profiles; - private string? _storageError; - - internal CodexProfileStore(string path) - { - ArgumentException.ThrowIfNullOrWhiteSpace(path); - _path = Path.GetFullPath(path); - _profiles = Load(); - } - - internal static CodexProfileStore CreateDefault() => - new(LocalStorage.GetPath("profiles.json")); - - internal IReadOnlyList Profiles - { - get - { - lock (_gate) - { - return _profiles.ToArray(); - } - } - } - - internal string? StorageError - { - get - { - lock (_gate) - { - return _storageError; - } - } - } - - internal bool TryGet(Guid id, out CodexProfile? profile) - { - lock (_gate) - { - profile = id != Guid.Empty - ? _profiles.FirstOrDefault(candidate => candidate.Id == id) - : null; - return profile is not null; - } - } - - internal bool TryUpsert( - string? displayName, - string? executablePath, - string? homePath, - out CodexProfile? profile, - out string? error) - { - profile = null; - error = null; - if (!TryNormalizeDisplayName(displayName, out var normalizedName)) - { - error = InvalidNameMessage; - return false; - } - - if (!CodexSourceOptions.TryCreate(executablePath, homePath, out var options, out _)) - { - error = InvalidSourceMessage; - return false; - } - - lock (_gate) - { - var existingIndex = _profiles.FindIndex(candidate => - string.Equals(candidate.DisplayName, normalizedName, StringComparison.OrdinalIgnoreCase)); - if (existingIndex < 0 && _profiles.Count >= MaximumProfiles) - { - error = MaximumProfilesMessage; - return false; - } - - var candidate = new CodexProfile( - existingIndex >= 0 ? _profiles[existingIndex].Id : Guid.NewGuid(), - normalizedName, - options); - var updated = _profiles.ToList(); - if (existingIndex >= 0) - { - updated[existingIndex] = candidate; - } - else - { - updated.Add(candidate); - } - - if (!TrySave(updated, out error)) - { - return false; - } - - _profiles = updated; - profile = candidate; - return true; - } - } - - internal bool TryRemove(Guid id, out string? error) - { - error = null; - if (id == Guid.Empty) - { - error = InvalidProfileIdMessage; - return false; - } - - lock (_gate) - { - var existingIndex = _profiles.FindIndex(candidate => candidate.Id == id); - if (existingIndex < 0) - { - error = ProfileNotFoundMessage; - return false; - } - - var updated = _profiles.ToList(); - updated.RemoveAt(existingIndex); - if (!TrySave(updated, out error)) - { - return false; - } - - _profiles = updated; - return true; - } - } - - private List Load() - { - try - { - if (!File.Exists(_path)) - { - return []; - } - - var fileInfo = new FileInfo(_path); - if (fileInfo.Length > MaximumDocumentBytes) - { - SetStorageError(LoadErrorMessage); - return []; - } - - var document = JsonSerializer.Deserialize( - File.ReadAllText(_path), - CodexProfileStoreJsonContext.Default.CodexProfileDocument); - if (document is null || document.SchemaVersion != SchemaVersion || document.Profiles is null) - { - SetStorageError(LoadErrorMessage); - return []; - } - - var profiles = new List(Math.Min(document.Profiles.Length, MaximumProfiles)); - var ids = new HashSet(); - var names = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var entry in document.Profiles) - { - if (entry is null - || !Guid.TryParseExact(entry.Id, "N", out var id) - || id == Guid.Empty - || !TryNormalizeDisplayName(entry.DisplayName, out var displayName) - || !TryNormalizePathShape(entry.ExecutablePath, executable: true, out var executablePath) - || !TryNormalizePathShape(entry.HomePath, executable: false, out var homePath) - || !ids.Add(id) - || !names.Add(displayName)) - { - continue; - } - - profiles.Add(new CodexProfile(id, displayName, new(executablePath, homePath))); - if (profiles.Count == MaximumProfiles) - { - break; - } - } - - return profiles; - } - catch (Exception exception) when (exception is IOException - or UnauthorizedAccessException - or JsonException - or NotSupportedException - or InvalidOperationException - or ArgumentException) - { - LocalStorage.TraceFailure("load Codex profiles", exception); - SetStorageError(LoadErrorMessage); - return []; - } - } - - private bool TrySave(IReadOnlyList profiles, out string? error) - { - error = null; - try - { - var document = new CodexProfileDocument( - SchemaVersion, - profiles.Select(profile => new CodexProfileEntry( - profile.Id.ToString("N"), - profile.DisplayName, - profile.SourceOptions.ExecutablePath, - profile.SourceOptions.HomePath)).ToArray()); - var content = JsonSerializer.Serialize( - document, - CodexProfileStoreJsonContext.Default.CodexProfileDocument); - if (!LocalStorage.TryWrite(_path, content)) - { - error = SaveErrorMessage; - SetStorageError(error); - return false; - } - - SetStorageError(null); - return true; - } - catch (Exception exception) when (exception is IOException - or UnauthorizedAccessException - or JsonException - or NotSupportedException - or InvalidOperationException - or ArgumentException) - { - LocalStorage.TraceFailure("save Codex profiles", exception); - error = SaveErrorMessage; - SetStorageError(error); - return false; - } - } - - private static bool TryNormalizeDisplayName(string? value, out string normalized) - { - normalized = string.Empty; - if (value is null || value.Any(char.IsControl)) - { - return false; - } - - normalized = value.Trim(); - return normalized.Length is >= 1 and <= MaximumDisplayNameLength; - } - - // Loading deliberately checks only path shape. A temporarily unavailable - // WSL or network directory remains selectable until use-time validation. - private static bool TryNormalizePathShape(string? value, bool executable, out string? normalized) - { - normalized = null; - if (string.IsNullOrWhiteSpace(value)) - { - return true; - } - - var trimmed = value.Trim(); - if (trimmed.Length > MaximumPathLength - || trimmed.Any(char.IsControl) - || !Path.IsPathFullyQualified(trimmed)) - { - return false; - } - - try - { - normalized = Path.TrimEndingDirectorySeparator(Path.GetFullPath(trimmed)); - if (normalized.Length == 0) - { - return false; - } - - if (executable) - { - var fileName = Path.GetFileName(normalized); - if (!(fileName.Equals("codex.exe", StringComparison.OrdinalIgnoreCase) - || fileName.Equals("codex.cmd", StringComparison.OrdinalIgnoreCase)) - || CodexAppServerReader.IsWindowsAppsPath(normalized)) - { - normalized = null; - return false; - } - } - - return true; - } - catch (Exception exception) when (exception is ArgumentException - or NotSupportedException - or PathTooLongException) - { - normalized = null; - return false; - } - } - - private void SetStorageError(string? error) - { - lock (_gate) - { - _storageError = error; - } - } -} - -internal sealed record CodexProfileDocument( - [property: JsonPropertyName("schemaVersion")] int SchemaVersion, - [property: JsonPropertyName("profiles")] CodexProfileEntry?[]? Profiles); - -internal sealed record CodexProfileEntry( - [property: JsonPropertyName("id")] string? Id, - [property: JsonPropertyName("displayName")] string? DisplayName, - [property: JsonPropertyName("executablePath")] string? ExecutablePath, - [property: JsonPropertyName("homePath")] string? HomePath); - -[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)] -[JsonSerializable(typeof(CodexProfileDocument))] -[JsonSerializable(typeof(CodexProfileEntry))] -internal sealed partial class CodexProfileStoreJsonContext : JsonSerializerContext -{ -} diff --git a/CodexUsageDock/CodexUsageDockCommandsProvider.cs b/CodexUsageDock/CodexUsageDockCommandsProvider.cs index 2688702..0d8dcd7 100644 --- a/CodexUsageDock/CodexUsageDockCommandsProvider.cs +++ b/CodexUsageDock/CodexUsageDockCommandsProvider.cs @@ -14,15 +14,9 @@ public partial class CodexUsageDockCommandsProvider : CommandProvider private readonly CodexUsageDockPage _details; private readonly CodexUsageDiagnosticsPage _diagnostics; private readonly CodexAccountActivityPage _accountActivity; - private readonly CodexPlanningPage _planner; private readonly CodexHistoryPage _history; private readonly CodexActionsPage _actions; private readonly CodexUsageTablePage _textUsage; - private readonly CodexProfilesPage _profiles; - private readonly ClaudeUsagePage _claude; - private readonly ListItem _claudeFiveHour; - private readonly ListItem _claudeWeekly; - private readonly object _claudePresentationLock = new(); private readonly UsageAlertEvaluator _alerts = new(); private readonly Action _notify; private readonly Func _clock; @@ -30,7 +24,12 @@ public partial class CodexUsageDockCommandsProvider : CommandProvider private const string FiveHourDockId = "nl.mathijs.codexusage.dock.five-hour"; private const string WeeklyDockId = "nl.mathijs.codexusage.dock.weekly"; private const string CreditsDockId = "nl.mathijs.codexusage.dock.credits"; - private const string ClaudeDockId = "nl.mathijs.codexusage.dock.claude"; + private readonly object _dockLayoutLock = new(); + private readonly UsageDockBand _combinedBand; + private readonly UsageDockBand _fiveHourBand; + private readonly UsageDockBand _weeklyBand; + private readonly UsageDockBand _creditsBand; + private readonly UsageDockBand[] _allDockBands; private ICommandItem[] _dockBands = []; public CodexUsageDockCommandsProvider() @@ -53,27 +52,24 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS _usage.SetRefreshInterval(_settings.RefreshInterval); _usage.SetAdaptiveWeeklyForecastEnabled(_settings.UseAdaptiveWeeklyForecast); - ApplySourceSettings(); _usage.SetAccountActivityEnabled(_settings.ShowAccountActivity); _usage.SetAggregateRetentionDays(_settings.HistoryRetentionDays); - _usage.ConfigureClaude(_settings.EnableClaude, _settings.ClaudeBridgePath); var details = _details = new CodexUsageDockPage(_usage, _settings); _diagnostics = new CodexUsageDiagnosticsPage(_usage); _diagnostics.Id = "nl.mathijs.codexusage.diagnostics"; _accountActivity = new CodexAccountActivityPage(_usage); - _planner = new CodexPlanningPage(_usage, _settings, _clock); _history = new CodexHistoryPage(_usage); _actions = new CodexActionsPage(_usage); _textUsage = new CodexUsageTablePage(_usage, _clock); - _profiles = new CodexProfilesPage(new CodexProfileStore(_settings.ProfileStoragePath)); - _profiles.ProfileSelected += OnProfileSelected; - _claude = new ClaudeUsagePage(_usage); - _claudeFiveHour = new ListItem(_claude); - _claudeWeekly = new ListItem(_claude); _details.Commands = [.. _details.Commands, new CommandContextItem(_textUsage) { Title = "Read usage in text" }]; _fiveHour = new UsageDockItem(_usage, UsageDockItemKind.FiveHour, details, _settings); _weekly = new UsageDockItem(_usage, UsageDockItemKind.Weekly, details, _settings); _resetsAndCredits = new UsageDockItem(_usage, UsageDockItemKind.ResetsAndCredits, details); + _combinedBand = new("nl.mathijs.codexusage.dock", DisplayName); + _fiveHourBand = new(FiveHourDockId, "Codex five-hour usage"); + _weeklyBand = new(WeeklyDockId, "Codex weekly usage"); + _creditsBand = new(CreditsDockId, "Codex resets and credits"); + _allDockBands = [_combinedBand, _fiveHourBand, _weeklyBand, _creditsBand]; _commands = [ @@ -98,42 +94,27 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS Title = "Codex account activity", Subtitle = "Account-wide daily tokens reported by Codex", }, - new CommandItem(_planner) { Title = "Codex workday planner", Subtitle = "Daily quota budget, recent pace, and forecast evidence" }, new CommandItem(_history) { Title = "Codex usage history", Subtitle = "Retained quota observations, CSV/JSON export, and deletion" }, new CommandItem(_actions) { Title = "Codex task usage and earned resets", Subtitle = "Request a task estimate or explicitly use an earned reset" }, new CommandItem(_textUsage) { Title = "Codex usage in text", Subtitle = "Quota tables and measured values without charts or color cues" }, - new CommandItem(_profiles) { Title = "Codex source profiles", Subtitle = "Save and select named local or Windows-accessible WSL sources" }, - new CommandItem(_claude) { Title = "Claude usage pilot", Subtitle = "Optional local statusline capture; independent Claude quotas" }, ]; _settings.Changed += OnSettingsChanged; _settings.ClearAdaptiveHistoryRequested += OnClearAdaptiveHistoryRequested; _usage.Updated += OnUsageUpdated; - _usage.ClaudeUpdated += OnClaudeUpdated; - RefreshClaudeItems(); - RebuildDockBands(); + UpdateDockLayout(); _usage.Start(); } public override ICommandItem[] TopLevelCommands() => _commands; - public override ICommandItem[]? GetDockBands() => _dockBands; + public override ICommandItem[]? GetDockBands() => [.. Volatile.Read(ref _dockBands)]; public override ICommandItem? GetCommandItem(string id) { if (string.IsNullOrWhiteSpace(id)) return null; - var known = _commands.Concat(_dockBands).FirstOrDefault(item => item.Command.Id == id); - if (known is not null) return known; - return id switch - { - "nl.mathijs.codexusage.dock" => new WrappedDockItem(GetVisibleDockItems(), "nl.mathijs.codexusage.dock", DisplayName), - FiveHourDockId => new WrappedDockItem([_fiveHour], FiveHourDockId, "Codex five-hour usage"), - WeeklyDockId => new WrappedDockItem([_weekly], WeeklyDockId, "Codex weekly usage"), - CreditsDockId => new WrappedDockItem([_resetsAndCredits], CreditsDockId, "Codex resets and credits"), - ClaudeDockId => new WrappedDockItem(_settings.EnableClaude ? [_claudeFiveHour, _claudeWeekly] : [], ClaudeDockId, "Claude usage"), - _ => null, - }; + return _commands.Concat(Volatile.Read(ref _dockBands)).FirstOrDefault(item => item.Command.Id == id); } private void OnSettingsChanged(object? sender, EventArgs e) @@ -145,53 +126,15 @@ private void OnSettingsChanged(object? sender, EventArgs e) } _usage.SetRefreshInterval(_settings.RefreshInterval); _usage.SetAdaptiveWeeklyForecastEnabled(_settings.UseAdaptiveWeeklyForecast); - var sourceChanged = ApplySourceSettings(); _usage.SetAccountActivityEnabled(_settings.ShowAccountActivity); _usage.SetAggregateRetentionDays(_settings.HistoryRetentionDays); - _usage.ConfigureClaude(_settings.EnableClaude, _settings.ClaudeBridgePath); _fiveHour.Refresh(); _weekly.Refresh(); _details.Refresh(); - _planner.Refresh(); _history.Refresh(); - RebuildDockBands(); - RaiseItemsChanged(); - if (sourceChanged) _ = _usage.RefreshAsync(); - } - - private bool ApplySourceSettings() - { - _ = CodexSourceOptions.TryCreate(_settings.CodexExecutablePath, _settings.CodexHomePath, out var options, out var error); - if (error is not null) _settings.ShowOperationStatus(error); - return _usage.ConfigureSource(options, error); - } - - private void OnProfileSelected(object? sender, CodexProfileSelectedEventArgs args) => _settings.ApplySourceProfile(args.Name, args.Options); - - private void OnClaudeUpdated(object? sender, EventArgs args) - { - RefreshClaudeItems(); - RebuildDockBands(); - RaiseItemsChanged(); - } - - private void RefreshClaudeItems() - { - lock (_claudePresentationLock) - { - var snapshot = _usage.GetClaudeUsage(); - var now = _clock(); - var fresh = snapshot.IsAvailable && UsageFreshness.IsFresh(snapshot.ObservedAt, now, _usage.RefreshInterval); - _claudeFiveHour.Title = "Claude 5h " + FormatClaudeRemaining(snapshot.Primary, fresh, now); - _claudeWeekly.Title = "Claude week " + FormatClaudeRemaining(snapshot.Weekly, fresh, now); - _claudeFiveHour.Subtitle = snapshot.Message; - _claudeWeekly.Subtitle = snapshot.Message; - } + UpdateDockLayout(); } - private static string FormatClaudeRemaining(ClaudeUsageWindow? window, bool fresh, DateTimeOffset now) => fresh && window is not null && window.ResetsAt > now - ? window.RemainingPercent.ToString("0", System.Globalization.CultureInfo.InvariantCulture) + "%" : "--"; - private void OnClearAdaptiveHistoryRequested(object? sender, EventArgs e) { var cleared = _usage.ClearAdaptiveWeeklyHistory(); @@ -212,8 +155,10 @@ private void OnUsageUpdated(object? sender, EventArgs e) return; } - RebuildDockBands(); - RaiseItemsChanged(); + foreach (var band in Volatile.Read(ref _dockBands).OfType()) + { + band.NotifyItemsChanged(); + } var alerts = _alerts.Evaluate(_usage.GetPresentation(), _clock(), _usage.RefreshInterval, new UsageAlertOptions(Enabled: _settings.EnableUsageAlerts)); if (alerts.Count > 0) @@ -224,25 +169,30 @@ private void OnUsageUpdated(object? sender, EventArgs e) } } - private void RebuildDockBands() + private void UpdateDockLayout() { - var items = GetVisibleDockItems(); - ICommandItem[] bands; - if (_settings.SeparateDockItems) + var changedBands = new List(); + bool catalogChanged; + lock (_dockLayoutLock) { - bands = items.Select(item => new WrappedDockItem([item], - ReferenceEquals(item, _fiveHour) ? FiveHourDockId : ReferenceEquals(item, _weekly) ? WeeklyDockId : CreditsDockId, - ReferenceEquals(item, _fiveHour) ? "Codex five-hour usage" : ReferenceEquals(item, _weekly) ? "Codex weekly usage" : "Codex resets and credits")) - .Cast().ToArray(); + var separate = _settings.SeparateDockItems; + Publish(_combinedBand, separate ? [] : GetVisibleDockItems()); + Publish(_fiveHourBand, separate && _settings.ShowFiveHourLimit ? [_fiveHour] : []); + Publish(_weeklyBand, separate && _settings.ShowWeeklyLimit ? [_weekly] : []); + Publish(_creditsBand, separate && _settings.ShowResetsAndCredits ? [_resetsAndCredits] : []); + ICommandItem[] bands = _allDockBands.Where(band => band.HasItems).ToArray(); + catalogChanged = !Volatile.Read(ref _dockBands).SequenceEqual(bands); + Volatile.Write(ref _dockBands, bands); } - else + + // No host callback may run while the layout lock is held. + foreach (var band in changedBands) band.NotifyItemsChanged(); + if (catalogChanged) RaiseItemsChanged(); + + void Publish(UsageDockBand band, IListItem[] items) { - var dockBand = items.Length == 0 ? null : new WrappedDockItem(items, "nl.mathijs.codexusage.dock", DisplayName); - bands = dockBand is null ? [] : [dockBand]; + if (band.PublishItems(items)) changedBands.Add(band); } - if (_settings.EnableClaude) - bands = [.. bands, new WrappedDockItem([_claudeFiveHour, _claudeWeekly], ClaudeDockId, "Claude usage")]; - _dockBands = bands; } private IListItem[] GetVisibleDockItems() @@ -271,20 +221,15 @@ public override void Dispose() _settings.Changed -= OnSettingsChanged; _settings.ClearAdaptiveHistoryRequested -= OnClearAdaptiveHistoryRequested; _usage.Updated -= OnUsageUpdated; - _usage.ClaudeUpdated -= OnClaudeUpdated; - _profiles.ProfileSelected -= OnProfileSelected; _fiveHour.Dispose(); _weekly.Dispose(); _resetsAndCredits.Dispose(); _details.Dispose(); _diagnostics.Dispose(); _accountActivity.Dispose(); - _planner.Dispose(); _history.Dispose(); _actions.Dispose(); _textUsage.Dispose(); - _profiles.Dispose(); - _claude.Dispose(); _usage.Dispose(); base.Dispose(); GC.SuppressFinalize(this); diff --git a/CodexUsageDock/CodexUsageService.Claude.cs b/CodexUsageDock/CodexUsageService.Claude.cs deleted file mode 100644 index 5c1d051..0000000 --- a/CodexUsageDock/CodexUsageService.Claude.cs +++ /dev/null @@ -1,72 +0,0 @@ -namespace CodexUsageDock; - -internal sealed partial class CodexUsageService -{ - private bool _claudeEnabled; - private string _claudePath = string.Empty; - private TimeSpan _claudeRefreshInterval; - private long _claudeGeneration; - private Task? _claudeReadTask; - private ClaudeUsageSnapshot _claudeUsage = ClaudeUsageSnapshot.Unavailable("The Claude pilot is disabled."); - internal event EventHandler? ClaudeUpdated; - - internal ClaudeUsageSnapshot GetClaudeUsage() { lock (_refreshStateLock) { return _claudeUsage; } } - internal Task ClaudeRefreshTask { get { lock (_refreshStateLock) { return _claudeReadTask ?? Task.CompletedTask; } } } - - internal void ConfigureClaude(bool enabled, string path) - { - lock (_refreshStateLock) - { - var interval = RefreshInterval; - if (_disposed || _claudeEnabled == enabled && _claudePath == path && _claudeRefreshInterval == interval) return; - _claudeEnabled = enabled; - _claudePath = path; - _claudeRefreshInterval = interval; - _claudeGeneration++; - _claudeUsage = ClaudeUsageSnapshot.Unavailable(enabled ? "Waiting for a local Claude usage capture." : "The Claude pilot is disabled."); - } - RaiseClaudeUpdated(); - StartClaudeRefresh(); - } - - private void StartClaudeRefresh() - { - lock (_refreshStateLock) - { - if (_disposed || !_claudeEnabled || _claudeReadTask is { IsCompleted: false }) return; - var path = _claudePath; - var generation = _claudeGeneration; - var interval = RefreshInterval; - var cancellationToken = _lifetimeCancellation.Token; - _claudeReadTask = Task.Run(() => - { - ClaudeUsageSnapshot result; - try { result = ClaudeUsageReader.Read(path, _clock(), interval, cancellationToken); } - catch (Exception error) - { - if (error is not OperationCanceledException) LocalStorage.TraceFailure("read Claude capture", error); - result = ClaudeUsageSnapshot.Unavailable("The Claude usage capture could not be read."); - } - lock (_refreshStateLock) - { - if (!_disposed && generation == _claudeGeneration && _claudeEnabled) _claudeUsage = result; - } - RaiseClaudeUpdated(); - bool restart; - lock (_refreshStateLock) - { - _claudeReadTask = null; - restart = !_disposed && generation != _claudeGeneration && _claudeEnabled; - } - if (restart) StartClaudeRefresh(); - }); - } - } - - private void RaiseClaudeUpdated() - { - lock (_refreshStateLock) { if (_disposed) return; } - try { ClaudeUpdated?.Invoke(this, EventArgs.Empty); } - catch (Exception error) { LocalStorage.TraceFailure("update Claude presentation", error); } - } -} diff --git a/CodexUsageDock/CodexUsageService.cs b/CodexUsageDock/CodexUsageService.cs index f9f5f32..46476ea 100644 --- a/CodexUsageDock/CodexUsageService.cs +++ b/CodexUsageDock/CodexUsageService.cs @@ -261,7 +261,6 @@ internal string? HistoryStorageError public Task RefreshAsync() { - StartClaudeRefresh(); TaskCompletionSource completion; CancellationToken cancellationToken; CodexSourceOptions options; @@ -684,8 +683,7 @@ public void Dispose() _disposed = true; refreshTask = Task.WhenAll(_refreshTask ?? Task.CompletedTask, _tokenRefreshTask, _accountRefreshTask, - (Task?)_resetActionTask ?? Task.CompletedTask, _threadActionTask ?? Task.CompletedTask, - _claudeReadTask ?? Task.CompletedTask); + (Task?)_resetActionTask ?? Task.CompletedTask, _threadActionTask ?? Task.CompletedTask); } _timer.Stop(); diff --git a/CodexUsageDock/Pages/ClaudeUsagePage.cs b/CodexUsageDock/Pages/ClaudeUsagePage.cs deleted file mode 100644 index c7b5b81..0000000 --- a/CodexUsageDock/Pages/ClaudeUsagePage.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System.Globalization; -using System.Text; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace CodexUsageDock; - -internal sealed partial class ClaudeUsagePage : ContentPage, IDisposable -{ - private readonly CodexUsageService _service; - private readonly object _gate = new(); - private MarkdownContent _content = new(string.Empty); - private bool _disposed; - internal ClaudeUsagePage(CodexUsageService service) - { - _service = service; - Id = "nl.mathijs.codexusage.claude"; - Name = "Open"; - Title = "Claude usage pilot"; - Icon = new IconInfo("\uE943"); - service.ClaudeUpdated += OnUpdated; - Refresh(); - } - - public override IContent[] GetContent() { lock (_gate) { return [_content]; } } - - internal static string Format(ClaudeUsageSnapshot snapshot) - { - var body = new StringBuilder("# Claude usage pilot\n\n").Append(snapshot.Message).Append("\n\n") - .Append("These independent Claude quotas come from your explicitly selected local statusline capture. ") - .Append("The bridge does not verify the Claude account and its percentages are never added to Codex usage.\n\n"); - if (snapshot.ObservedAt != DateTimeOffset.MinValue) - body.Append("Observed UTC: ").Append(snapshot.ObservedAt.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)) - .Append(". Status: ").Append(snapshot.Status).Append(".\n\n"); - body.Append("| Claude window | Remaining at observation | Reset UTC |\n| --- | ---: | --- |\n"); - AppendWindow(body, "Five-hour", snapshot.Primary); - AppendWindow(body, "Seven-day", snapshot.Weekly); - return body.Append("\nSetup: use the optional capture script described in the repository README, select its output file in settings, ") - .Append("then enable the pilot. The extension does not change Claude configuration or an existing statusline. ") - .Append("Missing or expired windows remain unavailable until Claude emits a new capture.").ToString(); - } - - private static void AppendWindow(StringBuilder body, string name, ClaudeUsageWindow? window) - { - body.Append("| ").Append(name).Append(" | ") - .Append(window is null ? "Not reported" : window.RemainingPercent.ToString("0.#", CultureInfo.InvariantCulture) + "%") - .Append(" | ").Append(window?.ResetsAt.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) ?? "Not reported").Append(" |\n"); - } - - private void Refresh() - { - lock (_gate) - { - if (_disposed) return; - var body = Format(_service.GetClaudeUsage()); - _content = new MarkdownContent(body); - Commands = [new CommandContextItem(new RefreshUsageCommand(_service)) { Title = "Refresh captures" }, - new CommandContextItem(new CopyTextCommand(body)) { Title = "Copy Claude usage" }]; - } - RaiseItemsChanged(0); - } - private void OnUpdated(object? sender, EventArgs args) => Refresh(); - public void Dispose() { lock (_gate) { _disposed = true; _service.ClaudeUpdated -= OnUpdated; } } -} diff --git a/CodexUsageDock/Pages/CodexPlanningPage.cs b/CodexUsageDock/Pages/CodexPlanningPage.cs deleted file mode 100644 index 1429952..0000000 --- a/CodexUsageDock/Pages/CodexPlanningPage.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System.Globalization; -using System.Text; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace CodexUsageDock; - -internal sealed partial class CodexPlanningPage : ContentPage, IDisposable -{ - private readonly CodexUsageService _service; - private readonly CodexUsageDockSettingsPage _settings; - private readonly Func _clock; - private readonly object _gate = new(); - private MarkdownContent _content = new(string.Empty); - private bool _disposed; - - internal CodexPlanningPage(CodexUsageService service, CodexUsageDockSettingsPage settings, Func? clock = null) - { - _service = service; - _settings = settings; - _clock = clock ?? (() => DateTimeOffset.Now); - Id = "nl.mathijs.codexusage.planner"; - Name = "Open"; - Title = "Codex workday planner"; - Icon = new IconInfo("\uE787"); - service.Updated += OnUpdated; - Refresh(); - } - - public override IContent[] GetContent() { lock (_gate) { return [_content]; } } - - internal void Refresh() - { - lock (_gate) - { - if (_disposed) return; - var now = _clock(); - var localEnd = now.LocalDateTime.Date.Add(_settings.WorkdayEnd.ToTimeSpan()); - var body = TimeZoneInfo.Local.IsInvalidTime(localEnd) - ? "The selected workday end does not exist in today's local time zone. Choose another time in settings." - : FormatPlan(UsagePlanner.Plan(_service.GetPresentation(), now, _service.RefreshInterval, - new DateTimeOffset(localEnd, TimeZoneInfo.Local.GetUtcOffset(localEnd)), _settings.RemainingWorkdays)); - _content = new MarkdownContent(body); - Commands = [new CommandContextItem(new RefreshUsageCommand(_service)) { Title = "Refresh usage" }, - new CommandContextItem(_settings) { Title = "Change planning assumptions" }, - new CommandContextItem(new CopyTextCommand(body)) { Title = "Copy plan" }]; - } - RaiseItemsChanged(0); - } - - internal static string FormatPlan(UsagePlanningResult plan) - { - var text = new StringBuilder("# Workday planner\n\n").Append(plan.Status).Append("\n\n"); - text.Append("Target today: ").Append(plan.DesiredEnd.ToString("yyyy-MM-dd HH:mm zzz", CultureInfo.InvariantCulture)) - .Append(". Remaining workdays before weekly reset: ").Append(plan.RequestedWorkdays?.ToString(CultureInfo.InvariantCulture) ?? "1") - .Append(". Change these assumptions in settings.\n\n"); - foreach (var window in plan.Windows) - { - text.Append("## ").Append(window.Label).Append(" allowance\n\n") - .Append(window.RemainingPercent.ToString("0.#", CultureInfo.InvariantCulture)).Append("% remaining; budget ") - .Append(window.AvailablePointsPerWorkday.ToString("0.#", CultureInfo.InvariantCulture)).Append(" quota percentage points per workday, or ") - .Append(window.AvailablePointsPerHour.ToString("0.#", CultureInfo.InvariantCulture)).Append(" points per hour today.\n\n") - .Append(window.HorizonMessage).Append("\n\n") - .Append("**Recent pace:** ").Append(window.Forecast.Status).Append("\n\n") - .Append("**Evidence:** ").Append(window.Evidence.Summary).Append("\n\n") - .Append("**Last observation check:** ").Append(window.Backtest.Status).Append("\n\n"); - if (window.Backtest.AbsoluteErrorPercentagePoints is { } error) - text.Append("Held-out prediction error: ").Append(error.ToString("0.##", CultureInfo.InvariantCulture)) - .Append(" percentage points. One held-out observation does not establish long-term accuracy.\n\n"); - } - return text.Append("Quota points are not tokens, money, or a guaranteed number of tasks. Both windows apply independently. ") - .Append("This planner uses recent pace; the dashboard may additionally use its optional learned weekly pattern.").ToString(); - } - - private void OnUpdated(object? sender, EventArgs args) => Refresh(); - public void Dispose() { lock (_gate) { _disposed = true; _service.Updated -= OnUpdated; } } -} diff --git a/CodexUsageDock/Pages/CodexProfilesPage.cs b/CodexUsageDock/Pages/CodexProfilesPage.cs deleted file mode 100644 index b89b26e..0000000 --- a/CodexUsageDock/Pages/CodexProfilesPage.cs +++ /dev/null @@ -1,356 +0,0 @@ -using System.Text.Json; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; -using Microsoft.CmdPal.Common.Commands; - -namespace CodexUsageDock; - -internal sealed class CodexProfileSelectedEventArgs : EventArgs -{ - internal CodexProfileSelectedEventArgs(string name, CodexSourceOptions options) - { - Name = name; - Options = options; - } - - internal string Name { get; } - - internal CodexSourceOptions Options { get; } -} - -internal sealed partial class CodexProfilesPage : ListPage, IDisposable -{ - private readonly object _gate = new(); - private readonly CodexProfileStore _store; - private readonly NewProfileFormPage _newProfilePage; - private bool _disposed; - - internal CodexProfilesPage(CodexProfileStore store) - { - _store = store; - _newProfilePage = new NewProfileFormPage(store, RefreshItems); - Id = "nl.mathijs.codexusage.profiles"; - Name = "Open"; - Title = "Codex source profiles"; - Icon = new IconInfo("\uE77B"); - PlaceholderText = "Search saved profiles"; - } - - internal event EventHandler? ProfileSelected; - - public override IListItem[] GetItems() - { - lock (_gate) - { - if (_disposed) - { - return []; - } - } - - var items = new List(); - if (_store.StorageError is { Length: > 0 }) - { - items.Add(new ListItem(new NoOpCommand()) - { - Title = "Saved profiles unavailable", - Subtitle = "Saved profiles could not be read. Saving a new profile will replace the unreadable file.", - Icon = new IconInfo("\uE783"), - }); - } - - items.Add(new ListItem(_newProfilePage) - { - Title = "Add or replace profile", - Subtitle = "Enter a name and optional Codex source paths", - Icon = new IconInfo("\uE710"), - TextToSuggest = "Add or replace profile", - }); - - foreach (var profile in _store.Profiles) - { - var id = profile.Id; - var deleteCommand = new AnonymousCommand(() => RemoveProfile(id)) - { - Name = "Delete profile", - Id = $"nl.mathijs.codexusage.profile.delete.{id:N}", - Result = CommandResult.KeepOpen(), - }; - var deleteConfirmation = new ConfirmableCommand( - deleteCommand, - "Delete this Codex profile?", - "This removes the saved profile only. It does not change the active source or Codex configuration.", - () => true) - { - Name = "Delete profile", - Id = $"nl.mathijs.codexusage.profile.confirm-delete.{id:N}", - }; - - items.Add(new ListItem(new UseProfileCommand(this, id)) - { - Title = profile.DisplayName, - Subtitle = DescribeSource(profile.SourceOptions), - TextToSuggest = profile.DisplayName, - MoreCommands = - [ - new CommandContextItem(deleteConfirmation) - { - Title = "Delete profile", - Icon = new IconInfo("\uE74D"), - }, - ], - }); - } - - return [.. items]; - } - - private CommandResult UseProfile(Guid id) - { - if (!_store.TryGet(id, out var profile) || profile is null) - { - return CommandResult.ShowToast("This Codex profile is no longer available."); - } - - // Store loading keeps structurally valid offline WSL/network paths. Use - // validates availability at activation so a stale profile fails closed. - if (!CodexSourceOptions.TryCreate( - profile.SourceOptions.ExecutablePath, - profile.SourceOptions.HomePath, - out var options, - out _)) - { - return CommandResult.ShowToast("The saved Codex source path is invalid or unavailable."); - } - - ProfileSelected?.Invoke(this, new CodexProfileSelectedEventArgs(profile.DisplayName, options)); - return CommandResult.KeepOpen(); - } - - private void RemoveProfile(Guid id) - { - _store.TryRemove(id, out _); - RefreshItems(); - } - - private void RefreshItems() - { - lock (_gate) - { - if (_disposed) - { - return; - } - } - - RaiseItemsChanged(0); - } - - private static string DescribeSource(CodexSourceOptions options) - { - if (options.ExecutablePath is null && options.HomePath is null) - { - return "Uses automatic Codex source discovery"; - } - - if (options.ExecutablePath is not null && options.HomePath is not null) - { - return "Custom executable and Codex home"; - } - - return options.ExecutablePath is not null ? "Custom executable" : "Custom Codex home"; - } - - private sealed partial class UseProfileCommand : InvokableCommand - { - private readonly CodexProfilesPage _owner; - private readonly Guid _id; - - internal UseProfileCommand(CodexProfilesPage owner, Guid id) - { - _owner = owner; - _id = id; - Id = $"nl.mathijs.codexusage.profile.use.{id:N}"; - } - - public override string Name => "Use profile"; - - public override ICommandResult Invoke() => _owner.UseProfile(_id); - } - - public void Dispose() - { - lock (_gate) - { - if (_disposed) - { - return; - } - - _disposed = true; - } - - _newProfilePage.Dispose(); - GC.SuppressFinalize(this); - } -} - -internal sealed partial class NewProfileFormPage : ContentPage, IDisposable -{ - private readonly object _gate = new(); - private readonly ProfileFormContent _form; - private MarkdownContent _message; - private bool _disposed; - - internal NewProfileFormPage(CodexProfileStore store, Action? saved = null) - { - _form = new ProfileFormContent(store, HandleSubmit); - _message = new MarkdownContent("# Add or replace a Codex profile\n\nSave a named source for later use. Paths are checked before they are saved."); - Id = "nl.mathijs.codexusage.profile.new"; - Name = "Open"; - Title = "Add or replace Codex profile"; - Icon = new IconInfo("\uE710"); - Saved = saved; - } - - private Action? Saved { get; } - - public override IContent[] GetContent() - { - lock (_gate) - { - return _disposed ? [] : [_message, _form]; - } - } - - private CommandResult HandleSubmit( - string? displayName, - string? executablePath, - string? homePath) - { - if (_disposed) - { - return CommandResult.KeepOpen(); - } - - if (!_form.Store.TryUpsert(displayName, executablePath, homePath, out _, out var error)) - { - SetMessage($"# Add or replace a Codex profile\n\n**Could not save the profile:** {UsageText.EscapeMarkdown(error ?? "The profile is invalid.")}"); - return CommandResult.KeepOpen(); - } - - Saved?.Invoke(); - SetMessage("# Profile saved\n\nThe profile is ready to select from the list."); - return CommandResult.GoBack(); - } - - private void SetMessage(string message) - { - lock (_gate) - { - if (_disposed) - { - return; - } - - _message = new MarkdownContent(message); - } - - RaiseItemsChanged(0); - } - - public void Dispose() - { - lock (_gate) - { - if (_disposed) - { - return; - } - - _disposed = true; - } - - GC.SuppressFinalize(this); - } - - private sealed partial class ProfileFormContent : FormContent - { - private const string InvalidPathValue = ""; - private readonly Func _submit; - - internal ProfileFormContent(CodexProfileStore store, Func submit) - { - Store = store; - _submit = submit; - TemplateJson = """ - {"type":"AdaptiveCard","version":"1.5","body":[ - {"type":"Input.Text","id":"name","label":"Profile name","placeholder":"Work","maxLength":40,"isRequired":true}, - {"type":"Input.Text","id":"executablePath","label":"Codex executable path (optional)","placeholder":"C:\\Path\\to\\codex.exe","maxLength":1024}, - {"type":"Input.Text","id":"homePath","label":"Codex home path (optional)","placeholder":"C:\\Users\\you\\.codex","maxLength":1024}, - {"type":"TextBlock","text":"Use a full Windows path. A Windows-accessible WSL directory is allowed when available to Windows; this extension does not launch WSL or modify Codex configuration.","wrap":true} - ],"actions":[{"type":"Action.Submit","title":"Save profile"}]} - """; - } - - internal CodexProfileStore Store { get; } - - public override CommandResult SubmitForm(string payload) - { - if (string.IsNullOrWhiteSpace(payload) || payload.Length > 8192) - { - return _submit(null, null, null); - } - - try - { - using var document = JsonDocument.Parse(payload, new JsonDocumentOptions { MaxDepth = 8 }); - if (document.RootElement.ValueKind != JsonValueKind.Object) - { - return _submit(null, null, null); - } - - if (!TryReadOptionalString(document.RootElement, "executablePath", out var executablePath) - || !TryReadOptionalString(document.RootElement, "homePath", out var homePath)) - { - // A malformed optional path must not silently select the - // default source. The marker is deliberately invalid and - // never leaves this form or reaches storage. - return _submit(ReadString(document.RootElement, "name"), InvalidPathValue, null); - } - - return _submit( - ReadString(document.RootElement, "name"), - executablePath, - homePath); - } - catch (JsonException) - { - return _submit(null, null, null); - } - } - - private static bool TryReadOptionalString(JsonElement root, string propertyName, out string? value) - { - value = null; - if (!root.TryGetProperty(propertyName, out var property) - || property.ValueKind == JsonValueKind.Null) - { - return true; - } - - if (property.ValueKind != JsonValueKind.String) - { - return false; - } - - value = property.GetString(); - return true; - } - - private static string? ReadString(JsonElement root, string propertyName) => - root.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String - ? value.GetString() - : null; - } -} diff --git a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs index 6ef4aae..6a0f6e7 100644 --- a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs +++ b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs @@ -8,7 +8,6 @@ namespace CodexUsageDock; internal sealed partial class CodexUsageDockSettingsPage : ContentPage { - private const string InvalidSourcePath = "Invalid source path: re-enter or clear this field"; private const string ShowFiveHourLimitKey = "showFiveHourLimit"; private const string ShowWeeklyLimitKey = "showWeeklyLimit"; private const string ShowResetsAndCreditsKey = "showResetsAndCredits"; @@ -19,15 +18,7 @@ internal sealed partial class CodexUsageDockSettingsPage : ContentPage private const string CompactDockKey = "compactDock"; private const string SeparateDockItemsKey = "separateDockItems"; private const string ShowAccountActivityKey = "showAccountActivity"; - private const string CodexExecutablePathKey = "codexExecutablePath"; - private const string CodexHomePathKey = "codexHomePath"; - private const int MaximumPathLength = 1024; private const string HistoryRetentionKey = "historyRetentionDays"; - private const string WorkdayEndKey = "workdayEnd"; - private const string RemainingWorkdaysKey = "remainingWorkdays"; - private const string SourceLabelKey = "sourceLabel"; - private const string EnableClaudeKey = "enableClaude"; - private const string ClaudeBridgePathKey = "claudeBridgePath"; private readonly Settings _settings = new(); private readonly string _path; private readonly FormContent _statusContent = new() @@ -87,44 +78,13 @@ internal CodexUsageDockSettingsPage(string path) _settings.Add(new ToggleSetting(SeparateDockItemsKey, false) { Label = "Separate Dock items", - Description = "Show each usage item as its own Dock entry.", + Description = "Offer separate metric bands instead of the combined band. Other-mode pins are hidden. After switching, add the desired bands through Dock customization if needed.", }); _settings.Add(new ToggleSetting(ShowAccountActivityKey, true) { Label = "Show account activity", Description = "Read account-wide daily tokens from the Codex service after quotas load. Older CLI versions may not support this.", }); - _settings.Add(new TextSetting(CodexExecutablePathKey, string.Empty) - { - Label = "Codex executable path", - Description = "Optional explicit path to codex.exe or codex.cmd.", - Placeholder = @"C:\Path\to\codex.exe", - Multiline = false, - }); - _settings.Add(new TextSetting(CodexHomePathKey, string.Empty) - { - Label = "Codex home path", - Description = "Optional Codex home directory. It may be a Windows-accessible WSL directory; this extension does not launch WSL or modify Codex configuration.", - Placeholder = @"C:\Users\you\.codex", - Multiline = false, - }); - _settings.Add(new TextSetting(SourceLabelKey, "Default") - { - Label = "Source label", - Description = "An optional name for these source paths. A label does not verify the signed-in account.", - Multiline = false, - }); - _settings.Add(new ToggleSetting(EnableClaudeKey, false) - { - Label = "Enable Claude usage pilot", - Description = "Read only the local quota snapshot produced by your optional Claude statusline bridge.", - }); - _settings.Add(new TextSetting(ClaudeBridgePathKey, string.Empty) - { - Label = "Claude bridge file", - Description = "The full path to your bridge JSON file. Configure the optional statusline bridge before enabling this pilot.", - Multiline = false, - }); _settings.Add(new ChoiceSetSetting( RefreshIntervalKey, [ @@ -142,19 +102,6 @@ internal CodexUsageDockSettingsPage(string path) Label = "Retain usage observations", Description = "Optional local quota history for export. Pausing keeps saved data; use History to delete it.", }); - _settings.Add(new TextSetting(WorkdayEndKey, "17:00") - { - Label = "Workday end (HH:mm)", - Description = "Local time used by the planner for today. After this time, planning pauses until the next day.", - Multiline = false, - }); - _settings.Add(new ChoiceSetSetting(RemainingWorkdaysKey, - [new("1 workday", "1"), new("2 workdays", "2"), new("3 workdays", "3"), new("4 workdays", "4"), - new("5 workdays", "5"), new("6 workdays", "6"), new("7 workdays", "7")]) - { - Label = "Workdays remaining before weekly reset", - Description = "Your planning assumption, including today. The extension does not infer your calendar.", - }); var clearHistory = new ConfirmableCommand( new AnonymousCommand(() => ClearAdaptiveHistoryRequested?.Invoke(this, EventArgs.Empty)) { @@ -199,34 +146,10 @@ internal CodexUsageDockSettingsPage(string path) public bool ShowAccountActivity => _settings.GetSetting(ShowAccountActivityKey); - public string CodexExecutablePath => GetPathSetting(CodexExecutablePathKey); - - public string CodexHomePath => GetPathSetting(CodexHomePathKey); - - internal string SourceLabel => UsageText.SanitizeExternal(_settings.GetSetting(SourceLabelKey), 40) ?? "Default"; - internal bool EnableClaude => _settings.GetSetting(EnableClaudeKey); - internal string ClaudeBridgePath => GetPathSetting(ClaudeBridgePathKey); - internal string ProfileStoragePath => Path.Combine(Path.GetDirectoryName(_path)!, "profiles.json"); - - internal void ApplySourceProfile(string label, CodexSourceOptions options) - { - _settings.Update(new JsonObject - { - [SourceLabelKey] = UsageText.SanitizeExternal(label, 40) ?? "Custom", - [CodexExecutablePathKey] = options.ExecutablePath ?? string.Empty, - [CodexHomePathKey] = options.HomePath ?? string.Empty, - }.ToJsonString()); - OnSettingsChanged(_settings, _settings); - } - public TimeSpan RefreshInterval => ParseRefreshInterval(_settings.GetSetting(RefreshIntervalKey)); internal int HistoryRetentionDays => _settings.GetSetting(HistoryRetentionKey) switch { "7" => 7, "30" => 30, "90" => 90, _ => 0 }; - internal TimeOnly WorkdayEnd => TimeOnly.TryParseExact(_settings.GetSetting(WorkdayEndKey), "HH:mm", - System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var time) ? time : new(17, 0); - internal int RemainingWorkdays => int.TryParse(_settings.GetSetting(RemainingWorkdaysKey), out var days) - && days is >= 1 and <= 7 ? days : 1; internal string? StatusMessage { get; private set; } @@ -266,12 +189,6 @@ private void Load() var valid = new JsonObject(); foreach (var property in document.RootElement.EnumerateObject()) { - if (property.Name is CodexExecutablePathKey or CodexHomePathKey or ClaudeBridgePathKey) - { - valid[property.Name] = property.Value.ValueKind == JsonValueKind.String && IsValidPathSetting(property.Value.GetString()) - ? property.Value.GetString() : InvalidSourcePath; - continue; - } if (property.Value.ValueKind is JsonValueKind.True or JsonValueKind.False && IsBooleanSetting(property.Name)) { @@ -285,18 +202,11 @@ private void Load() } var value = property.Value.GetString(); - if (property.Name == SourceLabelKey) - { - valid[property.Name] = UsageText.SanitizeExternal(value, 40) ?? "Default"; - } - else if (property.Name == RefreshIntervalKey && value is "1" or "5" or "15") + if (property.Name == RefreshIntervalKey && value is "1" or "5" or "15") { valid[property.Name] = value; } - else if (property.Name == HistoryRetentionKey && value is "0" or "7" or "30" or "90" - || property.Name == RemainingWorkdaysKey && value is "1" or "2" or "3" or "4" or "5" or "6" or "7" - || property.Name == WorkdayEndKey && TimeOnly.TryParseExact(value, "HH:mm", - System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out _)) + else if (property.Name == HistoryRetentionKey && value is "0" or "7" or "30" or "90") { valid[property.Name] = value; } @@ -318,31 +228,7 @@ private void Load() private static bool IsBooleanSetting(string name) => name is ShowFiveHourLimitKey or ShowWeeklyLimitKey or ShowResetsAndCreditsKey or ShowResetTimeKey or UseAdaptiveWeeklyForecastKey or EnableUsageAlertsKey or CompactDockKey or SeparateDockItemsKey or - ShowAccountActivityKey or EnableClaudeKey; - - private static bool IsValidPathSetting(string? value) - { - if (value is null || value.Length > MaximumPathLength) - { - return false; - } - - foreach (var character in value) - { - if (char.IsControl(character)) - { - return false; - } - } - - return true; - } - - private string GetPathSetting(string key) - { - var value = _settings.GetSetting(key); - return IsValidPathSetting(value) ? value! : InvalidSourcePath; - } + ShowAccountActivityKey; private void OnSettingsChanged(object sender, Settings args) { diff --git a/CodexUsageDock/UsageDockBand.cs b/CodexUsageDock/UsageDockBand.cs new file mode 100644 index 0000000..867d57d --- /dev/null +++ b/CodexUsageDock/UsageDockBand.cs @@ -0,0 +1,48 @@ +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace CodexUsageDock; + +internal sealed partial class UsageDockBand : CommandItem +{ + private readonly DockBandPage _page; + + internal UsageDockBand(string id, string title) : this(new DockBandPage(id, title)) { } + + private UsageDockBand(DockBandPage page) : base(page) => _page = page; + + internal bool HasItems => _page.HasItems; + internal bool PublishItems(IListItem[] items) => _page.PublishItems(items); + internal void NotifyItemsChanged() => _page.NotifyItemsChanged(); + + private sealed partial class DockBandPage : ListPage + { + private IListItem[] _items = []; + + internal DockBandPage(string id, string title) + { + Id = id; + Name = title; + Title = title; + } + + internal bool HasItems => Volatile.Read(ref _items).Length > 0; + public override IListItem[] GetItems() => [.. Volatile.Read(ref _items)]; + + // The host synchronously calls GetItems from ItemsChanged. Publish the + // complete layout before notifying, including newly inactive bands. + internal bool PublishItems(IListItem[] items) + { + if (Volatile.Read(ref _items).SequenceEqual(items)) return false; + Volatile.Write(ref _items, items); + return true; + } + + internal void NotifyItemsChanged() + { + var items = Volatile.Read(ref _items); + foreach (var item in items.OfType()) item.NotifyDisplayPropertiesChanged(); + RaiseItemsChanged(Volatile.Read(ref _items).Length); + } + } +} diff --git a/CodexUsageDock/UsageDockItem.cs b/CodexUsageDock/UsageDockItem.cs index 9a03271..2c02dd4 100644 --- a/CodexUsageDock/UsageDockItem.cs +++ b/CodexUsageDock/UsageDockItem.cs @@ -10,7 +10,7 @@ internal enum UsageDockItemKind ResetsAndCredits, } -internal sealed partial class UsageDockItem : ListItem, IDisposable +internal sealed partial class UsageDockItem : UsageDockListItem, IDisposable { private readonly CodexUsageService _usage; private readonly UsageDockItemKind _kind; @@ -43,8 +43,10 @@ private void UpdateText() if (_kind == UsageDockItemKind.ResetsAndCredits) { Title = FormatResetsAndCredits(snapshot); - Subtitle = CombineStatusAndDetail( - FormatSourceFreshness(snapshot, now, _usage.RefreshInterval), + Subtitle = FormatLiveDetailOrStatus( + snapshot, + now, + _usage.RefreshInterval, _settings?.CompactDock == true ? string.Empty : FormatResetExpiry(snapshot.ResetCredits, now)); Icon = new IconInfo("\uE777"); return; @@ -74,8 +76,14 @@ private void UpdateText() } Title = FormatQuotaTitle(_kind, window.RemainingPercent, compact); - var reset = compact || _settings?.ShowResetTime == false ? string.Empty : $"reset {FormatReset(window.ResetsAt)}"; - Subtitle = CombineStatusAndDetail(FormatSourceFreshness(snapshot, now, _usage.RefreshInterval), reset); + var reset = compact || _settings?.ShowResetTime == false + ? string.Empty + : $"Reset - {FormatLocalDateTime(window.ResetsAt.ToLocalTime(), CultureInfo.CurrentCulture)}"; + Subtitle = FormatLiveDetailOrStatus( + snapshot, + now, + _usage.RefreshInterval, + reset); Icon = new IconInfo(window.RemainingPercent <= 10 ? "\uE7BA" : "\uE916"); } @@ -189,6 +197,31 @@ private static string FormatAge(DateTimeOffset timestamp, DateTimeOffset now) private static string FormatLocalTime(DateTimeOffset value) => value.ToLocalTime().ToString("HH:mm", CultureInfo.CurrentCulture); + internal static string FormatLocalDateTime(DateTimeOffset local, CultureInfo culture) + { + var month = local.ToString("MMM", culture).TrimEnd('.'); + // Windows globalization data can abbreviate Dutch September as "sep". + if (culture.TwoLetterISOLanguageName == "nl" && local.Month == 9) month = "sept"; + return $"{local.ToString("%d", culture)} {month} {local.ToString("H:mm", culture)}"; + } + + internal static string FormatLiveDetailOrStatus( + CodexUsageSnapshot snapshot, + DateTimeOffset now, + TimeSpan refreshInterval, + string detail) + { + var state = UsageFreshness.Classify( + snapshot.UpdatedAt, + now, + refreshInterval); + return snapshot.Source == UsageDataSource.AppServer + && state == UsageFreshnessState.Fresh + && detail.Length > 0 + ? detail + : CombineStatusAndDetail(FormatSourceFreshness(snapshot, now, refreshInterval), detail); + } + private static string CombineStatusAndDetail(string status, string detail) => detail.Length == 0 ? status : $"{status} · {detail}"; @@ -220,13 +253,10 @@ internal static string FormatResetExpiry(RateLimitResetCredits? resets, DateTime if (nextExpiry is not { } expiry) { - return "expiration unavailable"; + return "Expires - unavailable"; } - var remaining = expiry - now; - return remaining < TimeSpan.FromHours(24) - ? $"expires in {(int)Math.Ceiling(remaining.TotalHours)} hours" - : $"expires in {(int)Math.Ceiling(remaining.TotalDays)} days"; + return $"Expires - {FormatLocalDateTime(expiry.ToLocalTime(), CultureInfo.CurrentCulture)}"; } internal static (string Title, string Subtitle) FormatUnavailable(UsageDockItemKind kind, bool compact = false) => @@ -237,14 +267,6 @@ internal static (string Title, string Subtitle) FormatUnavailable(UsageDockItemK _ => "-- resets", }, "Codex usage unavailable"); - private static string FormatReset(DateTimeOffset reset) - { - var local = reset.ToLocalTime(); - return local.Date == DateTime.Today - ? local.ToString("HH:mm", CultureInfo.CurrentCulture) - : local.ToString("ddd HH:mm", CultureInfo.CurrentCulture); - } - public void Dispose() { _usage.Updated -= OnUpdated; diff --git a/CodexUsageDock/UsageDockListItem.cs b/CodexUsageDock/UsageDockListItem.cs new file mode 100644 index 0000000..9771c52 --- /dev/null +++ b/CodexUsageDock/UsageDockListItem.cs @@ -0,0 +1,16 @@ +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace CodexUsageDock; + +internal partial class UsageDockListItem(ICommand command) : ListItem(command) +{ + internal void NotifyDisplayPropertiesChanged() + { + // A host can subscribe after reading the initial values and miss an + // intervening update. Refresh even unchanged values on the next read. + OnPropertyChanged(nameof(Title)); + OnPropertyChanged(nameof(Subtitle)); + OnPropertyChanged(nameof(Icon)); + } +} diff --git a/CodexUsageDock/UsagePlanning.cs b/CodexUsageDock/UsagePlanning.cs deleted file mode 100644 index 70fad83..0000000 --- a/CodexUsageDock/UsagePlanning.cs +++ /dev/null @@ -1,600 +0,0 @@ -using System.Globalization; - -namespace CodexUsageDock; - -internal static class UsagePlanner -{ - internal const string Disclaimer = "Planning guidance only; it is not a guarantee."; - - internal static UsagePlanningResult Plan( - UsagePresentation presentation, - DateTimeOffset now, - TimeSpan refreshInterval, - DateTimeOffset desiredEnd, - int? remainingWorkdays = null, - TimeZoneInfo? timeZone = null) - { - ArgumentNullException.ThrowIfNull(presentation); - - if (desiredEnd <= now) - { - return Unavailable(desiredEnd, "Planning unavailable: the desired end must be after now.", remainingWorkdays); - } - - if (remainingWorkdays is < 1 or > 7) - { - return Unavailable( - desiredEnd, - "Planning unavailable: remaining workdays must be between 1 and 7.", - remainingWorkdays); - } - - var snapshot = presentation.Usage; - if (presentation.IsLoading) - { - return Unavailable(desiredEnd, "Planning paused while fresh usage data is loading.", remainingWorkdays); - } - - if (snapshot.Source != UsageDataSource.AppServer) - { - var sourceMessage = snapshot.Source switch - { - UsageDataSource.LastConfirmed => "Planning paused: only last confirmed usage is available; refresh live usage first.", - UsageDataSource.Unavailable => "Planning paused: live usage is unavailable.", - UsageDataSource.LocalSession => "Planning paused: local session usage is not a verified live app-server measurement.", - _ => "Planning paused until fresh live app-server usage is available.", - }; - return Unavailable(desiredEnd, sourceMessage, remainingWorkdays); - } - - if (string.IsNullOrWhiteSpace(snapshot.AccountKey)) - { - return Unavailable(desiredEnd, "Planning paused: the verified account identity is unavailable.", remainingWorkdays); - } - - var freshness = UsageFreshness.Classify(snapshot.UpdatedAt, now, refreshInterval); - if (freshness != UsageFreshnessState.Fresh) - { - var freshnessMessage = freshness switch - { - UsageFreshnessState.Stale => "Planning paused: usage data is stale; refresh before planning.", - UsageFreshnessState.Future => "Planning paused: the usage timestamp is in the future.", - UsageFreshnessState.Unknown => "Planning paused: usage freshness is unknown.", - _ => "Planning paused until fresh usage data is available.", - }; - return Unavailable(desiredEnd, freshnessMessage, remainingWorkdays); - } - - if (snapshot.OrdinaryUsageAllowed == false) - { - return Unavailable(desiredEnd, "Planning paused: ordinary usage is currently blocked.", remainingWorkdays); - } - - try - { - var maximumSampleAge = UsageFreshness.MaximumAge(refreshInterval); - var primary = CreateWindowPlan( - snapshot.Primary, - presentation.PrimaryHistory, - now, - desiredEnd, - remainingWorkdays, - maximumSampleAge, - adaptiveCycleCount: 0, - isPrimary: true, - timeZone ?? TimeZoneInfo.Local); - var weekly = CreateWindowPlan( - snapshot.Secondary, - presentation.WeeklyHistory, - now, - desiredEnd, - remainingWorkdays, - maximumSampleAge, - CountAdaptiveCycles(presentation), - isPrimary: false, - timeZone ?? TimeZoneInfo.Local); - - if (primary is null && weekly is null) - { - return Unavailable( - desiredEnd, - "Planning paused: no valid 5-hour or weekly allowance window is available.", - remainingWorkdays); - } - - var windows = new[] { primary, weekly }.Where(window => window is not null).Cast().ToArray(); - var status = windows.Length == 2 - ? $"Planning available for both allowance windows. {Disclaimer}" - : $"Planning available for one allowance window. {Disclaimer}"; - return new UsagePlanningResult( - true, - status, - desiredEnd, - remainingWorkdays, - primary, - weekly, - Disclaimer); - } - catch (ArgumentOutOfRangeException) - { - return Unavailable(desiredEnd, "Planning paused: the usage window timestamps are out of range.", remainingWorkdays); - } - catch (OverflowException) - { - return Unavailable(desiredEnd, "Planning paused: the usage window duration is out of range.", remainingWorkdays); - } - } - - private static UsagePlanningWindow? CreateWindowPlan( - RateLimitWindow? window, - IReadOnlyList history, - DateTimeOffset now, - DateTimeOffset desiredEnd, - int? requestedWorkdays, - TimeSpan maximumSampleAge, - int adaptiveCycleCount, - bool isPrimary, - TimeZoneInfo timeZone) - { - if (!UsageFreshness.IsValidWindow(window, now)) - { - return null; - } - - var validWindow = window!; - var horizonEnd = validWindow.ResetsAt < desiredEnd ? validWindow.ResetsAt : desiredEnd; - var horizon = horizonEnd - now; - if (horizon <= TimeSpan.Zero) - { - return null; - } - - var workdays = isPrimary - ? 1 - : Math.Max(1, Math.Min(requestedWorkdays ?? 1, CalendarDaysUntilReset(validWindow.ResetsAt, now, timeZone))); - var remaining = validWindow.RemainingPercent; - var pointsPerWorkday = remaining / workdays; - var pointsPerHour = pointsPerWorkday / horizon.TotalHours; - var displayLabel = FormatWindowDuration(validWindow.WindowMinutes); - var resetsBeforeDesiredEnd = validWindow.ResetsAt < desiredEnd; - var horizonMessage = resetsBeforeDesiredEnd - ? $"This {displayLabel} window resets before the desired end at {FormatUtc(validWindow.ResetsAt)}; planning stops at that reset." - : $"Planning runs until {FormatUtc(horizonEnd)}."; - - var forecast = CreateForecast( - validWindow, - history, - now, - maximumSampleAge); - var evidence = CreateEvidence( - validWindow, - history, - now, - maximumSampleAge, - adaptiveCycleCount); - var backtest = CreateBacktest( - validWindow, - history, - now, - maximumSampleAge); - - return new UsagePlanningWindow( - displayLabel, - remaining, - validWindow.WindowMinutes, - validWindow.ResetsAt, - horizonEnd, - workdays, - horizon, - pointsPerWorkday, - pointsPerHour, - resetsBeforeDesiredEnd, - horizonMessage, - forecast, - evidence, - backtest); - } - - private static UsagePlanningForecast CreateForecast( - RateLimitWindow window, - IReadOnlyList history, - DateTimeOffset now, - TimeSpan maximumSampleAge) - { - if (!TryGetWindowStart(window, out var windowStart)) - { - return UsagePlanningForecast.Unavailable("Forecast unavailable: the window duration is out of range."); - } - - try - { - var analysis = UsageTrendAnalyzer.Analyze( - history ?? Array.Empty(), - windowStart, - window.ResetsAt, - now, - dataAvailable: true, - maximumSampleAge, - adaptiveWeeklyForecastEnabled: false, - adaptiveWeeklyHistory: null); - if (analysis.Forecast is null) - { - return UsagePlanningForecast.Unavailable(analysis.ForecastStatus); - } - - return new UsagePlanningForecast(true, analysis.Forecast, analysis.ForecastStatus); - } - catch (ArgumentException) - { - return UsagePlanningForecast.Unavailable("Forecast unavailable: the measurements are not usable."); - } - catch (InvalidOperationException) - { - return UsagePlanningForecast.Unavailable("Forecast unavailable: the measurements are not usable."); - } - catch (OverflowException) - { - return UsagePlanningForecast.Unavailable("Forecast unavailable: the timestamps are out of range."); - } - } - - private static UsagePlanningEvidence CreateEvidence( - RateLimitWindow window, - IReadOnlyList history, - DateTimeOffset now, - TimeSpan maximumSampleAge, - int adaptiveCycleCount) - { - var samples = GetWindowSamples(history, window, now); - var segment = GetLatestSegment(history, window, now, maximumSampleAge); - var measurementSpan = GetSpan(samples); - var segmentSpan = GetSpan(segment); - var summary = $"{samples.Length.ToString(CultureInfo.InvariantCulture)} measurements over {FormatDuration(measurementSpan)}; " - + $"latest continuous segment: {segment.Length.ToString(CultureInfo.InvariantCulture)} measurements over {FormatDuration(segmentSpan)}; " - + $"adaptive weekly cycles: {adaptiveCycleCount.ToString(CultureInfo.InvariantCulture)}. " - + "This is descriptive evidence, not a calibrated reliability probability."; - return new UsagePlanningEvidence( - samples.Length, - measurementSpan, - segment.Length, - segmentSpan, - adaptiveCycleCount, - summary); - } - - private static UsagePlanningBacktest CreateBacktest( - RateLimitWindow window, - IReadOnlyList history, - DateTimeOffset now, - TimeSpan maximumSampleAge) - { - var segment = GetLatestSegment(history, window, now, maximumSampleAge); - if (segment.Length < 3) - { - return UsagePlanningBacktest.Unavailable( - "Backtest unavailable: at least three continuous measurements are required to hold out the latest one."); - } - - var heldOut = segment[^1]; - var training = segment[..^1]; - var first = training[0]; - var last = training[^1]; - var trainingSpan = last.RecordedAt - first.RecordedAt; - var holdoutInterval = heldOut.RecordedAt - last.RecordedAt; - if (training.Length < 2 - || trainingSpan <= TimeSpan.Zero - || holdoutInterval <= TimeSpan.Zero - || holdoutInterval > MaximumGap(maximumSampleAge)) - { - return UsagePlanningBacktest.Unavailable( - "Backtest unavailable: the held-out measurement does not follow a meaningful continuous pace."); - } - - var consumed = first.RemainingPercent - last.RemainingPercent; - var rate = consumed / trainingSpan.TotalMinutes; - if (!double.IsFinite(rate) || rate <= 0) - { - return UsagePlanningBacktest.Unavailable( - "Backtest unavailable: preceding measurements do not show a usable downward pace."); - } - - var predicted = Math.Clamp( - last.RemainingPercent - rate * holdoutInterval.TotalMinutes, - 0, - 100); - var error = Math.Abs(predicted - heldOut.RemainingPercent); - var status = $"Holdout at {FormatUtc(heldOut.RecordedAt)}: predicted {FormatPercent(predicted)}%, " - + $"actual {FormatPercent(heldOut.RemainingPercent)}%, absolute error {FormatPercent(error)} percentage points; " - + "the latest measurement was excluded from the preceding-pace calculation."; - return new UsagePlanningBacktest( - true, - training.Length, - heldOut.RecordedAt, - predicted, - heldOut.RemainingPercent, - error, - status); - } - - private static UsageHistoryEntry[] GetLatestSegment( - IReadOnlyList history, - RateLimitWindow window, - DateTimeOffset now, - TimeSpan maximumSampleAge) - { - if (!TryGetWindowStart(window, out var windowStart)) - { - return []; - } - - try - { - return UsageTrendHistory.LatestSegment( - history ?? Array.Empty(), - windowStart, - window.ResetsAt, - now, - MaximumGap(maximumSampleAge)); - } - catch (ArgumentException) - { - return []; - } - catch (InvalidOperationException) - { - return []; - } - catch (OverflowException) - { - return []; - } - } - - private static UsageHistoryEntry[] GetWindowSamples( - IReadOnlyList history, - RateLimitWindow window, - DateTimeOffset now) - { - if (!TryGetWindowStart(window, out var windowStart) || history is null) - { - return []; - } - - try - { - return history - .Where(sample => sample is not null - && double.IsFinite(sample.RemainingPercent) - && sample.RemainingPercent is >= 0 and <= 100 - && sample.RecordedAt >= windowStart - && sample.RecordedAt <= window.ResetsAt - && sample.RecordedAt <= now) - .OrderBy(sample => sample.RecordedAt) - .GroupBy(sample => sample.RecordedAt) - .Select(group => group.Last()) - .ToArray(); - } - catch (ArgumentException) - { - return []; - } - catch (InvalidOperationException) - { - return []; - } - catch (OverflowException) - { - return []; - } - } - - private static TimeSpan? GetSpan(UsageHistoryEntry[] samples) - { - if (samples.Length < 2) - { - return null; - } - - try - { - return samples[^1].RecordedAt - samples[0].RecordedAt; - } - catch (ArgumentOutOfRangeException) - { - return null; - } - } - - private static int CountAdaptiveCycles(UsagePresentation presentation) - { - var history = presentation.AdaptiveWeeklyHistory; - if (history is null) - { - return 0; - } - - var completed = history.CompletedCycles?.Count(cycle => cycle is not null) ?? 0; - return completed + (history.ActiveCycle is null ? 0 : 1); - } - - private static int CalendarDaysUntilReset(DateTimeOffset resetsAt, DateTimeOffset now, TimeZoneInfo timeZone) - { - var days = (TimeZoneInfo.ConvertTime(resetsAt, timeZone).Date - TimeZoneInfo.ConvertTime(now, timeZone).Date).Days; - if (days <= 1) - { - return 1; - } - - return days; - } - - private static TimeSpan MaximumGap(TimeSpan maximumSampleAge) - { - if (maximumSampleAge <= TimeSpan.Zero) - { - return TimeSpan.FromMinutes(15); - } - - return maximumSampleAge.Ticks > TimeSpan.MaxValue.Ticks / 3 - ? TimeSpan.MaxValue - : TimeSpan.FromTicks(maximumSampleAge.Ticks * 3); - } - - private static bool TryGetWindowStart(RateLimitWindow window, out DateTimeOffset start) - { - try - { - start = window.ResetsAt - TimeSpan.FromMinutes(window.WindowMinutes); - return true; - } - catch (ArgumentOutOfRangeException) - { - start = default; - return false; - } - catch (OverflowException) - { - start = default; - return false; - } - } - - private static string FormatDuration(TimeSpan? duration) - { - if (duration is not { } value) - { - return "unknown span"; - } - - if (value.TotalMinutes < 1) - { - return $"{Math.Max(1, (int)Math.Round(value.TotalSeconds))} seconds"; - } - - if (value.TotalHours < 1) - { - return $"{Math.Round(value.TotalMinutes, 1).ToString("0.#", CultureInfo.InvariantCulture)} minutes"; - } - - if (value.TotalDays < 1) - { - return $"{Math.Round(value.TotalHours, 1).ToString("0.#", CultureInfo.InvariantCulture)} hours"; - } - - return $"{Math.Round(value.TotalDays, 1).ToString("0.#", CultureInfo.InvariantCulture)} days"; - } - - private static string FormatPercent(double value) => value.ToString("0.##", CultureInfo.InvariantCulture); - - private static string FormatWindowDuration(int minutes) - { - if (minutes == (int)TimeSpan.FromHours(5).TotalMinutes) - { - return "5-hour"; - } - - if (minutes == (int)TimeSpan.FromDays(7).TotalMinutes) - { - return "weekly"; - } - - if (minutes > 0 && minutes % (int)TimeSpan.FromDays(1).TotalMinutes == 0) - { - return $"{minutes / (int)TimeSpan.FromDays(1).TotalMinutes}-day"; - } - - if (minutes > 0 && minutes % 60 == 0) - { - return $"{minutes / 60}-hour"; - } - - return $"{minutes}-minute"; - } - - private static string FormatUtc(DateTimeOffset value) => - value.ToUniversalTime().ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture); - - private static UsagePlanningResult Unavailable( - DateTimeOffset desiredEnd, - string status, - int? remainingWorkdays) => - new(false, status, desiredEnd, remainingWorkdays, null, null, Disclaimer); -} - -internal sealed record UsagePlanningResult( - bool IsAvailable, - string Status, - DateTimeOffset DesiredEnd, - int? RequestedWorkdays, - UsagePlanningWindow? Primary, - UsagePlanningWindow? Weekly, - string Disclaimer) -{ - internal UsagePlanningWindow? FiveHour => Primary; - - internal IReadOnlyList Windows => - new[] { Primary, Weekly }.Where(window => window is not null).Cast().ToArray(); - - internal IReadOnlyList Evidence => - Windows.Select(window => window.Evidence).ToArray(); - - internal IReadOnlyList Backtests => - Windows.Select(window => window.Backtest).ToArray(); -} - -internal sealed record UsagePlanningWindow( - string Label, - double RemainingPercent, - int WindowMinutes, - DateTimeOffset ResetsAt, - DateTimeOffset HorizonEnd, - int Workdays, - TimeSpan Horizon, - double AvailablePointsPerWorkday, - double AvailablePointsPerHour, - bool ResetsBeforeDesiredEnd, - string HorizonMessage, - UsagePlanningForecast Forecast, - UsagePlanningEvidence Evidence, - UsagePlanningBacktest Backtest) -{ - internal double Remaining => RemainingPercent; - internal double QuotaPointsPerWorkday => AvailablePointsPerWorkday; - internal double QuotaPointsPerHour => AvailablePointsPerHour; -} - -internal sealed record UsagePlanningForecast( - bool IsAvailable, - UsageTrendForecast? Projection, - string Status) -{ - internal UsageTrendForecast? Forecast => Projection; - internal DateTimeOffset? EndsAt => Projection?.EndsAt; - internal bool ReachesLimitBeforeReset => Projection?.ReachesLimitBeforeReset ?? false; - - internal static UsagePlanningForecast Unavailable(string status) => new(false, null, status); -} - -internal sealed record UsagePlanningEvidence( - int MeasurementCount, - TimeSpan? MeasurementSpan, - int SegmentMeasurementCount, - TimeSpan? SegmentSpan, - int AdaptiveCycleCount, - string Summary) -{ - internal string Description => Summary; -} - -internal sealed record UsagePlanningBacktest( - bool IsAvailable, - int TrainingSampleCount, - DateTimeOffset? HeldOutAt, - double? PredictedRemainingPercent, - double? ActualRemainingPercent, - double? AbsoluteErrorPercentagePoints, - string Status) -{ - internal string Message => Status; - internal static UsagePlanningBacktest Unavailable(string status) => new(false, 0, null, null, null, null, status); -} diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f780b24..206a746 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -99,6 +99,8 @@ Store install, update, and uninstall behavior must be tested with a Store-signed For settings and storage changes, also restart Command Palette and Windows in the isolated test environment and verify all saved choices, including the first refresh interval. Make the test settings/history file unwritable, verify a visible failure, restore write access, and retry. A failed learned-history deletion must preserve the saved history; a successful deletion must remain cleared after restart. With a large synthetic session directory, verify that limits appear before token analysis completes and that text and chart projections both pause after a measurement gap. +When upgrading from a development build with manual source paths or the Claude pilot, verify that those fields and the source-profile and Claude commands are absent. Old saved values must not prevent automatic Codex detection, and saved Claude Dock pins must not restore a band. Saving another preference must preserve the remaining Codex choices and omit the obsolete settings fields. Existing profile files and external capture scripts or files are not removed by this upgrade. + ## Build the Microsoft Store package The package artwork is generated from one canonical visual mark. Treat `scripts/generate-assets.ps1` as its source instead of editing individual PNG files. The release builder compares decoded artwork with a small rendering tolerance, because PNG encoding and anti-aliasing can differ between supported build hosts without changing the design: diff --git a/PRIVACY.md b/PRIVACY.md index 7549117..244ea7a 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy Policy -Last updated: September 9, 2026 +Last updated: September 13, 2026 Codex Usage Dock is a local Windows extension for PowerToys Command Palette. It displays Codex usage limits, earned resets, reset expiry times, and available credits in the Command Palette Dock. @@ -16,21 +16,21 @@ Communication initiated by the extension is limited to the local Codex app-serve ## Data storage -Settings are saved explicitly in `CodexUsageDock/settings.json` under the current Windows user's local application data directory, alongside the local history files. This file contains display, refresh, planning, retention, forecasting, and explicit source preferences. Failed writes are reported without logging file contents, personal paths, or credentials. Learned-history deletion is confirmed only after its empty state has been saved successfully. +Settings are saved explicitly in `CodexUsageDock/settings.json` under the current Windows user's local application data directory, alongside the local history files. This file contains display, refresh, retention, and forecasting preferences. Failed writes are reported without logging file contents, personal paths, or credentials. Learned-history deletion is confirmed only after its empty state has been saved successfully. The extension does not create an external user account or remote database. Settings and temporary runtime state remain on the user's Windows device. Daily token totals and per-file read positions are kept only in memory and are rebuilt from the current weekly window after a restart. To keep the weekly usage trend available after Command Palette restarts, it stores a rolling maximum of seven days of local timestamps and remaining weekly-percentage measurements. When the adaptive weekly forecast is enabled, it also stores at most eight aggregated quota-cycle profiles: total observed duration and consumption, plus six-hour usage buckets relative to the reset. These files contain no account, session, prompt, or message content and are never transmitted. Users can pause learning while keeping those profiles; measurements collected while paused are not added later. Users can also delete the learned profiles from Codex Usage settings. Account-scoped history uses a one-way hash of the account identity supplied by Codex, combined with the default quota category, as an opaque local directory name. Raw account identifiers and email addresses are not stored or shown in diagnostics. Legacy history without account attribution is not imported into a verified account; unverified observations remain in memory and do not train saved forecasts. The last confirmed usage snapshot is retained only in memory during an outage, with its original timestamp. Diagnostics exposes field availability and bounded status messages, not raw service errors, credentials, or personal paths. -Optional source preferences store the user-selected executable and Codex home paths locally in settings. These paths are not included in diagnostic reports or logs. Account activity requests travel through the local Codex app-server and retain only aggregate daily token counts and optional totals in memory. Identity is checked before and after each request. Optional usage notifications contain a quota label and bounded status text, without account identifiers; their deduplication state remains in memory. Disabling account activity clears the visible account activity state and stops new optional reads. +Account activity requests travel through the local Codex app-server and retain only aggregate daily token counts and optional totals in memory. Identity is checked before and after each request. Optional usage notifications contain a quota label and bounded status text, without account identifiers; their deduplication state remains in memory. Disabling account activity clears the visible account activity state and stops new optional reads. Optional retained history stores at most 27,000 aggregate quota observations with UTC timestamps and reset times, separated by hashed account/category context. The selected retention is 7, 30, or 90 days; collection starts only after opting in. Explicit CSV/JSON exports create local files without account IDs or conversation content. Users control deletion of retained observations and exported copies separately. Neither retained observations nor exports contain prompts or task contents. An explicitly requested task-usage read passes the user-entered task ID through the local Codex app-server and keeps the resulting aggregate estimates only in memory. A confirmed earned-reset action sends a mutation through that server. Before sending, the extension stores a random request ID in the account's hashed local context. An unresolved ID is retained across restarts so a retry cannot accidentally become a separate redemption. Recovery records contain no authentication credentials or raw account identifiers and are separate from history deletion. -Optional source profiles store up to eight display names and executable/home paths in the local `profiles.json` beside settings. Profile selection changes only the extension's source preferences; it does not copy authentication or alter Codex configuration. Deleting a preset does not delete a source directory or change the active source. The quota fallback keeps bounded file metadata, read positions, partial lines, and its latest parsed quota event only in memory. +The quota fallback keeps bounded file metadata, read positions, partial lines, and its latest parsed quota event only in memory. -The optional Claude pilot reads only the capture file explicitly selected in settings. Its separately configured companion script receives Claude statusline input and retains at most 256 KiB in memory for parsing. Default mode forwards that input to the user's existing formatter; standalone mode emits only a compact quota line. The saved capture contains only a schema version, provider label, UTC timestamp, validated five-hour/seven-day usage percentages and reset times, and generic status messages. It does not copy workspace paths, model details, account identifiers, credentials, prompts, or responses into the capture. The extension does not request Claude credentials, contact Claude services, edit Claude configuration, or upload the capture. Users manage the script and its output file separately; disabling the pilot clears its displayed state and stops new reads but does not delete the file. +Source-path, source-label, and Claude preferences from earlier development builds are ignored and omitted on the next settings save. The extension no longer reads saved source-profile or Claude capture files. Existing profile files and externally configured capture scripts or files remain under the user's control and are not deleted or modified by the extension. ## Permissions diff --git a/README.md b/README.md index 72117b0..ab7dc91 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,11 @@ Codex Usage Dock is activated inside PowerToys Command Palette and intentionally 5. Choose **Add command** (`+`) in the section where you want the widget. 6. Search for **Codex Usage** and select its Dock band. -The Dock will show entries similar to `5h 47%`, `Week 86%`, and `2 resets · 10.00`. The percentages represent the amount remaining. The final entry shows available earned resets, the time until the next reset credit expires in whole hours or days, and, when available, the credits balance. Select an entry to see reset expiry details or refresh the data manually. +The Dock will show entries similar to `5h 47%`, `Week 86%`, and `2 resets · 10.00`. The percentages represent the amount remaining. Quota subtitles show the next reset, such as `Reset - 15 sept 12:56`. The final entry shows available earned resets, the next credit expiry as `Expires - 4 okt 4:00`, and, when available, the credits balance. Dates use your local time zone and abbreviated month names from your regional settings. Stale or fallback data retains its source warning. Select an entry to see reset expiry details or refresh the data manually. ## Customize the Dock -**Compact Dock** shortens quota labels to forms such as `5h47%` and `W86%` and hides reset times while retaining stale/source warnings. **Separate Dock items** offers each metric as a separate pinnable band; existing combined-band and individual pin identifiers remain resolvable after changing modes. +**Compact Dock** shortens quota labels to forms such as `5h47%` and `W86%` and hides reset times while retaining stale/source warnings. **Separate Dock items** offers each visible metric as a separate pinnable band. Turning it off offers the combined band. Pins belonging to the inactive mode and hidden metrics stop displaying items and are not restored as active bands after a reload. Command Palette keeps its saved pins: switching modes does not move or convert them. Add the desired bands through Dock customization if they were not already pinned; switching back makes matching saved pins available again. **Enable usage alerts** is off by default. When enabled, fresh, identified account data can notify on a downward crossing of 10% remaining, a new projected limit within one hour, or a reset credit entering its last 24 hours. The first measurement establishes a baseline. Duplicate refreshes do not repeat alerts, small reset-time fluctuations stay in the same cycle, and account/category changes start a new baseline. Multiple simultaneous alerts are combined into one host notification. Delivery depends on the Command Palette host. @@ -64,38 +64,19 @@ Microsoft Store installs updates automatically. You can also check for updates f ## Sources and account activity -Settings accepts an optional full path to a standalone `codex.exe` or `codex.cmd` and an optional Codex home directory. Empty fields retain environment-based discovery. An explicit directory can be a Windows-accessible WSL path; the extension reads that directory and passes it to the Windows CLI as `CODEX_HOME`, without starting WSL or changing Codex configuration. Inaccessible or invalid explicit paths stop source reads and show a settings error. A profile change clears the displayed context and discards results from the previous in-flight read. +Codex is detected automatically using the existing environment configuration described in [Requirements](#requirements). Local session readers use `CODEX_HOME` when set, otherwise the current user's `.codex` directory. The extension has no path fields or source-profile selection in its settings. -**Codex account activity** shows account-wide token summaries and up to 30 recent server-calendar days when `account/usage/read` is supported. It updates independently after quota data, at most every five minutes automatically; unsupported versions retry after 30 minutes. **Refresh now** on that page requests an immediate retry. Disable **Show account activity** to stop these optional reads. Account identity must match before and after the request. Missing days and fields are not zero usage, and the server's unspecified calendar time zone is kept separate from local calendar-day chart bars. No account activity is written to disk by this feature. +Saved source paths, source labels, and Claude preferences from earlier development builds are ignored. Other Codex preferences are preserved, and obsolete fields are omitted the next time settings are saved. Old source-profile files and externally configured capture scripts or files are not deleted or modified by the extension. -**Codex source profiles** saves up to eight named sets of executable and home paths. Add a profile, then choose **Use profile** to apply it and persist it for the next start. Reusing a name replaces that preset. Profiles contain no copied credentials; a name does not verify the signed-in account. Saved WSL or network directories can remain listed while offline, but paths must be accessible before use. Deleting a preset asks for confirmation and leaves the active source settings unchanged. Only one Codex source is active at a time. +**Codex account activity** shows account-wide token summaries and up to 30 recent server-calendar days when `account/usage/read` is supported. It updates independently after quota data, at most every five minutes automatically; unsupported versions retry after 30 minutes. **Refresh now** on that page requests an immediate retry. Disable **Show account activity** to stop these optional reads. Account identity must match before and after the request. Missing days and fields are not zero usage, and the server's unspecified calendar time zone is kept separate from local calendar-day chart bars. No account activity is written to disk by this feature. **Codex usage in text**, also available from Details, provides quota tables, reset times, recent measured weekly points, and local daily token totals without relying on charts or color. Missing values, reported zero, expired windows, and last-confirmed observations have distinct text labels. -## Optional Claude usage pilot - -The pilot displays separate Claude five-hour and seven-day limits from an explicitly selected local capture file. It is off by default and adds its own Dock band when enabled. It does not verify the Claude account, combine Claude percentages with Codex, or infer costs. Claude reads run independently of a slow Codex refresh. Changing the refresh interval immediately rereads the capture and updates its freshness status. - -The bridge uses Claude Code's documented `rate_limits.five_hour` and `rate_limits.seven_day` statusline fields. These may be absent independently, appear only after a session receives an API response, and require an eligible subscription. The pilot does not support gateway spend-limit fields. See the [official statusline field documentation](https://code.claude.com/docs/en/statusline#available-data). - -1. Copy [capture-claude-usage.ps1](scripts/capture-claude-usage.ps1) from this repository to a permanent location you control. The companion script is not bundled into the MSIX application. -2. Configure the command in your Claude statusline settings using the official instructions. If you have no existing formatter, use the script's **standalone** mode; replace both example paths with your own absolute paths: - - ```text - powershell.exe -NoProfile -NonInteractive -File "C:/Tools/capture-claude-usage.ps1" -OutputPath "C:/UsageCaptures/claude-usage.json" -Standalone - ``` - - Standalone mode displays a compact remaining-quota line. To retain an existing formatter, omit `-Standalone`, launch the capture script as a separate PowerShell process, and pipe that process's stdout into your existing formatter command. Default mode forwards the original stdin bytes unchanged, including when the capture destination fails. Calling the script inside the same PowerShell process is not a supported pipeline arrangement. The extension does not edit your Claude settings or replace a statusline automatically. -3. In **Codex Usage settings**, set **Claude bridge file** to the same absolute JSON file path and turn on **Enable Claude usage pilot**. The script creates the output directory when needed. -4. Open **Claude usage pilot** to inspect capture status, observation time, and each independent window. Add its Claude Dock band through Dock customization. - -The bridge retains at most 256 KiB of input for parsing and writes only the schema, provider, UTC capture time, validated quota windows, and generic availability messages. Writes replace the snapshot atomically. Missing, malformed, or oversized input writes an unavailable snapshot; it never refreshes the timestamp on old quota values. The extension reads at most 64 KiB per capture. Stale, future-dated, missing, and expired data do not appear as available Dock quota. **Refresh captures** rereads the file; it does not make Claude emit new data. After a quiet session, wait for a new Claude statusline update. - -## History, planning, and optional account actions +## History and optional account actions **Codex usage history** retains quota observations only when **Retain usage observations** is set to 7, 30, or 90 days. Observations are scoped to the identified account and default quota category, sampled in five-minute buckets, and capped at 27,000 rows. Reset changes within a bucket remain separate observations. Pausing collection keeps retained data; the history page offers confirmed deletion for the selected context. CSV and JSON export actions write files to the extension's local application data `exports` folder and show the resulting path. Exports contain quota percentages and UTC observation/reset times, without account IDs or conversation content. Exported copies are not deleted when retained history is cleared. -**Codex workday planner** uses today's local **Workday end** and your chosen **Workdays remaining before weekly reset**. It divides remaining weekly allowance across those days and today's remaining hours; the five-hour allowance is budgeted independently. Planning stops at an earlier reset and pauses after today's chosen end. It requires fresh, identified live data. The page explains the measurement span, continuous segment, learned-cycle count, and a held-out latest-observation check where sufficient data exists. This is descriptive evidence, not a calibrated confidence percentage or a guarantee. Its recent-pace estimate is separate from the dashboard's optional adaptive weekly forecast. +The workday planner and its end-time and remaining-workdays settings have been removed. Forecasts use observed usage without requiring a work schedule. Saved planner preferences from earlier development builds are ignored and omitted the next time settings are saved; other preferences and usage history are preserved. **Codex task usage and earned resets** accepts an explicit task ID for `account/usage/read` on compatible CLI versions. It shows server-estimated credits and optional USD, plus model/effort/speed and available input/cached/output token groups. These estimates are not invoices or conversions of quota percentages. Task reads verify account identity before and after, keep the most recently requested task, and retain results only in memory. diff --git a/SPRINTS.md b/SPRINTS.md index 230c8fe..60e1765 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -1,6 +1,8 @@ # Usage assistant implementation -This series implements the recommended Codex-first roadmap, followed by a small, optional Claude pilot. Source work uses separate feature branches and pull requests. Merge, Store publication, and installation are separate steps. +This series implements a Codex-only usage roadmap. Source work uses separate feature branches and pull requests. Merge, Store publication, and installation are separate steps. + +The sprint records below describe the original PRs and their historical verification. The current implementation removes the Claude pilot, manual source-path settings, named source profiles, and workday planner, while retaining automatic Codex detection, bounded fallback reads, accessible text views, and usage-based forecasts. See [CHANGELOG.md](CHANGELOG.md) for the current scope. | Sprint | Feature branch | Scope | Status | | --- | --- | --- | --- | diff --git a/scripts/capture-claude-usage.ps1 b/scripts/capture-claude-usage.ps1 deleted file mode 100644 index 8e1e16b..0000000 --- a/scripts/capture-claude-usage.ps1 +++ /dev/null @@ -1,313 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)] - [string]$OutputPath, - - [switch]$Standalone -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" - -$MaximumInputBytes = 256 * 1024 - -function Test-FullyQualifiedPath { - param( - [Parameter(Mandatory = $true)] - [string]$Path - ) - - try { - return [IO.Path]::IsPathFullyQualified($Path) - } - catch { - # Windows PowerShell 5.1 does not expose IsPathFullyQualified. - return $Path -match '^(?:[A-Za-z]:[\\/]|\\\\)' - } -} - -function Get-JsonProperty { - param( - [AllowNull()] - [object]$Object, - - [Parameter(Mandatory = $true)] - [string]$Name - ) - - if ($null -eq $Object) { - return $null - } - - $property = $Object.PSObject.Properties[$Name] - if ($null -eq $property) { - return $null - } - - return $property.Value -} - -function Convert-RateLimitWindow { - param( - [AllowNull()] - [object]$RateLimits, - - [Parameter(Mandatory = $true)] - [string]$Name, - - [Parameter(Mandatory = $true)] - [DateTimeOffset]$CapturedAt - ) - - $sourceWindow = Get-JsonProperty -Object $RateLimits -Name $Name - if ($null -eq $sourceWindow -or $sourceWindow -is [string] -or $sourceWindow -is [System.Array]) { - return $null - } - - $used = Get-JsonProperty -Object $sourceWindow -Name "used_percentage" - if ($null -eq $used -or $used -is [string] -or $used -is [char] -or $used -is [bool]) { - return $null - } - - try { - $usedNumber = [double]$used - } - catch { - return $null - } - - if ([double]::IsNaN($usedNumber) -or [double]::IsInfinity($usedNumber) -or - $usedNumber -lt 0 -or $usedNumber -gt 100) { - return $null - } - - $reset = Get-JsonProperty -Object $sourceWindow -Name "resets_at" - if ($null -eq $reset -or $reset -is [string] -or $reset -is [char] -or $reset -is [bool]) { - return $null - } - - try { - $resetNumber = [double]$reset - if ([double]::IsNaN($resetNumber) -or [double]::IsInfinity($resetNumber) -or - $resetNumber -ne [Math]::Truncate($resetNumber)) { - return $null - } - - $resetSeconds = [long]$resetNumber - $resetAt = [DateTimeOffset]::FromUnixTimeSeconds($resetSeconds) - } - catch { - return $null - } - - if ($resetAt -le $CapturedAt) { - return $null - } - - return [pscustomobject][ordered]@{ - used_percentage = $usedNumber - resets_at = $resetSeconds - } -} - -function Read-StandardInputAndPassThrough { - param( - [switch]$SuppressOutput - ) - - $inputStream = [Console]::OpenStandardInput() - $outputStream = [Console]::OpenStandardOutput() - $retained = [IO.MemoryStream]::new() - $buffer = New-Object byte[] 8192 - $oversized = $false - - try { - while (($count = $inputStream.Read($buffer, 0, $buffer.Length)) -gt 0) { - # Keep the existing statusline contract unless standalone output was requested. - if (-not $SuppressOutput) { - $outputStream.Write($buffer, 0, $count) - } - - if (-not $oversized) { - $remaining = $MaximumInputBytes - [int]$retained.Length - if ($count -le $remaining) { - $retained.Write($buffer, 0, $count) - } - else { - if ($remaining -gt 0) { - $retained.Write($buffer, 0, $remaining) - } - - $oversized = $true - } - } - } - - if (-not $SuppressOutput) { - $outputStream.Flush() - } - return [pscustomobject]@{ - Bytes = $retained.ToArray() - Oversized = $oversized - } - } - finally { - $retained.Dispose() - $inputStream.Dispose() - } -} - -function Write-AtomicSnapshot { - param( - [Parameter(Mandatory = $true)] - [string]$Path, - - [Parameter(Mandatory = $true)] - [string]$Content - ) - - $directory = [IO.Path]::GetDirectoryName($Path) - if ([string]::IsNullOrWhiteSpace($directory)) { - throw [ArgumentException]::new("The output path has no directory.") - } - - $directory = [IO.Path]::GetFullPath($directory) - [IO.Directory]::CreateDirectory($directory) | Out-Null - $leaf = [IO.Path]::GetFileName($Path) - if ([string]::IsNullOrWhiteSpace($leaf)) { - throw [ArgumentException]::new("The output path has no file name.") - } - - $temporaryLeaf = "." + $leaf + "." + [Guid]::NewGuid().ToString("N") + ".tmp" - $temporaryPath = [IO.Path]::Combine($directory, $temporaryLeaf) - $bytes = [Text.UTF8Encoding]::new($false).GetBytes($Content) - $stream = $null - - try { - $stream = [IO.File]::Open( - $temporaryPath, - [IO.FileMode]::CreateNew, - [IO.FileAccess]::Write, - [IO.FileShare]::None) - $stream.Write($bytes, 0, $bytes.Length) - $stream.Flush($true) - $stream.Dispose() - $stream = $null - - if ([IO.File]::Exists($Path)) { - $backupPath = [IO.Path]::Combine( - $directory, - "." + $leaf + "." + [Guid]::NewGuid().ToString("N") + ".bak") - try { - [IO.File]::Replace($temporaryPath, $Path, $backupPath, $true) - } - finally { - if ([IO.File]::Exists($backupPath)) { - [IO.File]::Delete($backupPath) - } - } - } - else { - [IO.File]::Move($temporaryPath, $Path) - } - } - finally { - if ($null -ne $stream) { - $stream.Dispose() - } - - if ([IO.File]::Exists($temporaryPath)) { - [IO.File]::Delete($temporaryPath) - } - } -} - -try { - $captured = Read-StandardInputAndPassThrough -SuppressOutput:$Standalone - - if ([string]::IsNullOrWhiteSpace($OutputPath) -or -not (Test-FullyQualifiedPath -Path $OutputPath)) { - throw [ArgumentException]::new("The output path must be fully qualified.") - } - - $capturedAt = [DateTimeOffset]::UtcNow - $primary = $null - $weekly = $null - $parseSucceeded = -not $captured.Oversized - - if ($parseSucceeded) { - try { - $json = [Text.UTF8Encoding]::new($false, $true).GetString($captured.Bytes) - $source = $json | ConvertFrom-Json - $rateLimits = Get-JsonProperty -Object $source -Name "rate_limits" - $primary = Convert-RateLimitWindow -RateLimits $rateLimits -Name "five_hour" -CapturedAt $capturedAt - $weekly = Convert-RateLimitWindow -RateLimits $rateLimits -Name "seven_day" -CapturedAt $capturedAt - } - catch { - $parseSucceeded = $false - $primary = $null - $weekly = $null - } - } - - $validWindows = @($primary, $weekly) | Where-Object { $null -ne $_ } - $validCount = @($validWindows).Count - $status = if (-not $parseSucceeded -or $validCount -eq 0) { - "unavailable" - } - elseif ($validCount -eq 2) { - "available" - } - else { - "partial" - } - $message = if ($validCount -eq 2) { - "Claude rate-limit windows are available." - } - elseif ($validCount -eq 1) { - "One Claude rate-limit window is unavailable; windows remain independent." - } - else { - "No valid Claude rate-limit windows were provided." - } - - $snapshot = [ordered]@{ - schemaVersion = 1 - provider = "claude" - observedAtUTC = $capturedAt.ToString("O", [Globalization.CultureInfo]::InvariantCulture) - rate_limits = [ordered]@{ - five_hour = $primary - seven_day = $weekly - } - status = $status - message = $message - } - $snapshotJson = $snapshot | ConvertTo-Json -Depth 8 -Compress - Write-AtomicSnapshot -Path $OutputPath -Content $snapshotJson - - if ($Standalone) { - $primaryRemaining = if ($null -eq $primary) { - "--" - } - else { - ([double](100 - [double](Get-JsonProperty -Object $primary -Name "used_percentage"))).ToString( - "0.##", - [Globalization.CultureInfo]::InvariantCulture) + "%" - } - $weeklyRemaining = if ($null -eq $weekly) { - "--" - } - else { - ([double](100 - [double](Get-JsonProperty -Object $weekly -Name "used_percentage"))).ToString( - "0.##", - [Globalization.CultureInfo]::InvariantCulture) + "%" - } - - [Console]::WriteLine("Claude 5h $primaryRemaining / week $weeklyRemaining") - } - - exit 0 -} -catch { - [Console]::Error.WriteLine("Claude usage capture could not write the snapshot.") - exit 1 -}