diff --git a/docs/plans/state.json b/docs/plans/state.json index c2e6d75..3ecb877 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -962,6 +962,34 @@ ], "test_status": "21/21 passing (html.test) + 18/18 passing (gmail.test) + 16/16 passing (automation/gmail.test)" }, + "webchat-slash-commands": { + "status": "completed", + "date": "2026-02-10", + "summary": "Slash commands, autocomplete popup, and web search button for the webchat SPA. 6 commands: /help, /reset, /compact, /usage, /status, /model. Search button toggles web search mode (prepends instruction to message). Backend agent.send extended with metadata for command routing.", + "phases": { + "backend_command_handling": { + "status": "completed", + "description": "Extended agent.send RPC handler to accept optional metadata (isCommand, command). /reset handled server-side via agent.reset().", + "files_modified": [ + "src/gateway/handlers/agent.ts" + ] + }, + "frontend_slash_commands": { + "status": "completed", + "description": "Slash command parsing, autocomplete popup (arrow keys, Enter/Tab/Escape), search button toggle, system messages, /help /reset /compact /usage /status /model handlers", + "files_modified": [ + "src/gateway/ui/pages/chat.js" + ] + }, + "css_styling": { + "status": "completed", + "description": "Styles for .chat-actions, .btn-action, .slash-popup, .slash-popup-item, .message.system, .chat-input-wrapper, plus responsive breakpoints for 600px and 768px", + "files_modified": [ + "src/gateway/ui/style.css" + ] + } + } + }, "tui-fullscreen-improvements": { "status": "completed", "date": "2026-02-10", diff --git a/src/gateway/handlers/agent.ts b/src/gateway/handlers/agent.ts index 0aebf80..e593a9d 100644 --- a/src/gateway/handlers/agent.ts +++ b/src/gateway/handlers/agent.ts @@ -15,8 +15,8 @@ export interface AgentHandlerDeps { export function createAgentHandlers(deps: AgentHandlerDeps) { return { 'agent.send': async (request: GatewayRequest, send: SendFn): Promise => { - 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') { diff --git a/src/gateway/ui/pages/chat.js b/src/gateway/ui/pages/chat.js index 1bd7cba..db0abff 100644 --- a/src/gateway/ui/pages/chat.js +++ b/src/gateway/ui/pages/chat.js @@ -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 = `${escapeHtml(filtered[i].name)}${escapeHtml(filtered[i].desc)}`; + 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 = ``; + +// ── Page Export ────────────────────────────────────────────── + export const ChatPage = { async render(el, client) { el.innerHTML = `
- - + +
-
- - +
+ +
+
+ +
+ + +
`; @@ -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 = {}; }, }; diff --git a/src/gateway/ui/style.css b/src/gateway/ui/style.css index b70f770..f0cc747 100644 --- a/src/gateway/ui/style.css +++ b/src/gateway/ui/style.css @@ -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; + } }