<script lang="ts">
import { onMount } from 'svelte';
import { appStore } from '$lib/store.svelte.js';
import { layoutStore, type Mode } from '$lib/stores/layout.svelte.js';
import { fetchFiles, type FileEntry } from '$lib/api.js';
import Autocomplete from '../shared/Autocomplete.svelte';
import type { AutocompleteItem } from '../shared/Autocomplete.svelte';
export type CommandResult =
| { type: 'handled'; message?: string }
| { type: 'send'; message: string }
| { type: 'none' };
let {
onsubmit,
oncommand
}: {
onsubmit: (msg: string, mode: string) => void;
oncommand?: (cmd: string, arg: string | undefined) => CommandResult;
} = $props();
let inputValue = $state('');
let textareaEl: HTMLTextAreaElement | undefined = $state();
// Autocomplete state
let acVisible = $state(false);
let acSelectedIndex = $state(0);
let acTrigger: '/' | '@' | null = $state(null);
let acTriggerPos = $state(0);
// Dynamic @ mention state
let mentionQuery = $state('');
let fileCache = $state<FileEntry[]>([]);
let isLoading = $state(false);
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
// Focus management: refocus after agent completes
$effect(() => {
if (!appStore.agentActive && textareaEl) {
requestAnimationFrame(() => textareaEl?.focus());
}
});
onMount(() => {
textareaEl?.focus();
});
const slashCommands: AutocompleteItem[] = [
{ label: '/help', description: 'Show available commands' },
{ label: '/mode', description: 'Switch mode (code/ask/architect)' },
{ label: '/clear', description: 'Clear chat history' },
{ label: '/theme', description: 'Toggle light/dark theme' },
{ label: '/model', description: 'Change model' },
{ label: '/status', description: 'Show agent status' },
{ label: '/compact', description: 'Compact context' },
{ label: '/resume', description: 'Resume last session' },
{ label: '/skills', description: 'List available skills' },
{ label: '/agents', description: 'List available agents' },
{ label: '/cost', description: 'Show token cost summary' },
{ label: '/optimize', description: 'Run self-tuning optimizer' }
];
const defaultAgents: AutocompleteItem[] = [
{ label: '@explain', description: 'Explain code behavior', icon: '◆' },
{ label: '@review-code', description: 'Review for bugs & issues', icon: '◆' },
{ label: '@test-gen', description: 'Generate unit tests', icon: '◆' },
{ label: '@commit-msg', description: 'Generate commit message', icon: '◆' }
];
let acItems = $derived.by(() => {
if (!acTrigger) return [];
if (acTrigger === '/') {
const query = inputValue.slice(acTriggerPos).toLowerCase();
if (!query) return slashCommands;
return slashCommands.filter((item) => item.label.toLowerCase().includes(query));
}
const query = mentionQuery.toLowerCase();
const items: AutocompleteItem[] = [];
const hiveAgents = [...appStore.hiveAgents.values()];
if (hiveAgents.length > 0) {
for (const agent of hiveAgents) {
const label = `@${agent.name}`;
if (!query || label.toLowerCase().includes(query)) {
items.push({ label, description: agent.task || 'Swarm agent', icon: '◆' });
}
}
} else {
for (const agent of defaultAgents) {
if (!query || agent.label.toLowerCase().includes(query)) {
items.push(agent);
}
}
}
for (const entry of fileCache) {
const label = entry.is_dir ? `@${entry.path}/` : `@${entry.path}`;
if (!query || label.toLowerCase().includes(query)) {
items.push({
label,
description: entry.is_dir ? 'Directory' : `File (${formatSize(entry.size)})`,
icon: entry.is_dir ? '📁' : '📄'
});
}
}
return items;
});
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
async function loadFiles(path: string) {
if (isLoading) return;
isLoading = true;
try {
const token = appStore.authToken ?? undefined;
fileCache = await fetchFiles(path, token);
} catch {
fileCache = [];
} finally {
isLoading = false;
}
}
function updateAutocomplete() {
const val = inputValue;
const cursorPos = textareaEl?.selectionStart ?? val.length;
if (val.startsWith('/')) {
acTrigger = '/';
acTriggerPos = 1;
acSelectedIndex = 0;
acVisible = true;
return;
}
const beforeCursor = val.slice(0, cursorPos);
const atIdx = beforeCursor.lastIndexOf('@');
if (atIdx >= 0) {
const afterAt = beforeCursor.slice(atIdx + 1);
if (!/\s/.test(afterAt)) {
acTrigger = '@';
acTriggerPos = atIdx + 1;
mentionQuery = afterAt;
acSelectedIndex = 0;
acVisible = true;
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const lastSlash = mentionQuery.lastIndexOf('/');
const dirPath = lastSlash > 0 ? mentionQuery.slice(0, lastSlash) : '';
loadFiles(dirPath);
}, 150);
return;
}
}
closeAutocomplete();
}
function closeAutocomplete() {
acVisible = false;
acTrigger = null;
mentionQuery = '';
if (searchTimeout) {
clearTimeout(searchTimeout);
searchTimeout = null;
}
}
function selectItem(item: AutocompleteItem) {
const cursorPos = textareaEl?.selectionStart ?? inputValue.length;
if (acTrigger === '/') {
const after = inputValue.slice(cursorPos);
inputValue = item.label + ' ' + after;
} else {
const beforeCursor = inputValue.slice(0, cursorPos);
const atIdx = beforeCursor.lastIndexOf('@');
if (atIdx >= 0) {
const before = inputValue.slice(0, atIdx);
const after = inputValue.slice(cursorPos);
const suffix = after.startsWith(' ') ? after : ' ' + after;
inputValue = before + item.label + suffix;
}
}
closeAutocomplete();
requestAnimationFrame(() => {
textareaEl?.focus();
const pos = inputValue.indexOf(' ', inputValue.indexOf(item.label)) + 1 || inputValue.length;
textareaEl?.setSelectionRange(pos, pos);
});
}
function handleKeydown(e: KeyboardEvent) {
if (acVisible && acItems.length > 0) {
if (e.key === 'ArrowDown') { e.preventDefault(); acSelectedIndex = (acSelectedIndex + 1) % acItems.length; return; }
if (e.key === 'ArrowUp') { e.preventDefault(); acSelectedIndex = (acSelectedIndex - 1 + acItems.length) % acItems.length; return; }
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); selectItem(acItems[acSelectedIndex]); return; }
if (e.key === 'Escape') { e.preventDefault(); closeAutocomplete(); return; }
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
}
function handleInput(e: Event) {
autoGrow(e);
updateAutocomplete();
}
function defaultCommandHandler(cmd: string, arg: string | undefined): CommandResult {
switch (cmd) {
case '/help':
return {
type: 'handled',
message: `Available commands:
• /mode [code|ask|architect] — Switch agent mode
• /clear — Clear chat history
• /theme — Toggle light/dark theme
• /model — Change model (send to agent)
• /status — Show agent status
• /compact — Compact context
• /resume — Resume last session
• /skills — List skills
• /agents — List agents
• /cost — Token cost summary
• /optimize — Self-tuning optimizer`
};
case '/mode':
if (arg && ['code', 'ask', 'architect'].includes(arg)) {
layoutStore.mode = arg as Mode;
return { type: 'handled', message: `Mode → ${arg}` };
}
return { type: 'handled', message: `Current mode: ${layoutStore.mode}\nUsage: /mode [code|ask|architect]` };
case '/clear':
appStore.reset();
return { type: 'handled', message: 'Chat history cleared' };
case '/theme':
layoutStore.toggleTheme();
return { type: 'handled', message: `Theme: ${layoutStore.theme}` };
case '/model':
case '/status':
case '/compact':
case '/resume':
case '/skills':
case '/agents':
case '/cost':
case '/optimize':
return { type: 'send', message: arg ? `${cmd} ${arg}` : cmd };
default:
return { type: 'none' };
}
}
function submit() {
const msg = inputValue.trim();
if (!msg) return;
inputValue = '';
closeAutocomplete();
if (textareaEl) textareaEl.style.height = 'auto';
if (msg.startsWith('/')) {
const [cmd, ...argParts] = msg.split(/\s+/);
const arg = argParts.length > 0 ? argParts.join(' ') : undefined;
const handler = oncommand ?? defaultCommandHandler;
const result = handler(cmd, arg);
switch (result.type) {
case 'handled':
if (result.message) appStore.addSystemMessage(result.message);
break;
case 'send':
// Agent commands: queue if busy, else send immediately
if (appStore.agentActive) {
appStore.queueMessage(result.message);
appStore.addSystemMessage(`⟳ Queued: ${result.message}`);
} else {
onsubmit(result.message, layoutStore.mode);
}
break;
case 'none':
if (appStore.agentActive) {
appStore.queueMessage(msg);
appStore.addSystemMessage(`⟳ Queued: "${msg.length > 50 ? msg.slice(0, 50) + '…' : msg}"`);
} else {
onsubmit(msg, layoutStore.mode);
}
break;
}
} else if (appStore.agentActive) {
// Buffer the message — will auto-send when agent finishes
appStore.queueMessage(msg);
const preview = msg.length > 50 ? msg.slice(0, 50) + '…' : msg;
appStore.addSystemMessage(`⟳ Queued: "${preview}"`);
} else {
onsubmit(msg, layoutStore.mode);
}
requestAnimationFrame(() => textareaEl?.focus());
}
function autoGrow(e: Event) {
const el = e.target as HTMLTextAreaElement;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
}
const queueCount = $derived(appStore.messageQueue.length);
</script>
<div class="input-bar">
<div class="input-wrapper">
<Autocomplete
items={acItems}
visible={acVisible}
selectedIndex={acSelectedIndex}
onselect={selectItem}
/>
<div class="input-row">
<span class="prompt-icon" class:active={!appStore.agentActive} aria-hidden="true">❯</span>
<textarea
bind:this={textareaEl}
bind:value={inputValue}
class="input-field"
rows={1}
placeholder={appStore.agentActive
? `Agent working… (${queueCount} queued — keep typing to buffer)`
: 'Message (Enter to send, Shift+Enter for new line, / for commands, @ for agents/files)'}
onkeydown={handleKeydown}
oninput={handleInput}
aria-label="Message input"
></textarea>
{#if isLoading}
<span class="loading-dot" aria-hidden="true">…</span>
{/if}
{#if queueCount > 0}
<span class="queue-badge" title="{queueCount} message(s) queued">{queueCount}</span>
{/if}
</div>
{#if appStore.agentActive}
<div class="agent-bar" aria-live="polite">
<span class="spinner" aria-hidden="true"></span>
<span class="agent-label">Agent working</span>
{#if appStore.phase}
<span class="phase">· {appStore.phase}</span>
{/if}
</div>
{/if}
</div>
</div>
<style>
.input-bar {
background: var(--bg);
border-top: 1px solid var(--border);
padding: 0.375rem 0.75rem 0.5rem;
flex-shrink: 0;
}
.input-wrapper {
position: relative;
}
.input-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.prompt-icon {
flex-shrink: 0;
font-size: 0.9375rem;
line-height: 1.5rem;
color: var(--fg-dim);
user-select: none;
margin-top: 0.0625rem;
transition: color 0.15s;
}
.prompt-icon.active {
color: var(--accent);
}
.input-field {
flex: 1;
background: transparent;
color: var(--fg);
border: none;
font-family: var(--font-mono);
font-size: 0.9375rem;
line-height: 1.5;
resize: none;
overflow-y: auto;
max-height: 200px;
outline: none;
box-sizing: border-box;
}
.input-field::placeholder {
color: var(--fg-dim);
font-size: 0.875rem;
}
.loading-dot {
flex-shrink: 0;
align-self: center;
font-size: 0.75rem;
color: var(--fg-dim);
animation: pulse 1s infinite;
}
.queue-badge {
flex-shrink: 0;
align-self: center;
font-size: 0.6875rem;
background: var(--yellow);
color: var(--bg);
border-radius: 9999px;
padding: 0.0625rem 0.375rem;
font-weight: 700;
}
.agent-bar {
display: flex;
align-items: center;
gap: 0.375rem;
margin-top: 0.25rem;
font-size: 0.75rem;
color: var(--fg-dim);
}
.spinner {
width: 0.625rem;
height: 0.625rem;
border: 1.5px solid var(--accent);
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
.agent-label {
color: var(--accent);
}
.phase {
color: var(--yellow);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
</style>