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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ notifications.
- Credit, spend-control, earned-reset, and model-specific limit details when available
- Compact, movable, always-on-top desktop widget
- Native-looking taskbar label beside the Windows notification area
- Event-driven task activity animation through official local Codex lifecycle hooks
- Immediate taskbar-label hiding while another app is fullscreen on the same monitor
- Persistent desktop/taskbar display preference
- Automatic refresh every two minutes and live server notifications
Expand Down Expand Up @@ -72,6 +73,76 @@ consumption because tokens do not map linearly to the remaining subscription per
Use the `−` button to switch to taskbar mode. Right-click the taskbar label or tray
icon to refresh, change display mode, or exit.

## Live Codex activity dots

Activity dots turn the official local Codex lifecycle hooks into an at-a-glance signal
that work is still running. They are available in both the taskbar label and desktop
widget, without polling Codex or estimating activity from rate-limit changes.

### What activity dots provide

- One quiet dot while Codex is idle, expanding into a three-dot wave during active work
- Immediate, event-driven updates when a Codex turn starts or finishes
- Independent tracking of parallel turns, so one completed turn cannot hide another
turn that is still running
- A completion animation only after the final active turn finishes
- A temporary taskbar preview for checking the animation without changing saved settings
- A dedicated setup window for installation status, trust approval, refresh, and removal

### Private and local by design

- No prompts, responses, transcript contents, transcript paths, or model output are
collected, stored, forwarded, or logged
- No telemetry, analytics, browser automation, remote backend, or credential access is used
- Hook signals stay on the current Windows account through a current-user-only named pipe
- Only the lifecycle event type and the Codex-provided session and turn identifiers are
passed to the in-memory activity monitor
- Activity state is not persisted, so the widget does not build a history of your work
- Authentication remains entirely owned by the locally installed Codex CLI

Hook installation remains an explicit, reviewable action and is never performed during
normal widget startup.

### Setup and removal

Select the three-dot activity button in the desktop widget, or choose **Activity dots...**
from the tray or taskbar-label menu. The setup window reports whether the hooks are missing,
awaiting approval, active, modified, or disabled. Select **Install hooks**, review the exact
proposed `~/.codex/hooks.json` content, and confirm the change.

After installation, select **Copy /hooks and open Codex**. Paste `/hooks` into Codex, then
review and trust the exact new `UserPromptSubmit`, `Stop`, and `SessionEnd` definitions.
New or changed definitions require new trust. Return to the setup window and select
**Check again** to verify that activity reporting is ready.

The setup window can remove handlers generated by the current widget and conservatively
recognized handlers from earlier Codex Usage Widget portable locations. Recognition is limited
to the exact command formats historically generated for `CodexUsageWidget.exe`; similarly named
handlers from other applications, existing hooks, and unknown configuration fields are preserved.

For scripted setup or recovery, the existing command-line flow remains available. From
PowerShell in the directory containing the widget executable, run:

```powershell
.\CodexUsageWidget.exe --install-activity-hooks
```

The command displays the proposed content and writes it only after interactive confirmation.

To perform the equivalent removal from PowerShell, run:

```powershell
.\CodexUsageWidget.exe --uninstall-activity-hooks
```

If the widget is closed, the hook handler exits successfully after a short bounded
connection attempt and Codex continues normally.

Activity state is intentionally in memory only. A task that started before the widget
or hooks were ready is not reconstructed. If Codex terminates without emitting `Stop`
or `SessionEnd`, the indicator can remain active until the widget restarts; no arbitrary
timeout is used because legitimate tasks can run for a long time.

## Development

The repository pins the .NET SDK in `global.json`.
Expand Down
27 changes: 22 additions & 5 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ dependency-injection package.

