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:
@@ -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
@@ -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 = {};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user