diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1dbdd55..55a83d7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,7 +29,11 @@ jobs:
node-version: '22'
- run: make test
- run: mvn --batch-mode package
- - run: java -jar target/tokidachi-0.3.0-all.jar --pretty
+ - name: Smoke-test shaded JAR
+ run: |
+ jar_path=$(find target -maxdepth 1 -type f -name 'tokidachi-*-all.jar' -print -quit)
+ test -n "$jar_path"
+ java -jar "$jar_path" --pretty
env:
HOME: ${{ runner.temp }}/empty-home
XDG_CACHE_HOME: ${{ runner.temp }}/cache
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f5bea03..f1b59b9 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -25,7 +25,11 @@ jobs:
HOME: ${{ runner.temp }}/empty-home
XDG_CACHE_HOME: ${{ runner.temp }}/cache
- run: ./scripts/package.sh target/tokidachi
- - run: cp target/tokidachi-0.3.0-all.jar dist/
+ - name: Copy shaded JAR
+ run: |
+ jar_path=$(find target -maxdepth 1 -type f -name 'tokidachi-*-all.jar' -print -quit)
+ test -n "$jar_path"
+ cp "$jar_path" dist/
- run: cd dist && sha256sum * > SHA256SUMS
- uses: softprops/action-gh-release@v3
with:
@@ -33,5 +37,5 @@ jobs:
files: |
dist/tokidachi-linux-x86_64.tar.gz
dist/tokidachi@gaalbu.github.io.shell-extension.zip
- dist/tokidachi-0.3.0-all.jar
+ dist/tokidachi-*-all.jar
dist/SHA256SUMS
diff --git a/README.md b/README.md
index 031c6f4..cb8b960 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,9 @@ without hiding them.
## What it shows
- Claude's 5-hour and 7-day utilization, plus reset times when available.
-- Every limit window returned by the official Codex app-server.
+- Every limit window returned by the official Codex app-server, including
+ multiple metered buckets and individual spend controls.
+- Codex limit-state warnings and available rate-limit resets when reported.
- Independent provider state: Claude can fail without affecting Codex, and
vice versa.
- Only providers that are actually configured. If neither is configured, the
@@ -146,7 +148,7 @@ make native
Build outputs:
- `mvn package` creates the executable
- `target/tokidachi-0.3.0-all.jar`.
+ `target/tokidachi-0.3.1-all.jar`.
- `mvn -Pnative package` creates `target/tokidachi`, the standalone
Linux executable used by the extension.
- `make package` creates the installable archives under `dist/`.
@@ -154,7 +156,7 @@ Build outputs:
The JAR is useful for development and diagnostics:
```bash
-java -jar target/tokidachi-0.3.0-all.jar --pretty
+java -jar target/tokidachi-0.3.1-all.jar --pretty
```
## Interact and customize
@@ -228,17 +230,19 @@ Jackson's tree model to preserve the JSON contract consumed by `extension.js`:
"pet": "pets/claude.svg",
"status": "ok",
"configured": true,
- "windows": []
+ "windows": [],
+ "notices": []
}
}
}
```
-`displayName`, `color`, and `pet` are additive presentation metadata. The
-protocol remains at version 1 because existing fields and provider payloads
-retain their shape; older consumers can ignore the new fields. Provider order
-comes from the registry in `ProviderRegistry.java`, and the extension creates
-sections and dividers directly from the keys returned in `providers`.
+`displayName`, `color`, `pet`, and `notices` are additive presentation
+metadata. The protocol remains at version 1 because existing fields and
+provider payloads retain their shape; older consumers can ignore the new
+fields. Provider order comes from the registry in `ProviderRegistry.java`, and
+the extension creates sections and dividers directly from the keys returned in
+`providers`.
Mascot SVGs ship inside the extension and are never downloaded at runtime.
They use original abstract shapes rather than third-party logos. Metadata is
@@ -251,7 +255,10 @@ extension identity and preserve local UI preferences.
Codex is queried with `ProcessBuilder` through the documented local
`codex app-server --stdio` JSON-RPC method `account/rateLimits/read`. The
-collector never opens Codex's auth file.
+collector never opens Codex's auth file. Both the historical single-bucket
+response and the current multi-bucket response are accepted. Unknown buckets
+are displayed without being hard-coded, while optional credit and spend-control
+fields are ignored safely when absent.
Claude is queried with Java's native `HttpClient`. The collector reads only
the OAuth access token from Claude Code's local credentials and sends it only
diff --git a/pom.xml b/pom.xml
index 994160e..b4c8b66 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
io.github.gaalbu
tokidachi
- 0.3.0
+ 0.3.1
Tokidachi collector
diff --git a/src/main/java/io/github/gaalbu/tokidachi/CacheStore.java b/src/main/java/io/github/gaalbu/tokidachi/CacheStore.java
index 1857247..a15423f 100644
--- a/src/main/java/io/github/gaalbu/tokidachi/CacheStore.java
+++ b/src/main/java/io/github/gaalbu/tokidachi/CacheStore.java
@@ -95,6 +95,9 @@ ObjectNode freshProvider(ObjectNode cached, String name, long now) {
ObjectNode result = mapper.createObjectNode();
result.put("cachedAt", cachedAt);
result.set("windows", windows.deepCopy());
+ JsonNode notices = previous.path("notices");
+ result.set("notices", notices.isArray()
+ ? notices.deepCopy() : mapper.createArrayNode());
return result;
}
diff --git a/src/main/java/io/github/gaalbu/tokidachi/CodexProvider.java b/src/main/java/io/github/gaalbu/tokidachi/CodexProvider.java
index d69c286..c40cec7 100644
--- a/src/main/java/io/github/gaalbu/tokidachi/CodexProvider.java
+++ b/src/main/java/io/github/gaalbu/tokidachi/CodexProvider.java
@@ -2,7 +2,6 @@
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.io.BufferedReader;
@@ -67,14 +66,13 @@ public ObjectNode collect() throws ProviderException {
if (response.hasNonNull("error")) {
throw new ProviderException("Codex rejected the usage request; run `codex login`", false);
}
- ArrayNode windows = UsageParsers.parseCodex(response.path("result"), clock);
- if (windows.isEmpty()) {
+ ObjectNode result = UsageParsers.parseCodexUsage(response.path("result"), clock);
+ if (result.path("windows").isEmpty()
+ && result.path("notices").isEmpty()
+ && !result.hasNonNull("message")) {
throw new ProviderException("Codex returned no usage windows", true);
}
- ObjectNode result = mapper.createObjectNode();
- result.put("status", "ok");
result.put("configured", true);
- result.set("windows", windows);
return result;
} finally {
stop(process);
@@ -88,7 +86,7 @@ private void writeRequests(Process process) throws ProviderException {
ObjectNode clientInfo = initialize.putObject("params").putObject("clientInfo");
clientInfo.put("name", "tokidachi");
clientInfo.put("title", "Tokidachi");
- clientInfo.put("version", "0.3.0");
+ clientInfo.put("version", "0.3.1");
ObjectNode initialized = mapper.createObjectNode();
initialized.put("method", "initialized");
diff --git a/src/main/java/io/github/gaalbu/tokidachi/CollectorService.java b/src/main/java/io/github/gaalbu/tokidachi/CollectorService.java
index 5e99214..1b9441e 100644
--- a/src/main/java/io/github/gaalbu/tokidachi/CollectorService.java
+++ b/src/main/java/io/github/gaalbu/tokidachi/CollectorService.java
@@ -32,7 +32,7 @@ ObjectNode collectAll() {
try {
ObjectNode current = provider.collect();
providerResults.set(provider.name(), withMetadata(provider, current));
- cacheProviders.set(provider.name(), cacheEntry(now, current.path("windows")));
+ cacheProviders.set(provider.name(), cacheEntry(now, current));
} catch (ProviderException error) {
providerResults.set(provider.name(), withMetadata(
provider, failure(error.getMessage(), error.configured(), previous)));
@@ -68,16 +68,23 @@ private ObjectNode failure(String message, boolean configured, ObjectNode previo
result.put("message", message);
result.set("windows", previous == null
? mapper.createArrayNode() : previous.path("windows").deepCopy());
+ result.set("notices", previous == null
+ ? mapper.createArrayNode() : arrayCopy(previous.path("notices")));
return result;
}
- private ObjectNode cacheEntry(long now, JsonNode windows) {
+ private ObjectNode cacheEntry(long now, ObjectNode provider) {
ObjectNode result = mapper.createObjectNode();
result.put("cachedAt", now);
- result.set("windows", windows instanceof ArrayNode ? windows.deepCopy() : mapper.createArrayNode());
+ result.set("windows", arrayCopy(provider.path("windows")));
+ result.set("notices", arrayCopy(provider.path("notices")));
return result;
}
+ private ArrayNode arrayCopy(JsonNode value) {
+ return value instanceof ArrayNode array ? array.deepCopy() : mapper.createArrayNode();
+ }
+
private ObjectNode withMetadata(UsageProvider provider, ObjectNode result) {
ProviderMetadata metadata = provider.metadata();
result.put("displayName", metadata.displayName());
diff --git a/src/main/java/io/github/gaalbu/tokidachi/UsageParsers.java b/src/main/java/io/github/gaalbu/tokidachi/UsageParsers.java
index 8eb8a07..6f307a9 100644
--- a/src/main/java/io/github/gaalbu/tokidachi/UsageParsers.java
+++ b/src/main/java/io/github/gaalbu/tokidachi/UsageParsers.java
@@ -12,6 +12,7 @@
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
@@ -45,10 +46,14 @@ static ArrayNode parseClaude(JsonNode payload, Clock clock) {
}
static ArrayNode parseCodex(JsonNode payload, Clock clock) {
+ return (ArrayNode) parseCodexUsage(payload, clock).path("windows");
+ }
+
+ static ObjectNode parseCodexUsage(JsonNode payload, Clock clock) {
JsonNode buckets = payload.path("rateLimitsByLimitId");
ObjectNode normalized = JsonNodeFactory.instance.objectNode();
if (buckets.isObject() && !buckets.isEmpty()) {
- buckets.fields().forEachRemaining(entry -> normalized.set(entry.getKey(), entry.getValue()));
+ buckets.properties().forEach(entry -> normalized.set(entry.getKey(), entry.getValue()));
} else {
JsonNode fallback = payload.path("rateLimits");
if (fallback.isObject()) {
@@ -56,15 +61,29 @@ static ArrayNode parseCodex(JsonNode payload, Clock clock) {
}
}
- ArrayNode windows = JsonNodeFactory.instance.arrayNode();
- normalized.fields().forEachRemaining(entry -> {
+ ObjectNode result = JsonNodeFactory.instance.objectNode();
+ ArrayNode windows = result.putArray("windows");
+ ArrayNode notices = result.putArray("notices");
+ result.put("status", "ok");
+ var bucketEntries = new ArrayList<>(normalized.properties());
+ bucketEntries.sort((left, right) -> {
+ boolean leftIsCodex = left.getKey().equals("codex");
+ boolean rightIsCodex = right.getKey().equals("codex");
+ if (leftIsCodex != rightIsCodex) {
+ return leftIsCodex ? -1 : 1;
+ }
+ return left.getKey().compareTo(right.getKey());
+ });
+ bucketEntries.forEach(entry -> {
String bucketName = entry.getKey();
JsonNode bucket = entry.getValue();
if (!bucket.isObject()) {
return;
}
- String displayName = bucket.path("limitName").asText();
- if (displayName.isBlank()) {
+ JsonNode limitName = bucket.get("limitName");
+ String displayName = limitName != null && limitName.isTextual()
+ ? limitName.asText().trim() : "";
+ if (displayName.isEmpty()) {
displayName = bucketName.equals("codex") ? "Codex" : bucketName;
}
for (String windowName : new String[]{"primary", "secondary"}) {
@@ -81,8 +100,52 @@ static ArrayNode parseCodex(JsonNode payload, Clock clock) {
windows.add(window);
}
}
+ ObjectNode individual = individualLimit(displayName, bucket.path("individualLimit"),
+ normalized.size() > 1, clock);
+ if (individual != null) {
+ windows.add(individual);
+ }
+ String reachedMessage = reachedMessage(bucket.path("rateLimitReachedType").asText());
+ if (reachedMessage == null && bucket.path("spendControlReached").asBoolean(false)) {
+ reachedMessage = "Individual spend limit reached";
+ }
+ if (reachedMessage != null) {
+ result.put("status", "attention");
+ result.put("message", reachedMessage);
+ }
});
- return windows;
+ JsonNode resetCount = payload.path("rateLimitResetCredits").path("availableCount");
+ if (resetCount.canConvertToInt() && resetCount.asInt() > 0) {
+ int count = resetCount.asInt();
+ notices.add(count + (count == 1
+ ? " rate-limit reset available" : " rate-limit resets available"));
+ }
+ return result;
+ }
+
+ private static ObjectNode individualLimit(
+ String displayName, JsonNode value, boolean qualifyLabel, Clock clock) {
+ if (!value.isObject()) {
+ return null;
+ }
+ JsonNode remaining = value.get("remainingPercent");
+ if (remaining == null || !remaining.isNumber()) {
+ return null;
+ }
+ double used = 100.0 - remaining.asDouble();
+ String label = qualifyLabel ? displayName + " · Individual limit" : "Individual limit";
+ return window(label, JsonNodeFactory.instance.numberNode(used), value.get("resetsAt"), clock);
+ }
+
+ private static String reachedMessage(String type) {
+ return switch (type) {
+ case "rate_limit_reached" -> "Codex usage limit reached";
+ case "workspace_owner_credits_depleted", "workspace_member_credits_depleted" ->
+ "Workspace credits are depleted";
+ case "workspace_owner_usage_limit_reached", "workspace_member_usage_limit_reached" ->
+ "Workspace usage limit reached";
+ default -> null;
+ };
}
static ObjectNode window(String label, JsonNode used, JsonNode reset, Clock clock) {
diff --git a/src/test/java/io/github/gaalbu/tokidachi/CodexProviderTest.java b/src/test/java/io/github/gaalbu/tokidachi/CodexProviderTest.java
index a921fa3..63f14fa 100644
--- a/src/test/java/io/github/gaalbu/tokidachi/CodexProviderTest.java
+++ b/src/test/java/io/github/gaalbu/tokidachi/CodexProviderTest.java
@@ -27,7 +27,7 @@ void exchangesJsonRpcWithAppServerProcess() throws IOException, ProviderExceptio
#!/usr/bin/env bash
set -euo pipefail
head -n 3 >/dev/null
- printf '%s\\n' '{"id":2,"result":{"rateLimitsByLimitId":{"codex":{"primary":{"usedPercent":12,"windowDurationMins":300,"resetsAt":1786237200},"secondary":{"usedPercent":31,"windowDurationMins":10080,"resetsAt":1786823146}}}}}'
+ printf '%s\\n' '{"id":2,"result":{"rateLimitsByLimitId":{"codex":{"limitName":null,"primary":{"usedPercent":12,"windowDurationMins":300,"resetsAt":1786237200},"secondary":{"usedPercent":31,"windowDurationMins":10080,"resetsAt":1786823146}}},"rateLimitResetCredits":{"availableCount":1,"credits":[]}}}'
""");
Files.setPosixFilePermissions(command, PosixFilePermissions.fromString("rwx------"));
@@ -39,5 +39,25 @@ void exchangesJsonRpcWithAppServerProcess() throws IOException, ProviderExceptio
assertTrue(result.path("configured").asBoolean());
assertEquals(2, result.path("windows").size());
assertEquals(12.0, result.path("windows").get(0).path("usedPercent").asDouble());
+ assertEquals("1 rate-limit reset available", result.path("notices").get(0).asText());
+ }
+
+ @Test
+ void returnsReachedStateWhenCodexProvidesNoWindows() throws IOException, ProviderException {
+ Path command = temporary.resolve("codex-limit-reached");
+ Files.writeString(command, """
+ #!/usr/bin/env bash
+ set -euo pipefail
+ head -n 3 >/dev/null
+ printf '%s\\n' '{"id":2,"result":{"rateLimitsByLimitId":{"codex":{"primary":null,"secondary":null,"rateLimitReachedType":"rate_limit_reached"}}}}'
+ """);
+ Files.setPosixFilePermissions(command, PosixFilePermissions.fromString("rwx------"));
+
+ ObjectNode result = new CodexProvider(new ObjectMapper(), Clock.systemUTC(),
+ Map.of("CODEX_BIN", command.toString())).collect();
+
+ assertEquals("attention", result.path("status").asText());
+ assertEquals("Codex usage limit reached", result.path("message").asText());
+ assertTrue(result.path("windows").isEmpty());
}
}
diff --git a/src/test/java/io/github/gaalbu/tokidachi/CollectorServiceTest.java b/src/test/java/io/github/gaalbu/tokidachi/CollectorServiceTest.java
index 9c307b4..f6dc671 100644
--- a/src/test/java/io/github/gaalbu/tokidachi/CollectorServiceTest.java
+++ b/src/test/java/io/github/gaalbu/tokidachi/CollectorServiceTest.java
@@ -29,6 +29,7 @@ void providerFailureDoesNotBlockTheOtherAndFreshCacheKeepsItsAge() {
ObjectNode claude = cached.putObject("providers").putObject("claude");
claude.put("cachedAt", NOW - 60);
claude.set("windows", windows());
+ claude.putArray("notices").add("1 rate-limit reset available");
store.write(cached);
ObjectNode result = new CollectorService(mapper, clock, store,
@@ -39,6 +40,8 @@ void providerFailureDoesNotBlockTheOtherAndFreshCacheKeepsItsAge() {
assertEquals("Claude", result.path("providers").path("claude").path("displayName").asText());
assertEquals("#d97757", result.path("providers").path("claude").path("color").asText());
assertEquals("pets/claude.svg", result.path("providers").path("claude").path("pet").asText());
+ assertEquals("1 rate-limit reset available",
+ result.path("providers").path("claude").path("notices").get(0).asText());
ObjectNode saved = store.read();
assertEquals(NOW - 60, saved.path("providers").path("claude").path("cachedAt").asLong());
assertEquals(NOW, saved.path("providers").path("codex").path("cachedAt").asLong());
diff --git a/src/test/java/io/github/gaalbu/tokidachi/UsageParsersTest.java b/src/test/java/io/github/gaalbu/tokidachi/UsageParsersTest.java
index 0c52076..854a90d 100644
--- a/src/test/java/io/github/gaalbu/tokidachi/UsageParsersTest.java
+++ b/src/test/java/io/github/gaalbu/tokidachi/UsageParsersTest.java
@@ -4,6 +4,7 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.DoubleNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.jupiter.api.Test;
import java.io.IOException;
@@ -37,6 +38,34 @@ void parsesCodexWindows() throws IOException {
StreamSupport.stream(windows.spliterator(), false).map(n -> n.path("usedPercent").asDouble()).toList());
}
+ @Test
+ void parsesCurrentCodexLimitsWithoutNullLabels() throws IOException {
+ ObjectNode usage = UsageParsers.parseCodexUsage(fixture("codex_usage_current.json"), clock);
+
+ assertEquals(java.util.List.of(
+ "Codex · 5-hour window",
+ "Codex · 1-week window",
+ "Codex · Individual limit",
+ "gpt-reserve · 1-week window"),
+ StreamSupport.stream(usage.path("windows").spliterator(), false)
+ .map(n -> n.path("label").asText()).toList());
+ assertEquals(75.0, usage.path("windows").get(2).path("usedPercent").asDouble());
+ assertEquals("ok", usage.path("status").asText());
+ assertEquals("1 rate-limit reset available", usage.path("notices").get(0).asText());
+ }
+
+ @Test
+ void marksReachedCodexLimitsForAttention() throws IOException {
+ ObjectNode payload = (ObjectNode) fixture("codex_usage_current.json");
+ ((ObjectNode) payload.path("rateLimitsByLimitId").path("codex"))
+ .put("rateLimitReachedType", "workspace_member_credits_depleted");
+
+ ObjectNode usage = UsageParsers.parseCodexUsage(payload, clock);
+
+ assertEquals("attention", usage.path("status").asText());
+ assertEquals("Workspace credits are depleted", usage.path("message").asText());
+ }
+
@Test
void clampsPercentages() {
assertEquals(100.0, UsageParsers.window("test", DoubleNode.valueOf(140), null, clock).path("usedPercent").asDouble());
diff --git a/tasks/plan.md b/tasks/plan.md
new file mode 100644
index 0000000..c5c134f
--- /dev/null
+++ b/tasks/plan.md
@@ -0,0 +1,52 @@
+# Implementation Plan: Widget language selection
+
+## Overview
+
+Add English and Brazilian Portuguese UI translations, defaulting to the system
+language, with an in-widget language selector whose choice persists across Shell
+restarts.
+
+## Architecture Decisions
+
+- Keep translations in a small pure JavaScript module so locale resolution and
+ fallback behavior can be unit tested outside GNOME Shell.
+- Store the user's selection (`auto`, `en`, or `pt-BR`) in the existing UI state;
+ `auto` resolves from GNOME's language list.
+- Translate widget-owned copy only. Provider names, collector errors, window
+ labels, and reset descriptions remain source data.
+
+## Task List
+
+### Phase 1: Translation foundation
+
+- [x] Add failing tests for locale resolution, fallback, and interpolation.
+- [x] Implement the translation catalog and helper functions.
+
+### Checkpoint: Translation foundation
+
+- [x] Focused localization tests pass.
+
+### Phase 2: Widget integration
+
+- [x] Add a language row to the widget menu and persist its selection.
+- [x] Refresh visible copy immediately when the language changes.
+- [x] Include the localization module in install and release packages.
+- [x] Document the language behavior.
+
+### Checkpoint: Complete
+
+- [x] JavaScript and Java tests pass.
+- [x] Packaging scripts include every runtime module.
+- [x] Static syntax checks and quality scan pass.
+
+## Risks and Mitigations
+
+| Risk | Impact | Mitigation |
+| --- | --- | --- |
+| GNOME locale names include encoding or region suffixes | Wrong fallback to English | Normalize locale separators and inspect the ordered system language list |
+| Switching language leaves stale dynamic labels | Mixed-language UI | Re-render the last usage or failure state after changing language |
+| New module omitted from installed extension | Runtime import failure | Update and test both installer and packaging file lists |
+
+## Open Questions
+
+- None. The selector cycles through System, English, and Portuguese (Brazil).
diff --git a/tasks/todo.md b/tasks/todo.md
new file mode 100644
index 0000000..4aecaba
--- /dev/null
+++ b/tasks/todo.md
@@ -0,0 +1,34 @@
+# Widget language selection
+
+## Task 1: Translation foundation
+
+**Acceptance criteria:**
+
+- [x] System Portuguese locales resolve to `pt-BR`; unsupported locales fall back to English.
+- [x] English and Brazilian Portuguese strings support named interpolation.
+
+**Verification:**
+
+- [x] `node --experimental-default-type=module --test tests/i18n.test.js`
+
+**Dependencies:** None
+
+## Task 2: Widget integration
+
+**Acceptance criteria:**
+
+- [x] The context menu exposes System, English, and Portuguese (Brazil).
+- [x] Selection persists and updates all widget-owned visible and accessible copy immediately.
+- [x] Installation and release archives contain the localization module.
+
+**Verification:**
+
+- [x] `make test`
+- [x] `node --check tokidachi@gaalbu.github.io/i18n.js`
+
+**Dependencies:** Task 1
+
+## Checkpoint
+
+- [x] Full test suite passes.
+- [x] Quality scan has no unresolved errors.
diff --git a/tests/fixtures/codex_usage_current.json b/tests/fixtures/codex_usage_current.json
new file mode 100644
index 0000000..11225a0
--- /dev/null
+++ b/tests/fixtures/codex_usage_current.json
@@ -0,0 +1,38 @@
+{
+ "rateLimits": {
+ "limitId": "codex",
+ "limitName": null,
+ "primary": {"usedPercent": 57, "windowDurationMins": 300, "resetsAt": 1787772632},
+ "secondary": {"usedPercent": 10, "windowDurationMins": 10080, "resetsAt": 1788271979},
+ "credits": {"hasCredits": false, "unlimited": false, "balance": "0"},
+ "individualLimit": {"limit": "100", "remainingPercent": 25, "resetsAt": 1788370175, "used": "75"},
+ "spendControlReached": false,
+ "planType": "plus",
+ "rateLimitReachedType": null
+ },
+ "rateLimitsByLimitId": {
+ "base_model_inference": {
+ "limitId": "base_model_inference",
+ "limitName": "gpt-reserve",
+ "primary": {"usedPercent": 0, "windowDurationMins": 10080, "resetsAt": 1788370175},
+ "secondary": null,
+ "credits": null,
+ "individualLimit": null,
+ "spendControlReached": null,
+ "planType": "plus",
+ "rateLimitReachedType": null
+ },
+ "codex": {
+ "limitId": "codex",
+ "limitName": null,
+ "primary": {"usedPercent": 57, "windowDurationMins": 300, "resetsAt": 1787772632},
+ "secondary": {"usedPercent": 10, "windowDurationMins": 10080, "resetsAt": 1788271979},
+ "credits": {"hasCredits": false, "unlimited": false, "balance": "0"},
+ "individualLimit": {"limit": "100", "remainingPercent": 25, "resetsAt": 1788370175, "used": "75"},
+ "spendControlReached": false,
+ "planType": "plus",
+ "rateLimitReachedType": null
+ }
+ },
+ "rateLimitResetCredits": {"availableCount": 1, "credits": []}
+}
diff --git a/tests/provider-model.test.js b/tests/provider-model.test.js
index 85e6b3d..4c28654 100644
--- a/tests/provider-model.test.js
+++ b/tests/provider-model.test.js
@@ -1,7 +1,12 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import {animationState, providerEntries, providerVisuals} from '../tokidachi@gaalbu.github.io/providerModel.js';
+import {
+ animationState,
+ providerEntries,
+ providerNotices,
+ providerVisuals,
+} from '../tokidachi@gaalbu.github.io/providerModel.js';
test('providerEntries keeps every collector provider in protocol order', () => {
const data = {providers: {
@@ -42,3 +47,11 @@ test('animationState reflects attention and high usage', () => {
assert.equal(animationState({status: 'ok', windows: [{usedPercent: 81}]}), 'high');
assert.equal(animationState({status: 'ok', windows: [{usedPercent: 80}]}), 'idle');
});
+
+test('providerNotices accepts only bounded non-empty strings', () => {
+ const notices = providerNotices({
+ notices: [' One reset available ', '', 42, 'x'.repeat(100), 'ignored'],
+ });
+
+ assert.deepEqual(notices, ['One reset available', 'x'.repeat(80)]);
+});
diff --git a/tests/ui-source.test.js b/tests/ui-source.test.js
index 9706560..57dd540 100644
--- a/tests/ui-source.test.js
+++ b/tests/ui-source.test.js
@@ -14,6 +14,7 @@ test('theme menu keeps its label distinct from the selected value', () => {
assert.match(stylesheet, /\.ai-usage-menu-value\s*\{[^}]*background-color:/s);
assert.match(stylesheet, /\.ai-usage-menu-value\s*\{[^}]*border-radius:/s);
assert.match(stylesheet, /\.ai-usage-menu-value\s*\{[^}]*padding:/s);
+ assert.match(extension, /providerNotices\(provider\)/);
});
test('language selection is persisted, translated, and shipped with the extension', () => {
diff --git a/tokidachi@gaalbu.github.io/extension.js b/tokidachi@gaalbu.github.io/extension.js
index 4f325b4..247091a 100644
--- a/tokidachi@gaalbu.github.io/extension.js
+++ b/tokidachi@gaalbu.github.io/extension.js
@@ -12,7 +12,12 @@ import {
resolveLanguage,
translate,
} from './i18n.js';
-import {animationState, providerEntries, providerVisuals} from './providerModel.js';
+import {
+ animationState,
+ providerEntries,
+ providerNotices,
+ providerVisuals,
+} from './providerModel.js';
const DEFAULT_CONFIG = {
refreshSeconds: 300,
@@ -764,6 +769,14 @@ export default class TokidachiExtension extends Extension {
for (const window of windows)
view.rows.add_child(this._makeUsageRow(window, view.color));
+ for (const notice of providerNotices(provider))
+ view.rows.add_child(label(notice, 'ai-usage-provider-status'));
+
+ if (windows.length > 0 && provider.message) {
+ view.rows.add_child(label(String(provider.message).slice(0, 120),
+ 'ai-usage-error'));
+ }
+
if (windows.length === 0) {
view.rows.add_child(label(provider.message || this._t('noUsageWindow'),
'ai-usage-error'));
diff --git a/tokidachi@gaalbu.github.io/metadata.json b/tokidachi@gaalbu.github.io/metadata.json
index bb16126..5e83b4f 100644
--- a/tokidachi@gaalbu.github.io/metadata.json
+++ b/tokidachi@gaalbu.github.io/metadata.json
@@ -2,7 +2,7 @@
"uuid": "tokidachi@gaalbu.github.io",
"name": "Tokidachi",
"description": "A quiet desktop widget for Claude Code and Codex usage limits.",
- "version": 4,
+ "version": 5,
"shell-version": ["45", "46", "47", "48"],
"url": "https://github.com/Gaalbu/tokidachi"
}
diff --git a/tokidachi@gaalbu.github.io/providerModel.js b/tokidachi@gaalbu.github.io/providerModel.js
index 0b9dfab..d3232d0 100644
--- a/tokidachi@gaalbu.github.io/providerModel.js
+++ b/tokidachi@gaalbu.github.io/providerModel.js
@@ -32,6 +32,15 @@ export function animationState(provider) {
return highestUsage > 80 ? 'high' : 'idle';
}
+export function providerNotices(provider) {
+ if (!Array.isArray(provider?.notices))
+ return [];
+ return provider.notices
+ .filter(notice => typeof notice === 'string' && notice.trim().length > 0)
+ .slice(0, 2)
+ .map(notice => notice.trim().slice(0, 80));
+}
+
function fallbackColor(name) {
let hash = 0;
for (const character of name)