From c18afd654fb4bc5ee0f5cc22bfb68198090bc6ab Mon Sep 17 00:00:00 2001 From: Gaalbu Date: Wed, 26 Aug 2026 14:46:44 -0300 Subject: [PATCH 1/3] fix: support current Codex usage limits --- README.md | 27 ++++--- pom.xml | 2 +- .../github/gaalbu/tokidachi/CacheStore.java | 3 + .../gaalbu/tokidachi/CodexProvider.java | 12 ++- .../gaalbu/tokidachi/CollectorService.java | 13 +++- .../github/gaalbu/tokidachi/UsageParsers.java | 75 +++++++++++++++++-- .../gaalbu/tokidachi/CodexProviderTest.java | 22 +++++- .../tokidachi/CollectorServiceTest.java | 3 + .../gaalbu/tokidachi/UsageParsersTest.java | 29 +++++++ tests/fixtures/codex_usage_current.json | 38 ++++++++++ tests/provider-model.test.js | 15 +++- tests/ui-source.test.js | 1 + tokidachi@gaalbu.github.io/extension.js | 15 +++- tokidachi@gaalbu.github.io/metadata.json | 2 +- tokidachi@gaalbu.github.io/providerModel.js | 9 +++ 15 files changed, 235 insertions(+), 31 deletions(-) create mode 100644 tests/fixtures/codex_usage_current.json 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/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) From ff10f53124adadf6bd635ca48d81e47b790c8113 Mon Sep 17 00:00:00 2001 From: Gaalbu Date: Wed, 26 Aug 2026 14:53:30 -0300 Subject: [PATCH 2/3] docs: track widget language selection --- tasks/plan.md | 52 +++++++++++++++++++++++++++++++++++++++++++++++++++ tasks/todo.md | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 tasks/plan.md create mode 100644 tasks/todo.md 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. From 3ff6b82cebe033fbe3212c73172727055b8ccb4e Mon Sep 17 00:00:00 2001 From: Gaalbu Date: Wed, 26 Aug 2026 15:06:09 -0300 Subject: [PATCH 3/3] ci: use generated shaded jar --- .github/workflows/ci.yml | 6 +++++- .github/workflows/release.yml | 8 ++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) 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