```text
src/CodexUsageWidget/
├── Application/ Refresh orchestration and shared presentation formatting
├── Application/ Refresh orchestration, activity state and presentation formatting
├── Domain/ Rate-limit, credit, spend-control and activity models
├── Infrastructure/
│ ├── Codex/ CLI discovery, app-server session, JSON-RPC and parsers
│ ├── Codex/ App-server integration plus lifecycle-hook parsing and local IPC
│ ├── Logging/ Local file diagnostics
│ ├── Settings/ Persistent display preference
│ └── Windows/ Tray icon and taskbar Win32 integration
Expand All @@ -21,14 +21,22 @@ tests/CodexUsageWidget.Tests/ Unit tests for parsing, formatting and persistence

## Runtime flow

1. `App` acquires the single-instance mutex and constructs the object graph.
1. `Program` handles activity-hook/configuration command modes before WPF startup;
`App` then acquires the single-instance mutex and constructs the normal widget object graph.
2. `UsageMonitor` owns refresh scheduling, timeout handling and refresh coalescing.
3. `CodexUsageProvider` coordinates required rate-limit reads and optional token-activity reads.
4. `CodexAppServerSession` owns initialized app-server connection lifetime.
5. `JsonRpcConnection` owns stdin/stdout request correlation and process lifetime.
6. Endpoint-specific parsers convert Codex payloads into domain records.
7. `UsageWidgetViewModel` maps snapshots to immutable presentation state.
8. `MainWindow` remains a window-lifecycle shell while focused user controls render
7. `CodexActivityPipeSignalSource` receives minimal lifecycle signals over a
current-user-only named pipe; `CodexActivityMonitor` owns the active turn set and
emits only final boolean transitions.
8. `CodexActivityHookSetupService` coordinates reviewable hook-file changes and reads
trust state through `hooks/list`; `CodexHookTrustStatusParser` owns the protocol shape.
9. `ActivityHookSetupWindow` presents setup status while a separate review dialog shows
the exact proposed file content before installation or removal.
10. `UsageWidgetViewModel` maps snapshots to immutable presentation state.
11. `MainWindow` remains a window-lifecycle shell while focused user controls render
compact, detailed, and repeated limit-row content.

## Dependency direction
Expand All @@ -46,6 +54,15 @@ tests/CodexUsageWidget.Tests/ Unit tests for parsing, formatting and persistence
- Optional token-activity failures degrade only the detailed activity section; core
rate-limit monitoring remains available.
- A semaphore prevents concurrent refreshes and a mutex prevents duplicate apps.
- Activity hook IPC is bounded and local to the current Windows user. Accepted clients are
consumed in order with a per-client read timeout, while separate pipe instances keep parallel
Codex sessions connectable. Duplicate turn lifecycle events are idempotent and session end
removes only that session's turns.
- UI hook setup reuses the same compare-before-write configuration plan as the CLI flow.
Codex remains the owner of hook trust; the widget only reads trust state and opens the
interactive CLI for the user's explicit `/hooks` approval.
- Activity state is not persisted or reconstructed with polling. Missing cleanup after
a hard Codex crash is cleared by restarting the widget.
- Unhandled exceptions and CLI diagnostics are recorded locally for support.
- Publish trimming is disabled because WPF is not a safe trimming boundary.

Expand Down
Binary file modified docs/images/detailed-widget.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 17 additions & 1 deletion src/CodexUsageWidget/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using CodexUsageWidget.Application;
using CodexUsageWidget.Infrastructure;
using CodexUsageWidget.Infrastructure.Codex;
using CodexUsageWidget.Infrastructure.Codex.Hooks;
using CodexUsageWidget.Infrastructure.Logging;
using CodexUsageWidget.Infrastructure.Settings;
using CodexUsageWidget.Infrastructure.Windows;
Expand Down Expand Up @@ -31,18 +32,32 @@ protected override void OnStartup(StartupEventArgs e)
_logger = new FileLogger(AppPaths.LogDirectory);
_exceptionHandler = new GlobalExceptionHandler(this, _logger);

CodexActivityMonitor? activityMonitor = null;
try
{
var usageProvider = new CodexUsageProvider(new CodexAppServerSession());
var appServerSession = new CodexAppServerSession();
var usageProvider = new CodexUsageProvider(appServerSession);
var usageMonitor = new UsageMonitor(usageProvider);
usageMonitor.DiagnosticMessage += (_, message) => _logger.Info(message);

activityMonitor = new CodexActivityMonitor(new CodexActivityPipeSignalSource());
var processPath = Environment.ProcessPath ??
throw new InvalidOperationException("Cannot determine the widget executable path.");
var activityHookSetupService = new CodexActivityHookSetupService(
new CodexHookConfigurationManager(),
appServerSession,
processPath);

var window = new MainWindow(
usageMonitor,
activityMonitor,
activityHookSetupService,
new CodexCliLauncher(),
new DisplayModeStore(),
new WidgetDensityStore(),
new TrayIconService());
MainWindow = window;
activityMonitor.StartAsync().GetAwaiter().GetResult();
window.Show();
if (window.StartsInTaskbarIndicatorMode)
{
Expand All @@ -53,6 +68,7 @@ protected override void OnStartup(StartupEventArgs e)
}
catch (Exception ex)
{
activityMonitor?.DisposeAsync().AsTask().GetAwaiter().GetResult();
_logger.LogError("Application startup failed.", ex);
System.Windows.MessageBox.Show(
"Codex Usage Widget could not start. See the log under " + AppPaths.LogDirectory,
Expand Down
28 changes: 28 additions & 0 deletions src/CodexUsageWidget/Application/ActivityHookSetupModels.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace CodexUsageWidget.Application;

public enum ActivityHookSetupState
{
NotInstalled,
ApprovalRequired,
Active,
Modified,
HooksDisabled,
InstalledStatusUnavailable,
Error
}

public enum ActivityHookChangeKind
{
Install,
Uninstall
}

public sealed record ActivityHookSetupStatus(
ActivityHookSetupState State,
string? Detail = null,
bool HasInstalledHandlers = false);

public sealed record ActivityHookChangePreview(
ActivityHookChangeKind Kind,
bool HasChanges,
string ProposedContent);
84 changes: 84 additions & 0 deletions src/CodexUsageWidget/Application/CodexActivityMonitor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
namespace CodexUsageWidget.Application;

public sealed class CodexActivityMonitor : IAsyncDisposable
{
private readonly object _stateLock = new();
private readonly object _transitionLock = new();
private readonly ICodexActivitySignalSource _source;
private readonly HashSet<ActiveTurn> _activeTurns = [];
private bool _started;

public CodexActivityMonitor(ICodexActivitySignalSource source)
{
_source = source;
}

public event Action<bool>? ActivityChanged;

public bool IsActive
{
get
{
lock (_stateLock)
{
return _activeTurns.Count > 0;
}
}
}

public async Task StartAsync(CancellationToken cancellationToken = default)
{
if (_started)
{
return;
}

_started = true;
_source.SignalReceived += SourceOnSignalReceived;
await _source.StartAsync(cancellationToken).ConfigureAwait(false);
}

private void SourceOnSignalReceived(CodexActivitySignal signal)
{
lock (_transitionLock)
{
bool? changedState = null;
lock (_stateLock)
{
var wasActive = _activeTurns.Count > 0;
switch (signal.Kind)
{
case CodexActivitySignalKind.TurnStarted when signal.TurnId is not null:
_activeTurns.Add(new ActiveTurn(signal.SessionId, signal.TurnId));
break;
case CodexActivitySignalKind.TurnStopped when signal.TurnId is not null:
_activeTurns.Remove(new ActiveTurn(signal.SessionId, signal.TurnId));
break;
case CodexActivitySignalKind.SessionEnded:
_activeTurns.RemoveWhere(turn =>
string.Equals(turn.SessionId, signal.SessionId, StringComparison.Ordinal));
break;
}

var currentActivity = _activeTurns.Count > 0;
if (wasActive != currentActivity)
{
changedState = currentActivity;
}
}

if (changedState is { } emittedActivity)
{
ActivityChanged?.Invoke(emittedActivity);
}
}
}

public async ValueTask DisposeAsync()
{
_source.SignalReceived -= SourceOnSignalReceived;
await _source.DisposeAsync().ConfigureAwait(false);
}

private readonly record struct ActiveTurn(string SessionId, string TurnId);
}
13 changes: 13 additions & 0 deletions src/CodexUsageWidget/Application/CodexActivitySignal.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace CodexUsageWidget.Application;

public enum CodexActivitySignalKind
{
TurnStarted,
TurnStopped,
SessionEnded
}

public sealed record CodexActivitySignal(
CodexActivitySignalKind Kind,
string SessionId,
string? TurnId = null);
10 changes: 10 additions & 0 deletions src/CodexUsageWidget/Application/IActivityHookSetupService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace CodexUsageWidget.Application;

public interface IActivityHookSetupService
{
Task<ActivityHookSetupStatus> GetStatusAsync(CancellationToken cancellationToken = default);

ActivityHookChangePreview PrepareChange(ActivityHookChangeKind kind);

void ApplyChange(ActivityHookChangePreview preview);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace CodexUsageWidget.Application;

public interface ICodexActivitySignalSource : IAsyncDisposable
{
event Action<CodexActivitySignal>? SignalReceived;

Task StartAsync(CancellationToken cancellationToken = default);
}
6 changes: 6 additions & 0 deletions src/CodexUsageWidget/Application/ICodexLauncher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace CodexUsageWidget.Application;

public interface ICodexLauncher
{
void OpenInteractive();
}
1 change: 1 addition & 0 deletions src/CodexUsageWidget/CodexUsageWidget.csproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<StartupObject>CodexUsageWidget.Program</StartupObject>
<TargetFramework>net10.0-windows10.0.17763.0</TargetFramework>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
Expand Down
27 changes: 27 additions & 0 deletions src/CodexUsageWidget/Infrastructure/Codex/CodexCliLauncher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System.Diagnostics;
using CodexUsageWidget.Application;

namespace CodexUsageWidget.Infrastructure.Codex;

public sealed class CodexCliLauncher : ICodexLauncher
{
public void OpenInteractive()
{
var startInfo = new ProcessStartInfo
{
FileName = Environment.GetEnvironmentVariable("COMSPEC") ?? "cmd.exe",
UseShellExecute = true,
WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
WindowStyle = ProcessWindowStyle.Normal
};
startInfo.ArgumentList.Add("/d");
startInfo.ArgumentList.Add("/k");
startInfo.ArgumentList.Add("call");
startInfo.ArgumentList.Add(CodexExecutableLocator.Resolve());

if (Process.Start(startInfo) is null)
{
throw new InvalidOperationException("Windows could not open Codex CLI.");
}
}
}
Loading