diff --git a/extensions/levelcode-ai/media/chat.html b/extensions/levelcode-ai/media/chat.html
index f5154bc..ed6a948 100644
--- a/extensions/levelcode-ai/media/chat.html
+++ b/extensions/levelcode-ai/media/chat.html
@@ -2419,15 +2419,39 @@
function modelLabel(){
try { const t = (document.getElementById('model').textContent || 'Auto').replace('▾', '').trim(); return t || 'Auto'; } catch(e){ return 'Auto'; }
}
- // [LevelCode] Retail micro-$ → a human figure. Sub-cent turns are common once prompt caching kicks
- // in, and "$0.00" would read as free/broken — so floor them at "<$0.01" instead of rounding away.
- function money(micros){
- const d = Number(micros) / 1e6;
- if (!isFinite(d) || d <= 0) { return '$0.00'; }
- return d < 0.01 ? '<$0.01' : '$' + d.toFixed(2);
+ // [LevelCode] Credits are the in-product spending unit: $1 = 100 credits, so 1 credit = 10_000
+ // retail micro-$. The gateway sends RETAIL micro-$ (Levelcode.retail_micros) — the same figures the
+ // account dashboard renders — so converting here with the same rule keeps the editor and the website
+ // showing one number. Pinned by test/creditFormat.test.js, which extracts these very functions.
+ const MICROS_PER_CREDIT = 10000;
+ // Group digits with a FIXED locale, not the ambient one. The bar it replaced rendered plain US-style
+ // money strings, the account dashboard formats the same figure the same way, and an ambient locale
+ // would make one balance render differently per machine ("1,279" vs "1.279" vs "1 279") — including
+ // on a CI runner, which would make these tests environment-dependent.
+ const CREDIT_LOCALE = 'en-US';
+ function toCredits(micros){
+ const c = Number(micros) / MICROS_PER_CREDIT;
+ return isFinite(c) ? c : 0;
+ }
+ // A balance reads as a whole credit count. Clamped at zero: an overage can push the remaining
+ // balance negative, and "-2 left" is a worse thing to put in front of someone than "0 left" (the
+ // dollar formatter this replaced clamped the same way).
+ function creditBalance(micros){
+ const c = toCredits(micros);
+ return (c > 0 ? Math.round(c) : 0).toLocaleString(CREDIT_LOCALE);
+ }
+ // A single run can cost well under one credit (a cheap-model turn is ~0.4), so small costs keep one
+ // decimal, and anything under 0.05 floors to "<0.1" rather than rounding to "0" — a run that cost
+ // something must never read as free. Returns the FIXED string: wrapping it in a unary + to drop
+ // trailing zeros silently defeated both of those rules. Identical to the dashboard's rate().
+ function creditCost(micros){
+ const c = toCredits(micros);
+ if (c <= 0) { return '0'; }
+ if (c >= 10) { return Math.round(c).toLocaleString(CREDIT_LOCALE); }
+ return c < 0.05 ? '<0.1' : c.toFixed(1);
}
- // Response action bar: (Retry) (Copy) (Helpful) (Unhelpful) … model · $cost · $left.
+ // Response action bar: (Retry) (Copy) (Helpful) (Unhelpful) … model · cost · balance, in credits.
function addAgentDone(reason, edits, credits, maxSteps, costMicros){
closeGroup(); // the run is over — finalize any open activity group
const bar = document.createElement('div'); bar.className = 'agentbar';
@@ -2447,8 +2471,8 @@
+ '' + esc(modelLabel()) + ''
// Gateway runs report real money (retail micro-$): what this run cost + what's left. BYOK runs
// report neither, so the bar stays exactly as it was.
- + (costMicros != null ? '· ' + esc(money(costMicros)) + '' : '')
- + (credits != null ? '· ' + esc(money(credits)) + ' left' : '');
+ + (costMicros != null ? '· ' + esc(creditCost(costMicros)) + ' credits' : '')
+ + (credits != null ? '· ' + esc(creditBalance(credits)) + ' left' : '');
bar.append(actions, meta);
log.appendChild(bar); scrollIfStuck();
diff --git a/extensions/levelcode-ai/test/creditFormat.test.js b/extensions/levelcode-ai/test/creditFormat.test.js
new file mode 100644
index 0000000..1db5668
--- /dev/null
+++ b/extensions/levelcode-ai/test/creditFormat.test.js
@@ -0,0 +1,119 @@
+/*---------------------------------------------------------------------------------------------
+ * Unit tests for the response bar's credit formatting — run: node test/creditFormat.test.js
+ *
+ * Like narrativeUi.test.js and shHighlight.test.js, the functions are EXTRACTED from the shipped
+ * chat.html, so these exercise the real code rather than a copy that can drift from it.
+ *
+ * Two directions are load-bearing, and both are ways of lying about money:
+ * 1. A run that cost something must never render as "0" — sub-credit turns are the common case on
+ * cheap models, so they floor rather than round away.
+ * 2. A balance must never render negative. An overage can push it below zero, and "-2 left" is a
+ * worse thing to show someone than "0 left" (the dollar formatter this replaced clamped too).
+ *--------------------------------------------------------------------------------------------*/
+// @ts-check
+'use strict';
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const html = fs.readFileSync(path.join(__dirname, '..', 'media', 'chat.html'), 'utf8');
+
+// Same slicing convention as narrativeUi.test.js: functions sit at 2 spaces and close with " }".
+function extract(name) {
+ const start = html.indexOf('function ' + name + '(');
+ assert.ok(start >= 0, 'chat.html no longer defines ' + name + '()');
+ const end = html.indexOf('\n }', start);
+ assert.ok(end >= 0, 'no closing brace found for ' + name + '()');
+ return html.slice(start, end + 4);
+}
+
+// MICROS_PER_CREDIT is a const in the same scope — pull it from the source so the test can never
+// disagree with the shipped conversion rate.
+const rateMatch = /const MICROS_PER_CREDIT = (\d+);/.exec(html);
+assert.ok(rateMatch, 'chat.html no longer defines MICROS_PER_CREDIT');
+assert.strictEqual(rateMatch[1], '10000', '1 credit must stay $0.01 — the website converts identically');
+
+// Digit grouping must not depend on the machine's locale — otherwise the same balance renders "1,279"
+// for one user and "1.279" or "1 279" for another, and these very assertions become dependent on the
+// CI runner's locale. Pull the constant from source so the check below exercises the shipped value.
+const localeMatch = /const CREDIT_LOCALE = '([\w-]+)';/.exec(html);
+assert.ok(localeMatch, 'chat.html must pin an explicit CREDIT_LOCALE — a bare toLocaleString() varies by environment');
+
+const sandbox = /** @type {any} */ ({});
+new Function(
+ 'const MICROS_PER_CREDIT = ' + rateMatch[1] + ";\nconst CREDIT_LOCALE = '" + localeMatch[1] + "';\n"
+ + extract('toCredits') + '\n' + extract('creditBalance') + '\n' + extract('creditCost') + '\n'
+ + 'this.toCredits = toCredits; this.creditBalance = creditBalance; this.creditCost = creditCost;'
+).call(sandbox);
+
+const { toCredits, creditBalance, creditCost } = sandbox;
+
+let n = 0;
+function test(name, fn) { fn(); n++; console.log(' ok - ' + name); }
+
+const usd = (d) => Math.round(d * 1e6); // dollars → retail micro-$, the unit the gateway sends
+
+// ---- 1. a cost must never read as free --------------------------------------------------------
+
+test('COST: a sub-credit run floors to "<0.1" instead of rounding to zero', () => {
+ // The cheap-model case, and the whole reason this is not a plain round(): a gpt-oss turn is ~0.4
+ // credits and a very cheap one is far less. "0" would tell the user the run was free.
+ assert.strictEqual(creditCost(usd(0.0002)), '<0.1');
+ assert.strictEqual(creditCost(usd(0.0004)), '<0.1');
+ assert.strictEqual(creditCost(usd(0.0036)), '0.4'); // the real gpt-oss per-turn figure
+});
+
+test('COST: keeps one decimal below 10, whole credits above', () => {
+ assert.strictEqual(creditCost(usd(0.07)), '7.0', 'the trailing .0 must survive');
+ assert.strictEqual(creditCost(usd(0.0767)), '7.7');
+ assert.strictEqual(creditCost(usd(0.46)), '46');
+ assert.strictEqual(creditCost(usd(0.5117)), '51');
+});
+
+test('COST: a genuinely zero cost is "0", not "<0.1"', () => {
+ assert.strictEqual(creditCost(0), '0');
+ assert.strictEqual(creditCost(-1), '0', 'a negative cost is nonsense — show nothing owed');
+});
+
+// ---- 2. a balance must never read negative ----------------------------------------------------
+
+test('BALANCE: whole credits, with separators', () => {
+ assert.strictEqual(creditBalance(usd(12.79)), '1,279'); // the figure the dashboard shows
+ assert.strictEqual(creditBalance(usd(100)), '10,000'); // an Ultra month
+ assert.strictEqual(creditBalance(usd(0.004)), '0');
+});
+
+test('BALANCE: an overage clamps to 0 rather than showing a negative', () => {
+ // Going over budget makes remaining negative in the ledger. "-2 left" is worse than "0 left".
+ assert.strictEqual(creditBalance(usd(-0.02)), '0');
+ assert.strictEqual(creditBalance(usd(-5)), '0');
+ assert.strictEqual(creditBalance(-1), '0');
+});
+
+// ---- 3. junk in, something sane out ------------------------------------------------------------
+
+test('ROBUST: nullish and non-numeric input never render NaN', () => {
+ for (const junk of [null, undefined, '', 'abc', {}, []]) {
+ assert.strictEqual(creditBalance(junk), '0', 'balance from ' + JSON.stringify(junk));
+ assert.strictEqual(creditCost(junk), '0', 'cost from ' + JSON.stringify(junk));
+ }
+ assert.strictEqual(toCredits(Infinity), 0, 'a non-finite figure must collapse to 0, not Infinity');
+});
+
+test('LOCALE: grouping is pinned, so the same balance renders identically everywhere', () => {
+ // Every grouped call must pass the explicit locale. A bare toLocaleString() would render "1.279"
+ // on a de-DE machine and "1 279" on fr-FR — the editor and the dashboard would then disagree about
+ // the same number, and this file's assertions would pass or fail depending on the CI runner.
+ const bare = /\.toLocaleString\(\s*\)/.exec(html);
+ assert.strictEqual(bare, null, 'found a bare toLocaleString() in chat.html — pass CREDIT_LOCALE');
+ assert.strictEqual(localeMatch[1], 'en-US', 'the dashboard groups with en-US; the bar must match');
+});
+
+test('CONVERSION: $1 is 100 credits, in both directions of the bar', () => {
+ assert.strictEqual(toCredits(usd(1)), 100);
+ assert.strictEqual(creditBalance(usd(1)), '100');
+ assert.strictEqual(creditCost(usd(1)), '100');
+});
+
+console.log('\ncreditFormat (chat.html): ' + n + ' tests passed.');