Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Each entry links to the commit or pull request that introduced the change.

### Fixed

- 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 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))
Expand Down
438 changes: 435 additions & 3 deletions CodexUsageDock.Tests/ProviderDockTests.cs

Large diffs are not rendered by default.

12 changes: 8 additions & 4 deletions CodexUsageDock.Tests/UsageDataTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CodexUsageSnapshot>(TaskCreationOptions.RunContinuationsAsynchronously);
Expand All @@ -457,6 +457,10 @@ public async Task CompletedRefreshRebuildsAndInvalidatesDockBands()
{
var invalidationCount = 0;
provider.ItemsChanged += (_, _) => invalidationCount++;
var band = Assert.Single(provider.GetDockBands()!);
var list = Assert.IsAssignableFrom<IListPage>(band.Command);
var bandInvalidations = 0;
list.ItemsChanged += (_, _) => bandInvalidations++;

var refresh = service.RefreshAsync();
result.SetResult(CodexUsageSnapshot.Loading with
Expand All @@ -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<IListPage>(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
Expand Down
83 changes: 46 additions & 37 deletions CodexUsageDock/CodexUsageDockCommandsProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ public partial class CodexUsageDockCommandsProvider : CommandProvider
private readonly CodexUsageTablePage _textUsage;
private readonly CodexProfilesPage _profiles;
private readonly ClaudeUsagePage _claude;
private readonly ListItem _claudeFiveHour;
private readonly ListItem _claudeWeekly;
private readonly UsageDockListItem _claudeFiveHour;
private readonly UsageDockListItem _claudeWeekly;
private readonly object _claudePresentationLock = new();
private readonly UsageAlertEvaluator _alerts = new();
private readonly Action<string> _notify;
Expand All @@ -31,6 +31,13 @@ public partial class CodexUsageDockCommandsProvider : CommandProvider
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 _claudeBand;
private readonly UsageDockBand[] _allDockBands;
private ICommandItem[] _dockBands = [];

public CodexUsageDockCommandsProvider()
Expand Down Expand Up @@ -68,12 +75,18 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS
_profiles = new CodexProfilesPage(new CodexProfileStore(_settings.ProfileStoragePath));
_profiles.ProfileSelected += OnProfileSelected;
_claude = new ClaudeUsagePage(_usage);
_claudeFiveHour = new ListItem(_claude);
_claudeWeekly = new ListItem(_claude);
_claudeFiveHour = new UsageDockListItem(_claude);
_claudeWeekly = new UsageDockListItem(_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");
_claudeBand = new(ClaudeDockId, "Claude usage");
_allDockBands = [_combinedBand, _fiveHourBand, _weeklyBand, _creditsBand, _claudeBand];

_commands =
[
Expand Down Expand Up @@ -111,29 +124,19 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS
_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)
Expand All @@ -154,8 +157,7 @@ private void OnSettingsChanged(object? sender, EventArgs e)
_details.Refresh();
_planner.Refresh();
_history.Refresh();
RebuildDockBands();
RaiseItemsChanged();
UpdateDockLayout();
if (sourceChanged) _ = _usage.RefreshAsync();
}

Expand All @@ -171,8 +173,7 @@ private bool ApplySourceSettings()
private void OnClaudeUpdated(object? sender, EventArgs args)
{
RefreshClaudeItems();
RebuildDockBands();
RaiseItemsChanged();
if (_claudeBand.HasItems) _claudeBand.NotifyItemsChanged();
}

private void RefreshClaudeItems()
Expand Down Expand Up @@ -212,8 +213,10 @@ private void OnUsageUpdated(object? sender, EventArgs e)
return;
}

RebuildDockBands();
RaiseItemsChanged();
foreach (var band in Volatile.Read(ref _dockBands).OfType<UsageDockBand>())
{
if (!ReferenceEquals(band, _claudeBand)) band.NotifyItemsChanged();
}
var alerts = _alerts.Evaluate(_usage.GetPresentation(), _clock(), _usage.RefreshInterval,
new UsageAlertOptions(Enabled: _settings.EnableUsageAlerts));
if (alerts.Count > 0)
Expand All @@ -224,25 +227,31 @@ 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<UsageDockBand>();
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<ICommandItem>().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] : []);
Publish(_claudeBand, _settings.EnableClaude ? [_claudeFiveHour, _claudeWeekly] : []);
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()
Expand Down
2 changes: 1 addition & 1 deletion CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ 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)
{
Expand Down
48 changes: 48 additions & 0 deletions CodexUsageDock/UsageDockBand.cs
Original file line number Diff line number Diff line change
@@ -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<UsageDockListItem>()) item.NotifyDisplayPropertiesChanged();
RaiseItemsChanged(Volatile.Read(ref _items).Length);
}
}
}
2 changes: 1 addition & 1 deletion CodexUsageDock/UsageDockItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions CodexUsageDock/UsageDockListItem.cs
Original file line number Diff line number Diff line change
@@ -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));
}
}
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ The Dock will show entries similar to `5h 47%`, `Week 86%`, and `2 resets · 10.

## 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, hidden metrics, and the disabled Claude pilot 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.
Comment thread
TheBeems marked this conversation as resolved.

**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.

Expand Down