fix(usage): simplify default full footer

This commit is contained in:
Ayaan Zaidi
2026-06-13 17:57:59 +05:30
parent 3ec16bbad3
commit 7328caba82
4 changed files with 91 additions and 158 deletions

View File

@@ -32,8 +32,13 @@ title: "Usage tracking"
## Custom `/usage full` footer
`messages.usageTemplate` customizes the per-response `/usage full` footer. The
value is a JSON file path (supports `~`) or an inline object:
`/usage full` shows a built-in compact footer with model, reasoning, fast/slow,
context window, final-call tokens, cache, and cost when those fields are
available. No template file is required.
`messages.usageTemplate` is only for advanced custom layouts. The value is a
JSON file path (supports `~`) or an inline object, and it replaces the built-in
footer when valid:
```json
{
@@ -43,35 +48,30 @@ value is a JSON file path (supports `~`) or an inline object:
}
```
Set it to the string `"default"` to use OpenClaw's built-in footer as-is:
```json
{ "messages": { "usageTemplate": "default" } }
```
When unset, the legacy single-line footer is used; `"default"` opts into the
richer built-in footer (model, reasoning, fast/slow, context-window bar, last-call
tokens, cache, cost).
Your template is **merged over that built-in footer**, the same way other config
objects layer over defaults: nested objects (`scales`, `aliases`) extend
key-by-key, while an `output.surfaces.<channel>` piece list replaces that
channel's default. So a template only needs to contain what it adds or changes —
start from `"default"` and override just the pieces you want. A missing,
unreadable, or invalid template falls back to the built-in line.
Missing, unreadable, invalid, or empty templates fall back to the built-in
footer.
### Shape
```jsonc
{
"schema": "openclaw.usageBar.v1",
"scales": { "<name>": "lowhigh glyphs" }, // string (1 glyph/char) or array
"scales": { "<name>": "low-to-high glyphs" }, // string (1 glyph/char) or array
"aliases": { "<table>": { "<value>": "<label>" } },
"output": {
"sep": "", // joins surviving pieces
"default": [ /* pieces */ ], // fallback for any surface
"surfaces": { "discord": [ /* pieces */ ], "telegram": [ /* pieces */ ] }
}
"sep": "", // joins surviving pieces
"default": [
/* pieces */
], // fallback for any surface
"surfaces": {
"discord": [
/* pieces */
],
"telegram": [
/* pieces */
],
},
},
}
```
@@ -79,47 +79,47 @@ Each surface is an ordered list of **pieces**; the engine renders each, drops
empties, and joins survivors with `sep`. A surface with no entry uses
`output.default`.
### Contract — the `{paths}` you can read
### Contract Paths
A piece reads values from the per-turn contract by dot-path. Absent values are
empty (so a `when` guard or a `|fallback` keeps the piece clean).
| Path | Meaning |
| --- | --- |
| `surface` | channel id (`discord`/`telegram`/) |
| `model.provider` · `model.display_name` | provider id · model id |
| `model.reasoning` | effort (`off``xhigh`) |
| `model.is_fallback` · `model.is_override` | bool fallback used · model pinned |
| `state.fast_mode` | bool fast vs slow |
| `context.max_tokens` · `context.pct_used` | window budget · 0100 used |
| `usage.input_tokens` · `usage.output_tokens` · `usage.cache_hit_pct` | turn aggregate |
| `usage.last.input_tokens` · `usage.last.output_tokens` · `usage.last.cache_hit_pct` | final model call only |
| `cost.turn_usd` | estimated turn cost |
| `timing.duration_ms` | wall-clock ms |
| `identity.name` · `identity.emoji` | agent name · chosen emoji |
| Path | Meaning |
| ----------------------------------------------------------------------------------- | -------------------------------------- |
| `surface` | channel id (`discord`/`telegram`/etc.) |
| `model.provider` / `model.display_name` | provider id / model id |
| `model.reasoning` | effort (`off` through `xhigh`) |
| `model.is_fallback` / `model.is_override` | bool: fallback used / model pinned |
| `state.fast_mode` | bool: fast vs slow |
| `context.max_tokens` / `context.pct_used` | window budget / 0-100 used |
| `usage.input_tokens` / `usage.output_tokens` / `usage.cache_hit_pct` | turn aggregate |
| `usage.last.input_tokens` / `usage.last.output_tokens` / `usage.last.cache_hit_pct` | final model call only |
| `cost.turn_usd` | estimated turn cost |
| `timing.duration_ms` | wall-clock ms |
| `identity.name` / `identity.emoji` | agent name / chosen emoji |
(Provider rate-limit windows are **not** in this contract.)
### Verbs — `{path|verb:arg|fallback}`
### Verbs
Pipe a value through verbs leftright; a non-verb segment is the fallback.
Pipe a value through verbs left to right; a non-verb segment is the fallback.
| Verb | Effect | Example |
| --- | --- | --- |
| `num` | compact count | `272000 272k` |
| `fixed:N` | N decimals (default 2) | `0.0377` |
| `dur` | seconds duration | `14820 4h07m` |
| `pct` | append `%` | `96 96%` |
| `inv` | `100 x` | for usedremaining |
| `alias:TABLE` | lookup in `aliases`, echo if unlisted | `medium 🌗` |
| `meter:W:SCALE` | W-cell glyph bar over a 0100 value | `[⣿⣿⠐⠐⠐]` (`meter:1` = one glyph) |
| Verb | Effect | Example |
| --------------- | ------------------------------------- | --------------------------------- |
| `num` | compact count | `272000 -> 272k` |
| `fixed:N` | N decimals (default 2) | `0.0377` |
| `dur` | seconds to duration | `14820 -> 4h07m` |
| `pct` | append `%` | `96 -> 96%` |
| `inv` | `100 - x` | for used to remaining |
| `alias:TABLE` | lookup in `aliases`, echo if unlisted | `medium -> 🌗` |
| `meter:W:SCALE` | W-cell glyph bar over a 0-100 value | `[⣿⣿⠐⠐⠐]` (`meter:1` = one glyph) |
### Piece forms
- `{ "text": "📚 {context.max_tokens|num}" }` literal + interpolation.
- `{ "when": "<path>", "text": }` render only if the path is truthy.
- `{ "map": "<path>", "cases": { "true": "⚡", "false": "🐌", "_default": "?" } }` valueglyph.
- `{ "each": "limits.windows", "item": "{label}", "item_scales": ["weather"] }` iterate an array; `*` in `item` selects the per-item scale.
- `{ "text": "📚 {context.max_tokens|num}" }`: literal + interpolation.
- `{ "when": "<path>", "text": "..." }`: render only if the path is truthy.
- `{ "map": "<path>", "cases": { "true": "⚡", "false": "🐌" } }`: value to glyph.
- `{ "each": "limits.windows", "item": "{label}" }`: iterate an array.
### Example
@@ -134,11 +134,13 @@ Pipe a value through verbs left→right; a non-verb segment is the fallback.
{ "text": "{model.display_name}" },
{ "when": "model.reasoning", "text": " {model.reasoning|alias:reasoning}" },
{ "map": "state.fast_mode", "cases": { "true": " ⚡", "false": " 🐌" } },
{ "when": "context.max_tokens",
"text": " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}" }
]
}
}
{
"when": "context.max_tokens",
"text": " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}",
},
],
},
},
}
```

View File

@@ -1,36 +1,7 @@
import type { UsageBarTemplate } from "./translator.js";
/**
* Built-in `/usage full` footer template, used when `messages.usageTemplate` is
* set to the sentinel string `"default"`. Opt-in and intentionally undocumented
* in the config schema/help for now — a path or inline object still overrides.
*
* It is the same `openclaw.usageBar.v1` DSL a user template uses, kept in source
* (rather than a shipped JSON) so the default stays in lockstep with the engine.
*
* DSL recap (see translator.ts for the full verb set):
* - Each surface in `output.surfaces` is an ordered piece list; the engine
* renders each piece, drops empties, and joins survivors with `output.sep`.
* - `{path|verb:arg|fallback}` resolves `path` against the contract, applies
* verbs left→right, and uses `fallback` when the value is absent.
* - Verbs: num (compact count) · fixed:N (decimals) · dur (seconds→4h07m/5.2d)
* · pct · inv (100x) · alias:TABLE (lookup in `aliases`, echo if unlisted)
* · meter:W:SCALE (glyph bar — used for the 📚 context-window bar).
* - `{ when }` shows a piece only if a path is truthy; `{ map, cases }` maps a
* value to a glyph; `{ each, item }` iterates an array.
*
* Contract paths used below (built by buildUsageContract in contract.ts):
* model.{provider,display_name,reasoning,is_fallback,is_override}
* identity.emoji · state.fast_mode · context.{pct_used,max_tokens}
* usage.last.{input_tokens,output_tokens,cache_hit_pct} · cost.turn_usd
*
* `when` guards wrap the optional segments so an absent field drops the whole
* piece (no dangling separators or empty glyphs).
*/
export const DEFAULT_USAGE_BAR_TEMPLATE: UsageBarTemplate = {
schema: "openclaw.usageBar.v1",
// Full glyph-ramp palette shipped by default; a user template can add more
// or override by name. Each is low→high (string = one glyph per char).
scales: {
braille: "⠐⡀⡄⡆⡇⣇⣧⣷⣿",
block: "░▏▎▍▌▋▊▉█",
@@ -53,10 +24,8 @@ export const DEFAULT_USAGE_BAR_TEMPLATE: UsageBarTemplate = {
},
output: {
sep: "",
// Surfaces without an explicit entry (telegram, web, …) fall back to this.
// The engine reads `output.default`, NOT `output.surfaces.default`.
default: [
{ text: "{model.display_name|alias:models}" },
{ text: "{model.provider}{identity.emoji|🤖} {model.display_name|alias:models}" },
{ map: "model.is_fallback", cases: { true: " 🔄" } },
{ map: "model.is_override", cases: { true: " 📌" } },
{ when: "model.reasoning", text: " {model.reasoning|alias:reasoning}" },
@@ -65,6 +34,12 @@ export const DEFAULT_USAGE_BAR_TEMPLATE: UsageBarTemplate = {
when: "context.max_tokens",
text: " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}",
},
{
when: "usage.last",
text: " ↕️ {usage.last.input_tokens|num}/{usage.last.output_tokens|num}",
},
{ when: "usage.last.cache_hit_pct", text: " 🗄 {usage.last.cache_hit_pct|pct}" },
{ when: "cost.turn_usd", text: " 💰{cost.turn_usd|fixed:4}" },
],
surfaces: {
discord: [

View File

@@ -2,10 +2,11 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { DEFAULT_USAGE_BAR_TEMPLATE } from "./default-template.js";
import { clearUsageBarTemplateCacheForTest, loadUsageBarTemplate } from "./template.js";
const tplA = { segments: [{ text: "A" }] };
const tplB = { output: { lines: [] } };
const tplB = { output: { default: [{ text: "B" }] } };
let dir: string | undefined;
@@ -25,36 +26,16 @@ function tmpFile(name: string, contents: string): string {
}
describe("loadUsageBarTemplate", () => {
it("returns undefined when unset", () => {
expect(loadUsageBarTemplate(undefined)).toBeUndefined();
it("returns the built-in template when unset", () => {
expect(loadUsageBarTemplate(undefined)).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
});
it('resolves the "default" sentinel to the built-in usable template', () => {
const tpl = loadUsageBarTemplate("default");
expect(tpl).toBeDefined();
expect((tpl as { output?: unknown }).output).toBeDefined();
it("returns an inline template object when usable", () => {
expect(loadUsageBarTemplate(tplA)).toBe(tplA);
});
it("merges an inline override over the default (vocab extends, surfaces replace)", () => {
const merged = loadUsageBarTemplate({
scales: { mine: "ab" },
output: { surfaces: { discord: [{ text: "X" }] } },
}) as {
scales: Record<string, unknown>;
output: { surfaces: Record<string, unknown>; default?: unknown };
};
// added scale, and the default palette is still present
expect(merged.scales.mine).toBe("ab");
expect(merged.scales.braille).toBeDefined();
// the overridden channel is replaced; the default fallback survives
expect(merged.output.surfaces.discord).toEqual([{ text: "X" }]);
expect(merged.output.default).toBeDefined();
});
it("does not mutate the shared default when merging an override", () => {
loadUsageBarTemplate({ scales: { mine: "ab" } });
const bare = loadUsageBarTemplate("default") as { scales: Record<string, unknown> };
expect(bare.scales.mine).toBeUndefined();
it("falls back to the built-in template for an unusable inline object", () => {
expect(loadUsageBarTemplate({ nope: true })).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
});
it("loads and parses a template file", () => {
@@ -62,22 +43,22 @@ describe("loadUsageBarTemplate", () => {
expect(loadUsageBarTemplate(path)).toMatchObject(tplA);
});
it("falls back (undefined) for invalid JSON", () => {
it("falls back to the built-in template for invalid JSON", () => {
const path = tmpFile("bad.json", "{ not json");
expect(loadUsageBarTemplate(path)).toBeUndefined();
expect(loadUsageBarTemplate(path)).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
});
it("reloads a path after an initial miss", () => {
dir = mkdtempSync(join(tmpdir(), "usage-template-"));
const missing = join(dir, "missing.json");
expect(loadUsageBarTemplate(missing)).toBeUndefined();
expect(loadUsageBarTemplate(missing)).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
writeFileSync(missing, JSON.stringify(tplB));
expect(loadUsageBarTemplate(missing)).toMatchObject(tplB);
});
it("reloads a path after invalid JSON is fixed", () => {
const path = tmpFile("bad.json", "{ not json");
expect(loadUsageBarTemplate(path)).toBeUndefined();
expect(loadUsageBarTemplate(path)).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
writeFileSync(path, JSON.stringify(tplB));
expect(loadUsageBarTemplate(path)).toMatchObject(tplB);
});

View File

@@ -6,9 +6,6 @@ import type { UsageBarTemplate } from "./translator.js";
export type UsageTemplateConfig = string | Record<string, unknown> | undefined;
/** Sentinel value of `messages.usageTemplate` that selects the built-in default. */
const DEFAULT_SENTINEL = "default";
type CacheEntry = { template: UsageBarTemplate | undefined; watcher?: FSWatcher };
const fileCache = new Map<string, CacheEntry>();
@@ -26,24 +23,12 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Deep-merge a user override OVER the built-in default, like other openclaw
* config objects: nested objects are merged key-by-key (so `scales`/`aliases`
* extend rather than replace), while arrays and scalars from the override win
* (a `output.surfaces.<channel>` piece-list replaces that channel's default).
* Never mutates `base` — each level is cloned.
*/
function mergeTemplate(
base: Record<string, unknown>,
override: Record<string, unknown>,
): UsageBarTemplate {
const out: Record<string, unknown> = { ...base };
for (const [key, value] of Object.entries(override)) {
const prev = out[key];
out[key] =
isPlainObject(prev) && isPlainObject(value) ? mergeTemplate(prev, value) : value;
function isUsableTemplate(value: unknown): value is UsageBarTemplate {
if (!isPlainObject(value)) {
return false;
}
return out;
const hasOutput = typeof value.output === "object" && value.output !== null;
return hasOutput || Array.isArray(value.segments);
}
function readTemplateFile(path: string): UsageBarTemplate | undefined {
@@ -55,7 +40,7 @@ function readTemplateFile(path: string): UsageBarTemplate | undefined {
}
try {
const parsed: unknown = JSON.parse(raw);
return isPlainObject(parsed) ? parsed : undefined;
return isUsableTemplate(parsed) ? parsed : undefined;
} catch {
return undefined;
}
@@ -80,30 +65,20 @@ function cacheTemplateFile(path: string): UsageBarTemplate | undefined {
return entry.template;
}
export function loadUsageBarTemplate(
configured: UsageTemplateConfig,
): UsageBarTemplate | undefined {
export function loadUsageBarTemplate(configured: UsageTemplateConfig): UsageBarTemplate {
if (!configured) {
return undefined;
}
// The bare default, no override.
if (configured === DEFAULT_SENTINEL) {
return DEFAULT_USAGE_BAR_TEMPLATE;
}
// Inline override object → merged over the default.
if (typeof configured === "object") {
return isPlainObject(configured)
? mergeTemplate(DEFAULT_USAGE_BAR_TEMPLATE, configured)
: undefined;
return isUsableTemplate(configured) ? configured : DEFAULT_USAGE_BAR_TEMPLATE;
}
// File path → parsed override merged over the default. A missing/invalid file
// yields no override (undefined), so the caller falls back to the built-in line.
const path = expandPath(configured);
const cached = fileCache.get(path);
const override = cached
? (cached.template ?? (cached.watcher ? undefined : cacheTemplateFile(path)))
: cacheTemplateFile(path);
return override ? mergeTemplate(DEFAULT_USAGE_BAR_TEMPLATE, override) : undefined;
return (
(cached
? (cached.template ?? (cached.watcher ? undefined : cacheTemplateFile(path)))
: cacheTemplateFile(path)) ?? DEFAULT_USAGE_BAR_TEMPLATE
);
}
export function clearUsageBarTemplateCacheForTest(): void {