diff --git a/frontend/packages/agent/src/agent.css b/frontend/packages/agent/src/agent.css index 544cfc27..2636b4ba 100644 --- a/frontend/packages/agent/src/agent.css +++ b/frontend/packages/agent/src/agent.css @@ -263,6 +263,32 @@ h2, h3 { opacity: 0.4; cursor: progress; } +.overflow-sep { + height: 1px; + background: var(--border); + margin: 0.3em 0.4em; +} +.overflow-section-label { + font-size: 0.72em; + letter-spacing: 0.1em; + color: var(--muted); + padding: 0 0.7em 0.1em; + text-transform: uppercase; +} +.overflow-item-model { + color: var(--muted); + font-variant-numeric: tabular-nums; +} +.overflow-item-model:hover { + color: var(--fg); + background: rgba(203, 166, 247, 0.08); + border-color: var(--purple-dim); +} +.overflow-item-model.active { + color: var(--purple); + border-color: var(--purple-dim); + background: rgba(203, 166, 247, 0.06); +} /* Header pill — inbox / loose-ends triggers. Compact, count-prominent. */ .header-pill { diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index b9320108..c47683df 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -272,6 +272,39 @@ window.marked = marked; }); menu.append(logoutBtn); + // ─── model quick-picker ──────────────────────────────────────── + // Three one-click shortcuts for the most common model aliases. + // The active model is highlighted via the `active` class; see + // `renderModelChip` which updates `modelPickerBtns` live. + const modelSep = el('div', { class: 'overflow-sep', 'aria-hidden': 'true' }); + const modelLabel = el('div', { class: 'overflow-section-label' }, 'model'); + menu.append(modelSep, modelLabel); + const MODEL_ALIASES = [ + { name: 'haiku', label: 'haiku (fast)' }, + { name: 'sonnet', label: 'sonnet (balanced)' }, + { name: 'opus', label: 'opus (powerful)' }, + ]; + modelPickerBtns = []; + for (const m of MODEL_ALIASES) { + const btn = el('button', { + type: 'button', + class: 'overflow-item overflow-item-model', + role: 'menuitem', + title: `/model ${m.name}`, + 'data-model': m.name, + }, + el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '⊞'), + m.label, + ); + btn.addEventListener('click', () => { + if (currentModel === m.name) { closeOverflowMenu(); return; } + closeOverflowMenu(); + postModel(m.name); + }); + modelPickerBtns.push(btn); + menu.append(btn); + } + overflowMenuPopulated = true; } @@ -431,6 +464,13 @@ window.marked = marked; // bus-driven `status_changed` handler so it can re-enable the // composer without waiting for the next snapshot fetch. let currentLabel = ''; + // Tracked so the overflow-menu model picker can highlight the active + // model and avoid a redundant `/api/model` POST on same-model click. + let currentModel = null; + // References to the model picker buttons populated in the overflow + // menu so renderModelChip can update their `active` state without + // rebuilding the whole menu. + let modelPickerBtns = []; const SLASH_COMMANDS = [ { name: '/help', desc: 'list slash commands' }, @@ -969,12 +1009,23 @@ window.marked = marked; } function renderModelChip(model) { + currentModel = model || null; const el_ = $('model-chip'); if (!el_) return; - if (!model) { el_.hidden = true; return; } - el_.hidden = false; - el_.textContent = 'model · ' + model; - el_.title = `claude --model ${model}\nset via the operator's /model command; persists across turns until changed`; + if (!model) { el_.hidden = true; } else { + el_.hidden = false; + el_.textContent = 'model · ' + model; + el_.title = `claude --model ${model}\nset via the operator's /model command; persists across turns until changed`; + } + // Sync model picker buttons: highlight the active alias. A short alias + // (haiku/sonnet/opus) matches if `model` ends with that string; full + // API names like `claude-3-5-haiku-20241022` match on the suffix too. + for (const btn of modelPickerBtns) { + const alias = btn.dataset.model || ''; + const isActive = !!model && (model === alias || model.endsWith(alias)); + 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