diff --git a/frontend/packages/agent/src/agent.css b/frontend/packages/agent/src/agent.css
index 8684cbad..b491c8e5 100644
--- a/frontend/packages/agent/src/agent.css
+++ b/frontend/packages/agent/src/agent.css
@@ -275,16 +275,19 @@ h2, h3 {
padding: 0 0.7em 0.1em;
text-transform: uppercase;
}
-.overflow-item-model {
+.overflow-item-model,
+.overflow-item-effort {
color: var(--muted);
font-variant-numeric: tabular-nums;
}
-.overflow-item-model:hover {
+.overflow-item-model:hover,
+.overflow-item-effort:hover {
color: var(--fg);
background: color-mix(in srgb, var(--purple) 8%, transparent);
border-color: var(--purple-dim);
}
-.overflow-item-model.active {
+.overflow-item-model.active,
+.overflow-item-effort.active {
color: var(--purple);
border-color: var(--purple-dim);
background: color-mix(in srgb, var(--purple) 6%, transparent);
@@ -674,15 +677,17 @@ pre.diff {
font-size: 0.8em;
letter-spacing: 0.05em;
}
-.model-chip {
+.model-chip,
+.effort-chip {
display: inline-block;
padding: 0.1em 0.6em;
border: 1px solid var(--purple-dim);
border-radius: 999px;
- color: var(--cyan);
font-size: 0.78em;
letter-spacing: 0.04em;
}
+.model-chip { color: var(--cyan); }
+.effort-chip { color: var(--amber); }
/* Context-window badge. Mirrors Claude Code's bottom-right "N tokens"
chip — single primary number (total prompt tokens in use), full
breakdown on hover. Sized/coloured like a peer of model-chip so
diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js
index 4d9049cd..53feb7e5 100644
--- a/frontend/packages/agent/src/app.js
+++ b/frontend/packages/agent/src/app.js
@@ -312,6 +312,47 @@ window.marked = marked;
menu.append(btn);
}
+ // ─── effort quick-picker ───────────────────────────────────────
+ // One-click shortcuts for each reasoning-effort level the backend
+ // declares in `availableEfforts` (from `state.available_efforts`).
+ // The active level is highlighted via the `active` class; see
+ // `renderEffortChip` which updates `effortPickerBtns` live. Applies
+ // on the next claude session. Omitted entirely when the backend
+ // hasn't supplied any levels (older harness / cold-load not yet in).
+ effortPickerBtns = [];
+ if (availableEfforts.length) {
+ const effortSep = el('div', { class: 'overflow-sep', 'aria-hidden': 'true' });
+ const effortLabel = el('div', { class: 'overflow-section-label' }, 'effort');
+ menu.append(effortSep, effortLabel);
+ // Display-only labels for well-known levels; any level the backend
+ // sends that isn't here just shows its bare name.
+ const EFFORT_DESCRIPTIONS = {
+ medium: 'medium (default)',
+ high: 'high',
+ xhigh: 'xhigh (max)',
+ };
+ for (const level of availableEfforts) {
+ const label = EFFORT_DESCRIPTIONS[level] ?? level;
+ const btn = el('button', {
+ type: 'button',
+ class: 'overflow-item overflow-item-effort',
+ role: 'menuitem',
+ title: `set reasoning effort to ${level} (applies next session)`,
+ 'data-effort': level,
+ },
+ el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '◇'),
+ label,
+ );
+ btn.addEventListener('click', () => {
+ if (currentEffort === level) { closeOverflowMenu(); return; }
+ closeOverflowMenu();
+ postEffort(level);
+ });
+ effortPickerBtns.push(btn);
+ menu.append(btn);
+ }
+ }
+
overflowMenuPopulated = true;
}
@@ -483,6 +524,17 @@ window.marked = marked;
// the built-in default until the first /api/state cold-load completes.
let availableModels = ['haiku', 'sonnet', 'opus'];
+ // Effort quick-picker — sibling of the model picker. The operator's
+ // selected reasoning-effort level applies on the next claude session.
+ // `currentEffort` highlights the active button + chip; `effortPickerBtns`
+ // are the menu buttons updated live by `renderEffortChip`. The level list
+ // is the backend's to declare — seeded from `state.available_efforts` on
+ // cold-load; empty until then (no hard-coded levels in the frontend), so
+ // the picker section is omitted until the backend supplies the set.
+ let currentEffort = null;
+ let effortPickerBtns = [];
+ let availableEfforts = [];
+
const SLASH_COMMANDS = [
{ name: '/help', desc: 'list slash commands' },
{ name: '/clear', desc: 'wipe the terminal panel (local-only)' },
@@ -515,6 +567,28 @@ window.marked = marked;
}
}
+ async function postEffort(level) {
+ try {
+ const resp = await fetch('api/effort', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ effort: level }),
+ redirect: 'manual',
+ });
+ const ok = resp.ok || resp.type === 'opaqueredirect'
+ || (resp.status >= 200 && resp.status < 400);
+ if (!ok && termAPI) {
+ const text = await resp.text().catch(() => '');
+ termAPI.row('turn-end-fail', '✗ effort change failed: ' + resp.status
+ + (text ? ' — ' + text : ''));
+ }
+ // No refreshState — the harness emits `effort_changed` on the
+ // SSE bus and the chip handler picks it up live.
+ } catch (err) {
+ if (termAPI) termAPI.row('turn-end-fail', '✗ effort change failed: ' + err);
+ }
+ }
+
async function postSimple(url, label) {
try {
const resp = await fetch(url, { method: 'POST', redirect: 'manual' });
@@ -1038,6 +1112,24 @@ window.marked = marked;
btn.setAttribute('aria-pressed', String(isActive));
}
}
+
+ function renderEffortChip(effort) {
+ currentEffort = effort || null;
+ const el_ = $('effort-chip');
+ if (el_) {
+ if (!effort) { el_.hidden = true; } else {
+ el_.hidden = false;
+ el_.textContent = 'effort · ' + effort;
+ el_.title = `reasoning effort: ${effort}\nset via the operator's effort picker; applies on the next claude session`;
+ }
+ }
+ // Sync effort picker buttons: highlight the active level (exact match).
+ for (const btn of effortPickerBtns) {
+ const isActive = !!effort && btn.dataset.effort === effort;
+ btn.classList.toggle('active', isActive);
+ btn.setAttribute('aria-pressed', String(isActive));
+ }
+ }
// Token badges — two separate chips:
// ctx · N last inference's prompt size = current context window
// utilisation (what to watch for compaction decisions)
@@ -1130,6 +1222,9 @@ window.marked = marked;
if (Array.isArray(s.available_models) && s.available_models.length > 0) {
availableModels = s.available_models;
}
+ if (Array.isArray(s.available_efforts) && s.available_efforts.length > 0) {
+ availableEfforts = s.available_efforts;
+ }
if (!headerSet) { setHeader(s.label, s.qualified_label, s.dashboard_port, s.hive_name, s.swarm_name); headerSet = true; }
currentLabel = s.label;
// Render server-supplied navigation links — see
@@ -1171,6 +1266,7 @@ window.marked = marked;
}
renderAliveBadge(s.status);
renderModelChip(s.model);
+ renderEffortChip(s.effort);
renderTokenUsage({ ctx: s.ctx_usage, cost: s.cost_usage });
// Open-threads aren't part of /api/state (kept on the broker
// db, fetched via the per-agent socket). Cold-load fetches
@@ -1671,6 +1767,7 @@ window.marked = marked;
}
},
model_changed(ev, api) { if (!api.fromHistory) renderModelChip(ev.model); },
+ effort_changed(ev, api) { if (!api.fromHistory) renderEffortChip(ev.effort); },
token_usage_changed(ev, api) {
if (!api.fromHistory) renderTokenUsage({ ctx: ev.ctx, cost: ev.cost });
},
diff --git a/frontend/packages/agent/src/index.html b/frontend/packages/agent/src/index.html
index f27d2c81..aad9bf90 100644
--- a/frontend/packages/agent/src/index.html
+++ b/frontend/packages/agent/src/index.html
@@ -38,6 +38,7 @@
…
… booting
+