diff --git a/static/js/chat.js b/static/js/chat.js
index 118399c..70f5f10 100644
--- a/static/js/chat.js
+++ b/static/js/chat.js
@@ -156,6 +156,13 @@ import createResearchSynapse from './researchSynapse.js';
initSlashCommands({ apiBase, isStreaming: () => isStreaming });
// Initialize email inbox
emailInbox.init(documentModule);
+ // Wire the slash-command autocomplete popup on the chat composer. The
+ // dispatcher already handles the typed command — this just surfaces the
+ // registry as a discoverable menu when the user starts a message with /.
+ import('./slashAutocomplete.js').then(mod => {
+ const ta = document.getElementById('message');
+ if (ta && mod.initSlashAutocomplete) mod.initSlashAutocomplete(ta);
+ }).catch(() => {});
}
// addMessage, createMsgFooter, displayMetrics, hideWelcomeScreen, showWelcomeScreen
diff --git a/static/js/slashAutocomplete.js b/static/js/slashAutocomplete.js
new file mode 100644
index 0000000..693fb24
--- /dev/null
+++ b/static/js/slashAutocomplete.js
@@ -0,0 +1,265 @@
+// static/js/slashAutocomplete.js
+// Lightweight popup that surfaces the existing /command registry as users
+// type. Reads COMMANDS from slashCommands.js — no command logic lives here.
+
+import { COMMANDS, LEGACY_ALIASES } from './slashCommands.js';
+
+const POPUP_ID = 'slash-autocomplete';
+const MAX_VISIBLE = 12;
+
+// Flatten the registry into a searchable list of leaf entries. Each entry is
+// either a top-level command or a "cmd sub" pair (so subcommands get their
+// own row when relevant — /toggle web, /session new, etc).
+// Commands intentionally excluded from the autocomplete popup (pure easter
+// eggs with no productivity value, or internal machinery).
+const EXCLUDED = new Set(['flip','roll','8ball','fortune','odyssey','ascii']);
+
+// Important legacy aliases to promote to their own rows in the popup. These
+// are the short forms people will actually type (/new, /clear, /web, etc.)
+// rather than the full /session new, /toggle web equivalents.
+const PROMOTED_ALIASES = new Set([
+ 'new','clear','rename','fork','export','archive','important','star',
+ 'web','bash','research','doc',
+ 'memories','forget',
+]);
+
+function _flatten() {
+ const out = [];
+ const seen = new Set();
+
+ // 1. Top-level commands and their subcommands from COMMANDS
+ for (const [name, def] of Object.entries(COMMANDS)) {
+ if (EXCLUDED.has(name)) continue;
+ if (def.handler) {
+ seen.add(`/${name}`);
+ out.push({
+ token: `/${name}`,
+ aliases: (def.alias || []).map(a => `/${a}`),
+ category: def.category || '',
+ help: def.help || '',
+ usage: def.usage || '',
+ });
+ }
+ if (def.subs) {
+ for (const [sub, sdef] of Object.entries(def.subs)) {
+ if (sub.startsWith('_')) continue;
+ const tok = `/${name} ${sub}`;
+ seen.add(tok);
+ out.push({
+ token: tok,
+ aliases: (sdef.alias || []).map(a => `/${name} ${a}`),
+ category: def.category || '',
+ help: sdef.help || '',
+ usage: sdef.usage || '',
+ });
+ }
+ }
+ }
+
+ // 2. Promoted legacy aliases (/new, /clear, /web …) as convenient short rows
+ if (LEGACY_ALIASES) {
+ for (const [alias, { parent, sub }] of Object.entries(LEGACY_ALIASES)) {
+ if (!PROMOTED_ALIASES.has(alias)) continue;
+ const tok = `/${alias}`;
+ if (seen.has(tok)) continue;
+ const parentDef = COMMANDS[parent];
+ const subDef = parentDef?.subs?.[sub];
+ if (!subDef) continue;
+ seen.add(tok);
+ out.push({
+ token: tok,
+ aliases: [],
+ category: parentDef.category || '',
+ help: subDef.help || '',
+ usage: tok,
+ });
+ }
+ }
+
+ return out;
+}
+
+function _scoreMatch(entry, query) {
+ // query already starts with "/". Match against token + aliases. Prefix wins
+ // over substring; alias match scores slightly lower than token match.
+ const q = query.toLowerCase();
+ const t = entry.token.toLowerCase();
+ if (t === q) return 1000;
+ if (t.startsWith(q)) return 500 + (50 - Math.min(50, t.length - q.length));
+ for (const a of entry.aliases) {
+ const al = a.toLowerCase();
+ if (al === q) return 900;
+ if (al.startsWith(q)) return 400;
+ }
+ if (t.includes(q)) return 100;
+ if (entry.help.toLowerCase().includes(q.slice(1))) return 25; // help text
+ return 0;
+}
+
+function _ensurePopup(textarea) {
+ let el = document.getElementById(POPUP_ID);
+ if (el) return el;
+ el = document.createElement('div');
+ el.id = POPUP_ID;
+ el.className = 'slash-autocomplete-popup';
+ el.setAttribute('role', 'listbox');
+ el.setAttribute('aria-label', 'Slash commands');
+ document.body.appendChild(el);
+ return el;
+}
+
+function _position(popup, textarea) {
+ const r = textarea.getBoundingClientRect();
+ const maxH = Math.min(window.innerHeight * 0.5, 360);
+ popup.style.maxHeight = maxH + 'px';
+ // Anchor above the textarea, left-aligned with it
+ popup.style.left = Math.round(r.left) + 'px';
+ popup.style.width = Math.max(280, Math.round(Math.min(r.width, 520))) + 'px';
+ // Place above when there's enough room, otherwise below.
+ const aboveSpace = r.top;
+ if (aboveSpace > maxH + 20) {
+ popup.style.bottom = (window.innerHeight - r.top + 6) + 'px';
+ popup.style.top = '';
+ } else {
+ popup.style.top = (r.bottom + 6) + 'px';
+ popup.style.bottom = '';
+ }
+}
+
+function _render(popup, items, selectedIdx, query) {
+ if (!items.length) {
+ popup.innerHTML = `
No commands match ${_esc(query)}
`;
+ return;
+ }
+ // Group by category for the headers
+ let html = '';
+ let lastCat = null;
+ for (let i = 0; i < items.length; i++) {
+ const it = items[i];
+ if (it.category !== lastCat) {
+ html += `${_esc(it.category || 'Other')}
`;
+ lastCat = it.category;
+ }
+ const sel = i === selectedIdx ? ' slash-ac-row-sel' : '';
+ const usage = it.usage && it.usage !== it.token ? ` ${_esc(it.usage)}` : '';
+ html += ``
+ + `${_esc(it.token)}`
+ + `${_esc(it.help)}`
+ + usage
+ + `
`;
+ }
+ popup.innerHTML = html;
+ // Scroll selected into view
+ const selEl = popup.querySelector('.slash-ac-row-sel');
+ if (selEl) selEl.scrollIntoView({ block: 'nearest' });
+}
+
+function _esc(s) {
+ return String(s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"','\'':''' }[c]));
+}
+
+export function initSlashAutocomplete(textarea) {
+ if (!textarea || textarea._slashAcWired) return;
+ textarea._slashAcWired = true;
+
+ const all = _flatten();
+ let popup = null;
+ let visible = false;
+ let items = [];
+ let selectedIdx = 0;
+
+ const hide = () => {
+ if (!visible) return;
+ visible = false;
+ if (popup) popup.style.display = 'none';
+ };
+
+ const show = () => {
+ if (!popup) popup = _ensurePopup(textarea);
+ visible = true;
+ popup.style.display = 'block';
+ _position(popup, textarea);
+ };
+
+ const refresh = () => {
+ const v = textarea.value;
+ // Only trigger when the message starts with "/" (no leading space) and
+ // contains at most one space after the command (so subcommands work).
+ // If the user has moved past the slash command (newline, longer prose),
+ // the menu hides — we don't autocomplete mid-sentence.
+ if (!v.startsWith('/') || v.includes('\n')) { hide(); return; }
+ const query = v.trim();
+ items = all
+ .map(e => ({ e, s: _scoreMatch(e, query) }))
+ .filter(x => x.s > 0)
+ .sort((a, b) => b.s - a.s)
+ .slice(0, MAX_VISIBLE)
+ .map(x => x.e);
+ if (!items.length && query.length > 1) { hide(); return; }
+ if (!items.length) {
+ // Just "/" with no matches — fall back to showing everything up to MAX_VISIBLE
+ items = all.slice(0, MAX_VISIBLE);
+ }
+ selectedIdx = 0;
+ show();
+ _render(popup, items, selectedIdx, query);
+ };
+
+ const insert = (token) => {
+ textarea.value = token + ' ';
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
+ textarea.focus();
+ const len = textarea.value.length;
+ textarea.setSelectionRange(len, len);
+ hide();
+ };
+
+ textarea.addEventListener('input', refresh);
+ textarea.addEventListener('focus', () => { if (textarea.value.startsWith('/')) refresh(); });
+ textarea.addEventListener('blur', () => { setTimeout(hide, 120); }); // delay so click works
+
+ textarea.addEventListener('keydown', (e) => {
+ if (!visible || !items.length) return;
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ selectedIdx = (selectedIdx + 1) % items.length;
+ _render(popup, items, selectedIdx, textarea.value);
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ selectedIdx = (selectedIdx - 1 + items.length) % items.length;
+ _render(popup, items, selectedIdx, textarea.value);
+ } else if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) {
+ // Tab always inserts. Enter inserts only when the user hasn't already
+ // typed a full command + args — i.e. the popup is still in completion
+ // mode, not in "ready to submit a typed-out command" mode.
+ const v = textarea.value.trim();
+ const exactHit = items.find(it => it.token === v || it.aliases.includes(v));
+ if (e.key === 'Enter' && exactHit) {
+ // User typed the whole command — let the normal submit path handle it
+ hide();
+ return;
+ }
+ e.preventDefault();
+ insert(items[selectedIdx].token);
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ hide();
+ }
+ });
+
+ // Re-position on window resize / scroll
+ window.addEventListener('resize', () => { if (visible) _position(popup, textarea); });
+
+ // Click handler on the popup (delegated)
+ document.addEventListener('mousedown', (e) => {
+ if (!visible || !popup) return;
+ const row = e.target.closest?.('.slash-ac-row');
+ if (row && popup.contains(row)) {
+ e.preventDefault();
+ const tok = row.dataset.token;
+ if (tok) insert(tok);
+ }
+ });
+}
+
+export default { initSlashAutocomplete };
diff --git a/static/js/slashCommands.js b/static/js/slashCommands.js
index 81bb159..cf0c71b 100644
--- a/static/js/slashCommands.js
+++ b/static/js/slashCommands.js
@@ -5650,7 +5650,7 @@ const COMMANDS = {
// ── Legacy aliases ────────────────────────────────────────────────
// Maps old flat command names to { parent, sub } so `/new` still works.
-const LEGACY_ALIASES = {
+export const LEGACY_ALIASES = {
'new': { parent: 'session', sub: 'new' },
'create': { parent: 'session', sub: 'new' },
'delete': { parent: 'session', sub: 'delete' },
@@ -5950,7 +5950,7 @@ export function clearSetupMode(preservePendingState = false) {
}
}
-export { handleSlashCommand, handleSetupInput, handleSetupWizard, slashReply, typewriterReply };
+export { handleSlashCommand, handleSetupInput, handleSetupWizard, slashReply, typewriterReply, COMMANDS };
const slashCommands = {
initSlashCommands,
diff --git a/static/style.css b/static/style.css
index 260dbc2..7e8fc9b 100644
--- a/static/style.css
+++ b/static/style.css
@@ -34358,3 +34358,68 @@ body.theme-frosted .modal {
background-color: color-mix(in srgb, var(--accent, var(--red)) 10%, transparent);
transform: translateX(1px);
}
+
+/* Slash command autocomplete popup, anchored to the message composer */
+.slash-autocomplete-popup {
+ position: fixed;
+ z-index: 9000;
+ background: var(--bg-elev-2, #1a1a1a);
+ border: 1px solid var(--border, rgba(255,255,255,0.08));
+ border-radius: 8px;
+ box-shadow: 0 8px 24px rgba(0,0,0,0.35);
+ font-size: 13px;
+ color: var(--fg, #e6e6e6);
+ overflow-y: auto;
+ padding: 4px 0;
+ display: none;
+}
+.slash-ac-cat {
+ font-size: 10px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--fg-muted, #888);
+ padding: 6px 10px 2px;
+ opacity: 0.7;
+}
+.slash-ac-row {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ padding: 5px 10px;
+ cursor: pointer;
+ line-height: 1.3;
+ white-space: nowrap;
+ overflow: hidden;
+}
+.slash-ac-row:hover { background: color-mix(in srgb, var(--fg) 6%, transparent); }
+.slash-ac-row-sel { background: color-mix(in srgb, var(--accent, var(--red)) 14%, transparent); }
+.slash-ac-token {
+ font-family: 'Fira Code', ui-monospace, monospace;
+ color: var(--accent, var(--red));
+ font-weight: 600;
+ flex-shrink: 0;
+}
+.slash-ac-help {
+ color: var(--fg);
+ opacity: 0.85;
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.slash-ac-usage {
+ color: var(--fg-muted, #888);
+ font-family: 'Fira Code', ui-monospace, monospace;
+ font-size: 11px;
+ opacity: 0.55;
+ flex-shrink: 0;
+}
+.slash-ac-empty {
+ padding: 10px;
+ color: var(--fg-muted, #888);
+ font-style: italic;
+}
+.slash-ac-empty code {
+ font-family: 'Fira Code', ui-monospace, monospace;
+ color: var(--accent, var(--red));
+}