feat(webchat): add slash commands, autocomplete popup, and web search button

Add 6 slash commands (/help, /reset, /compact, /usage, /status, /model)
with autocomplete popup (arrow keys, Enter/Tab/Escape navigation).
Search button toggles web search mode by prepending instruction to message.
Backend agent.send extended with metadata for server-side command routing.
This commit is contained in:
William Valentin
2026-02-10 20:45:14 -08:00
parent 7a69794418
commit 4c8ba3f20c
4 changed files with 554 additions and 18 deletions
+16 -2
View File
@@ -15,8 +15,8 @@ export interface AgentHandlerDeps {
export function createAgentHandlers(deps: AgentHandlerDeps) {
return {
'agent.send': async (request: GatewayRequest, send: SendFn): Promise<OutboundMessage | void> => {
const params = request.params as { message?: string; connectionId?: string; attachments?: GatewayAttachment[] } | undefined;
if (!params?.message) {
const params = request.params as { message?: string; connectionId?: string; attachments?: GatewayAttachment[]; metadata?: { isCommand?: boolean; command?: string } } | undefined;
if (!params?.message && !params?.metadata?.isCommand) {
return makeError(request.id, ErrorCode.InvalidRequest, 'message is required');
}
@@ -43,6 +43,20 @@ export function createAgentHandlers(deps: AgentHandlerDeps) {
return deps.laneQueue.enqueue(laneId, async () => {
deps.sessionBridge.setBusy(connectionId, true);
// Handle slash commands via metadata (mirrors daemon/routing.ts pattern)
if (params.metadata?.isCommand) {
try {
if (params.metadata.command === 'reset') {
agent.reset();
send(makeEvent(request.id, 'done', { content: 'Session reset.' }));
return;
}
} finally {
deps.sessionBridge.setBusy(connectionId, false);
deps.metrics?.endRequest(requestId);
}
}
// Set up tool use callback to emit streaming events
deps.sessionBridge.setOnToolUse(connectionId, (event) => {
if (event.type === 'start') {
+352 -16
View File
@@ -2,15 +2,30 @@
* Flynn Chat Page
*
* Session selector, message input, streaming tool events,
* and markdown-rendered responses.
* markdown-rendered responses, slash commands, and web search.
*/
/* global marked, hljs */
let _currentSession = null;
let _sending = false;
let _searchMode = false;
let _slashPopupIndex = -1;
let _elements = {};
// ── Slash Command Definitions ───────────────────────────────
const SLASH_COMMANDS = [
{ name: '/help', desc: 'Show available commands' },
{ name: '/reset', desc: 'Reset session' },
{ name: '/compact', desc: 'Compact context' },
{ name: '/usage', desc: 'Show token usage' },
{ name: '/status', desc: 'Show system health' },
{ name: '/model', desc: 'Show current model' },
];
// ── Helpers ─────────────────────────────────────────────────
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
@@ -40,7 +55,7 @@ function createMessageEl(role, content) {
const div = document.createElement('div');
div.className = `message ${role}`;
if (role === 'assistant') {
if (role === 'assistant' || role === 'system') {
div.innerHTML = renderMarkdown(content);
setTimeout(highlightCode, 0);
} else {
@@ -89,6 +104,238 @@ function scrollToBottom() {
}
}
// ── Search Mode ─────────────────────────────────────────────
function setSearchMode(active) {
_searchMode = active;
const btn = _elements.searchBtn;
const input = _elements.input;
if (!btn || !input) return;
if (active) {
btn.classList.add('active');
input.placeholder = 'What do you want to search for?';
} else {
btn.classList.remove('active');
input.placeholder = 'Type a message...';
}
input.focus();
}
// ── Slash Popup ─────────────────────────────────────────────
function getFilteredCommands(text) {
const prefix = text.toLowerCase();
return SLASH_COMMANDS.filter(c => c.name.startsWith(prefix));
}
function showSlashPopup(filtered) {
const popup = _elements.slashPopup;
if (!popup) return;
popup.innerHTML = '';
if (filtered.length === 0) {
hideSlashPopup();
return;
}
for (let i = 0; i < filtered.length; i++) {
const item = document.createElement('div');
item.className = 'slash-popup-item' + (i === _slashPopupIndex ? ' selected' : '');
item.innerHTML = `<span class="cmd-name">${escapeHtml(filtered[i].name)}</span><span class="cmd-desc">${escapeHtml(filtered[i].desc)}</span>`;
item.addEventListener('click', () => {
selectSlashCommand(filtered[i].name);
});
// Touch-friendly: handle pointerenter for hover on mobile
item.addEventListener('pointerenter', () => {
_slashPopupIndex = i;
updatePopupSelection(filtered);
});
popup.appendChild(item);
}
popup.classList.remove('hidden');
}
function hideSlashPopup() {
const popup = _elements.slashPopup;
if (popup) popup.classList.add('hidden');
_slashPopupIndex = -1;
}
function updatePopupSelection(filtered) {
const popup = _elements.slashPopup;
if (!popup) return;
const items = popup.querySelectorAll('.slash-popup-item');
items.forEach((el, i) => {
el.classList.toggle('selected', i === _slashPopupIndex);
});
}
function selectSlashCommand(name) {
const input = _elements.input;
if (!input) return;
input.value = name;
hideSlashPopup();
input.focus();
}
function handleSlashPopupInput() {
const input = _elements.input;
if (!input) return;
const text = input.value;
// Show popup only when text starts with / and is at most a single word (the command itself)
if (text.startsWith('/') && !text.includes(' ')) {
const filtered = getFilteredCommands(text);
// Clamp selection index
if (_slashPopupIndex >= filtered.length) _slashPopupIndex = filtered.length - 1;
showSlashPopup(filtered);
} else {
hideSlashPopup();
}
}
// ── Slash Command Handlers ──────────────────────────────────
function parseSlashCommand(text) {
const trimmed = text.trim();
if (!trimmed.startsWith('/')) return null;
const parts = trimmed.split(/\s+/);
const cmd = parts[0].toLowerCase();
const args = parts.slice(1).join(' ');
switch (cmd) {
case '/help': return { type: 'help' };
case '/reset': return { type: 'reset' };
case '/compact': return { type: 'compact' };
case '/usage': return { type: 'usage' };
case '/status': return { type: 'status' };
case '/model': return { type: 'model', args };
default: return null;
}
}
function showSystemMessage(content) {
_elements.messages.appendChild(createMessageEl('system', content));
scrollToBottom();
}
async function handleSlashCommand(cmd, client) {
switch (cmd.type) {
case 'help': {
const lines = [
'**Available Commands**',
'',
'| Command | Description |',
'|---------|-------------|',
'| `/help` | Show this help |',
'| `/reset` | Reset the current session |',
'| `/compact` | Ask the agent to compact context |',
'| `/usage` | Show token usage stats |',
'| `/status` | Show system health |',
'| `/model` | Show current model info |',
'',
'Type `/` to see autocomplete suggestions.',
];
showSystemMessage(lines.join('\n'));
return true;
}
case 'reset': {
try {
// Send reset command via metadata
const stream = client.stream('agent.send', {
message: '/reset',
metadata: { isCommand: true, command: 'reset' },
});
await stream.result;
_elements.messages.innerHTML = '';
showSystemMessage('Session reset.');
} catch (err) {
showSystemMessage(`Failed to reset: ${err.message}`);
}
return true;
}
case 'compact': {
// Send as a regular message — the agent will interpret the request
showSystemMessage('Requesting context compaction...');
return false; // Let it pass through as a normal message
}
case 'usage': {
try {
const result = await client.call('system.tokenUsage');
const sessions = result.sessions ?? [];
if (sessions.length === 0) {
showSystemMessage('No usage data available.');
} else {
const lines = ['**Token Usage**', ''];
let totalIn = 0, totalOut = 0, totalCalls = 0;
for (const s of sessions) {
totalIn += s.total?.inputTokens ?? 0;
totalOut += s.total?.outputTokens ?? 0;
totalCalls += s.total?.calls ?? 0;
}
lines.push(`**Input:** ${totalIn.toLocaleString()} tokens`);
lines.push(`**Output:** ${totalOut.toLocaleString()} tokens`);
lines.push(`**API Calls:** ${totalCalls}`);
if (sessions.length > 1) {
lines.push(`**Sessions:** ${sessions.length}`);
}
showSystemMessage(lines.join('\n'));
}
} catch (err) {
showSystemMessage(`Failed to fetch usage: ${err.message}`);
}
return true;
}
case 'status': {
try {
const result = await client.call('system.health');
const lines = [
'**System Status**',
'',
`**Uptime:** ${result.uptime ?? 'unknown'}`,
`**Status:** ${result.status ?? 'unknown'}`,
];
if (result.channels) {
lines.push('', '**Channels:**');
for (const ch of result.channels) {
const dot = ch.status === 'connected' ? '\\*' : '-';
lines.push(` ${dot} ${ch.name}: ${ch.status}`);
}
}
if (result.model) {
lines.push('', `**Model:** ${result.model}`);
}
showSystemMessage(lines.join('\n'));
} catch (err) {
showSystemMessage(`Failed to fetch status: ${err.message}`);
}
return true;
}
case 'model': {
try {
const result = await client.call('system.health');
const model = result.model ?? result.config?.model ?? 'unknown';
showSystemMessage(`**Current Model:** ${model}`);
} catch (err) {
showSystemMessage(`Failed to fetch model info: ${err.message}`);
}
return true;
}
default:
return false;
}
}
// ── Session Management ──────────────────────────────────────
async function loadSessions(client) {
const select = _elements.sessionSelect;
if (!select) return;
@@ -145,17 +392,37 @@ async function loadHistory(client) {
}
}
async function sendMessage(client) {
// ── Send Message ────────────────────────────────────────────
async function sendMessage(client, overrideText) {
const input = _elements.input;
const text = input?.value?.trim();
if (!text || _sending) return;
const rawText = overrideText ?? input?.value?.trim();
if (!rawText || _sending) return;
// Check for slash commands first
const cmd = parseSlashCommand(rawText);
if (cmd) {
if (!overrideText) input.value = '';
hideSlashPopup();
const handled = await handleSlashCommand(cmd, client);
if (handled) return;
// If not fully handled (e.g. /compact), fall through to send as message
}
_sending = true;
_elements.sendBtn.disabled = true;
input.value = '';
if (!overrideText) input.value = '';
// Show user message
_elements.messages.appendChild(createMessageEl('user', text));
// Apply search mode prefix
let messageText = rawText;
if (_searchMode && !rawText.startsWith('/')) {
messageText = `Search the web for: ${rawText}`;
setSearchMode(false);
}
// Show user message (show original text, not the prefixed version)
const displayText = _searchMode ? rawText : messageText;
_elements.messages.appendChild(createMessageEl('user', rawText));
scrollToBottom();
// Create placeholder for assistant response
@@ -166,7 +433,7 @@ async function sendMessage(client) {
scrollToBottom();
try {
const stream = client.stream('agent.send', { message: text });
const stream = client.stream('agent.send', { message: messageText });
stream.on('tool_start', (data) => {
const el = createToolEventEl('tool_start', data);
@@ -211,19 +478,34 @@ async function sendMessage(client) {
}
}
// ── Search SVG Icon ─────────────────────────────────────────
const SEARCH_ICON = `<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M8.5 3a5.5 5.5 0 0 1 4.38 8.82l4.15 4.15a.75.75 0 0 1-1.06 1.06l-4.15-4.15A5.5 5.5 0 1 1 8.5 3zm0 1.5a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" fill="currentColor"/></svg>`;
// ── Page Export ──────────────────────────────────────────────
export const ChatPage = {
async render(el, client) {
el.innerHTML = `
<div class="chat-layout">
<div class="chat-header">
<select id="chat-session-select"></select>
<button id="chat-new-session" class="btn btn-secondary">+ New Session</button>
<button id="chat-load-history" class="btn btn-secondary">Load History</button>
<button id="chat-new-session" class="btn btn-secondary">+ New</button>
<button id="chat-load-history" class="btn btn-secondary">History</button>
</div>
<div class="chat-messages" id="chat-messages"></div>
<div class="chat-input">
<textarea id="chat-input" placeholder="Type a message..." rows="1"></textarea>
<button id="chat-send" class="btn btn-primary">Send</button>
<div class="chat-actions">
<button id="chat-search" class="btn-action" title="Search the web">
${SEARCH_ICON}
<span class="btn-action-label">Search</span>
</button>
</div>
<div class="chat-input-wrapper">
<div id="slash-popup" class="slash-popup hidden"></div>
<div class="chat-input">
<textarea id="chat-input" placeholder="Type a message..." rows="1"></textarea>
<button id="chat-send" class="btn btn-primary">Send</button>
</div>
</div>
</div>
`;
@@ -233,6 +515,8 @@ export const ChatPage = {
messages: el.querySelector('#chat-messages'),
input: el.querySelector('#chat-input'),
sendBtn: el.querySelector('#chat-send'),
searchBtn: el.querySelector('#chat-search'),
slashPopup: el.querySelector('#slash-popup'),
};
// Load sessions into dropdown
@@ -260,22 +544,72 @@ export const ChatPage = {
loadHistory(client);
});
// Event: search button toggle
_elements.searchBtn.addEventListener('click', () => {
setSearchMode(!_searchMode);
});
// Event: send message
_elements.sendBtn.addEventListener('click', () => sendMessage(client));
// Event: Enter to send (Shift+Enter for newline)
// Event: keyboard in textarea
_elements.input.addEventListener('keydown', (e) => {
const popup = _elements.slashPopup;
const isPopupVisible = popup && !popup.classList.contains('hidden');
// Handle slash popup navigation
if (isPopupVisible) {
const text = _elements.input.value;
const filtered = getFilteredCommands(text);
if (e.key === 'ArrowDown') {
e.preventDefault();
_slashPopupIndex = Math.min(_slashPopupIndex + 1, filtered.length - 1);
updatePopupSelection(filtered);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
_slashPopupIndex = Math.max(_slashPopupIndex - 1, 0);
updatePopupSelection(filtered);
return;
}
if ((e.key === 'Enter' || e.key === 'Tab') && _slashPopupIndex >= 0 && _slashPopupIndex < filtered.length) {
e.preventDefault();
selectSlashCommand(filtered[_slashPopupIndex].name);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
hideSlashPopup();
return;
}
}
// Enter to send (Shift+Enter for newline)
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
hideSlashPopup();
sendMessage(client);
}
});
// Auto-resize textarea
// Event: input changes for slash popup + auto-resize
_elements.input.addEventListener('input', () => {
// Auto-resize textarea
const ta = _elements.input;
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, 150) + 'px';
// Slash command popup
handleSlashPopupInput();
});
// Dismiss slash popup on outside click
el.addEventListener('click', (e) => {
if (!e.target.closest('.chat-input-wrapper')) {
hideSlashPopup();
}
});
// If there's a current session, show welcome
@@ -287,6 +621,8 @@ export const ChatPage = {
teardown() {
_currentSession = null;
_sending = false;
_searchMode = false;
_slashPopupIndex = -1;
_elements = {};
},
};
+158
View File
@@ -529,6 +529,30 @@ header #status.status-ok {
.card .value {
font-size: var(--font-size-lg);
}
.chat-layout {
height: calc(100vh - 32px);
}
.chat-header {
padding-bottom: 8px;
margin-bottom: 8px;
}
.btn-action {
font-size: 11px;
padding: 6px 8px;
min-height: 36px;
}
.slash-popup-item {
padding: 10px 12px;
}
.slash-popup-item .cmd-name {
min-width: 70px;
font-size: var(--font-size-sm);
}
}
/* ==========================================================================
@@ -904,6 +928,117 @@ tr:hover td {
cursor: not-allowed;
}
/* Chat actions bar (search button, etc.) */
.chat-actions {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
}
.btn-action {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 16px;
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border);
font-family: var(--font-mono);
font-size: var(--font-size-sm);
cursor: pointer;
white-space: nowrap;
user-select: none;
transition: all var(--transition);
}
.btn-action:hover {
color: var(--text-primary);
border-color: var(--text-muted);
background: var(--bg-secondary);
}
.btn-action.active {
background: var(--accent-muted);
color: var(--accent);
border-color: var(--accent);
}
.btn-action svg {
width: 14px;
height: 14px;
flex-shrink: 0;
fill: currentColor;
}
/* Slash command autocomplete popup */
.chat-input-wrapper {
position: relative;
}
.slash-popup {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
margin-bottom: 4px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
max-height: 240px;
overflow-y: auto;
z-index: 100;
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.3);
}
.slash-popup-item {
display: flex;
align-items: baseline;
gap: 12px;
padding: 8px 12px;
cursor: pointer;
transition: background var(--transition);
}
.slash-popup-item:hover,
.slash-popup-item.selected {
background: var(--bg-tertiary);
}
.slash-popup-item .cmd-name {
color: var(--accent);
font-weight: 600;
font-size: var(--font-size-base);
min-width: 80px;
}
.slash-popup-item .cmd-desc {
color: var(--text-muted);
font-size: var(--font-size-sm);
}
/* System messages */
.message.system {
align-self: stretch;
max-width: 100%;
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border-light);
font-size: var(--font-size-sm);
}
.message.system strong {
color: var(--text-primary);
}
.message.system code {
color: var(--accent);
background: var(--bg-secondary);
padding: 1px 4px;
border-radius: 3px;
}
/* Streaming text cursor */
.streaming-cursor::after {
content: '|';
@@ -1212,4 +1347,27 @@ tr:hover td {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.chat-header {
flex-wrap: wrap;
gap: 8px;
}
.chat-header select {
min-width: 0;
flex: 1;
}
.chat-actions {
padding: 6px 0;
}
.btn-action {
padding: 8px 10px;
min-height: 36px;
}
.slash-popup {
max-height: 200px;
}
}