feat(mosaic): merge @mosaic/cli into @mosaic/mosaic
@mosaic/mosaic is now the single package providing both: - 'mosaic' binary (CLI: yolo, coord, prdy, tui, gateway, etc.) - 'mosaic-wizard' binary (installation wizard) Changes: - Move packages/cli/src/* into packages/mosaic/src/ - Convert dynamic @mosaic/mosaic imports to static relative imports - Add CLI deps (ink, react, socket.io-client, @mosaic/config) to mosaic - Add jsx: react-jsx to mosaic's tsconfig - Exclude packages/cli from workspace (pnpm-workspace.yaml) - Update install.sh to install @mosaic/mosaic instead of @mosaic/cli - Bump version to 0.0.17 This eliminates the circular dependency between @mosaic/cli and @mosaic/mosaic that was blocking the build graph.
This commit is contained in:
468
packages/mosaic/src/tui/app.tsx
Normal file
468
packages/mosaic/src/tui/app.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { Box, useApp, useInput } from 'ink';
|
||||
import type { ParsedCommand } from '@mosaic/types';
|
||||
import { TopBar } from './components/top-bar.js';
|
||||
import { BottomBar } from './components/bottom-bar.js';
|
||||
import { MessageList } from './components/message-list.js';
|
||||
import { InputBar } from './components/input-bar.js';
|
||||
import { Sidebar } from './components/sidebar.js';
|
||||
import { SearchBar } from './components/search-bar.js';
|
||||
import { useSocket } from './hooks/use-socket.js';
|
||||
import { useGitInfo } from './hooks/use-git-info.js';
|
||||
import { useViewport } from './hooks/use-viewport.js';
|
||||
import { useAppMode } from './hooks/use-app-mode.js';
|
||||
import { useConversations } from './hooks/use-conversations.js';
|
||||
import { useSearch } from './hooks/use-search.js';
|
||||
import { executeHelp, executeStatus, executeHistory, commandRegistry } from './commands/index.js';
|
||||
import { fetchConversationMessages } from './gateway-api.js';
|
||||
import { expandFileRefs, hasFileRefs, handleAttachCommand } from './file-ref.js';
|
||||
|
||||
export interface TuiAppProps {
|
||||
gatewayUrl: string;
|
||||
conversationId?: string;
|
||||
sessionCookie?: string;
|
||||
initialModel?: string;
|
||||
initialProvider?: string;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
projectId?: string;
|
||||
/** CLI package version passed from the entry point (cli.ts). */
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export function TuiApp({
|
||||
gatewayUrl,
|
||||
conversationId,
|
||||
sessionCookie,
|
||||
initialModel,
|
||||
initialProvider,
|
||||
agentId,
|
||||
agentName,
|
||||
projectId: _projectId,
|
||||
version = '0.0.0',
|
||||
}: TuiAppProps) {
|
||||
const { exit } = useApp();
|
||||
const gitInfo = useGitInfo();
|
||||
const appMode = useAppMode();
|
||||
|
||||
const socket = useSocket({
|
||||
gatewayUrl,
|
||||
sessionCookie,
|
||||
initialConversationId: conversationId,
|
||||
initialModel,
|
||||
initialProvider,
|
||||
agentId,
|
||||
});
|
||||
|
||||
const conversations = useConversations({ gatewayUrl, sessionCookie });
|
||||
|
||||
const viewport = useViewport({ totalItems: socket.messages.length });
|
||||
|
||||
const search = useSearch(socket.messages);
|
||||
|
||||
// Scroll to current match when it changes
|
||||
const currentMatch = search.matches[search.currentMatchIndex];
|
||||
useEffect(() => {
|
||||
if (currentMatch && appMode.mode === 'search') {
|
||||
viewport.scrollTo(currentMatch.messageIndex);
|
||||
}
|
||||
}, [currentMatch, appMode.mode, viewport]);
|
||||
|
||||
// Compute highlighted message indices for MessageList
|
||||
const highlightedMessageIndices = useMemo(() => {
|
||||
if (search.matches.length === 0) return undefined;
|
||||
return new Set(search.matches.map((m) => m.messageIndex));
|
||||
}, [search.matches]);
|
||||
|
||||
const currentHighlightIndex = currentMatch?.messageIndex;
|
||||
|
||||
const [sidebarSelectedIndex, setSidebarSelectedIndex] = useState(0);
|
||||
|
||||
// Controlled input state — held here so Ctrl+C can clear it
|
||||
const [tuiInput, setTuiInput] = useState('');
|
||||
// Ctrl+C double-press: first press with empty input shows hint; second exits
|
||||
const ctrlCPendingExit = useRef(false);
|
||||
// Flag to suppress the character that ink-text-input leaks when a Ctrl+key
|
||||
// combo is handled by the top-level useInput handler (e.g. Ctrl+T → 't').
|
||||
const ctrlJustFired = useRef(false);
|
||||
|
||||
// Wrap sendMessage to expand @file references before sending
|
||||
const sendMessageWithFileRefs = useCallback(
|
||||
(content: string) => {
|
||||
if (!hasFileRefs(content)) {
|
||||
socket.sendMessage(content);
|
||||
return;
|
||||
}
|
||||
void expandFileRefs(content)
|
||||
.then(({ expandedMessage, filesAttached, errors }) => {
|
||||
for (const err of errors) {
|
||||
socket.addSystemMessage(err);
|
||||
}
|
||||
if (filesAttached.length > 0) {
|
||||
socket.addSystemMessage(
|
||||
`📎 Attached ${filesAttached.length} file(s): ${filesAttached.join(', ')}`,
|
||||
);
|
||||
}
|
||||
socket.sendMessage(expandedMessage);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
socket.addSystemMessage(
|
||||
`File expansion failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
// Send original message without expansion
|
||||
socket.sendMessage(content);
|
||||
});
|
||||
},
|
||||
[socket],
|
||||
);
|
||||
|
||||
const handleLocalCommand = useCallback(
|
||||
(parsed: ParsedCommand) => {
|
||||
switch (parsed.command) {
|
||||
case 'help':
|
||||
case 'h': {
|
||||
const result = executeHelp(parsed);
|
||||
socket.addSystemMessage(result);
|
||||
break;
|
||||
}
|
||||
case 'status':
|
||||
case 's': {
|
||||
const result = executeStatus(parsed, {
|
||||
connected: socket.connected,
|
||||
model: socket.modelName,
|
||||
provider: socket.providerName,
|
||||
sessionId: socket.conversationId ?? null,
|
||||
tokenCount: socket.tokenUsage.total,
|
||||
});
|
||||
socket.addSystemMessage(result);
|
||||
break;
|
||||
}
|
||||
case 'clear':
|
||||
socket.clearMessages();
|
||||
break;
|
||||
case 'new':
|
||||
case 'n':
|
||||
void conversations
|
||||
.createConversation()
|
||||
.then((conv) => {
|
||||
if (conv) {
|
||||
socket.switchConversation(conv.id);
|
||||
appMode.setMode('chat');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
socket.addSystemMessage('Failed to create new conversation.');
|
||||
});
|
||||
break;
|
||||
case 'attach': {
|
||||
if (!parsed.args) {
|
||||
socket.addSystemMessage('Usage: /attach <file-path>');
|
||||
break;
|
||||
}
|
||||
void handleAttachCommand(parsed.args)
|
||||
.then(({ content, error }) => {
|
||||
if (error) {
|
||||
socket.addSystemMessage(`Attach error: ${error}`);
|
||||
} else if (content) {
|
||||
// Send the file content as a user message
|
||||
socket.sendMessage(content);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
socket.addSystemMessage(
|
||||
`Attach failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'stop':
|
||||
if (socket.isStreaming && socket.socketRef.current?.connected && socket.conversationId) {
|
||||
socket.socketRef.current.emit('abort', {
|
||||
conversationId: socket.conversationId,
|
||||
});
|
||||
socket.addSystemMessage('Abort signal sent.');
|
||||
} else {
|
||||
socket.addSystemMessage('No active stream to stop.');
|
||||
}
|
||||
break;
|
||||
case 'cost': {
|
||||
const u = socket.tokenUsage;
|
||||
socket.addSystemMessage(
|
||||
`Tokens — input: ${u.input}, output: ${u.output}, total: ${u.total}\nCost: $${u.cost.toFixed(6)}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'history':
|
||||
case 'hist': {
|
||||
void executeHistory({
|
||||
conversationId: socket.conversationId,
|
||||
gatewayUrl,
|
||||
sessionCookie,
|
||||
fetchMessages: fetchConversationMessages,
|
||||
})
|
||||
.then((result) => {
|
||||
socket.addSystemMessage(result);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
socket.addSystemMessage(`Failed to fetch history: ${msg}`);
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
socket.addSystemMessage(`Local command not implemented: /${parsed.command}`);
|
||||
}
|
||||
},
|
||||
[socket],
|
||||
);
|
||||
|
||||
const handleGatewayCommand = useCallback(
|
||||
(parsed: ParsedCommand) => {
|
||||
if (!socket.socketRef.current?.connected) {
|
||||
socket.addSystemMessage('Not connected to gateway. Command cannot be executed.');
|
||||
return;
|
||||
}
|
||||
socket.socketRef.current.emit('command:execute', {
|
||||
conversationId: socket.conversationId ?? '',
|
||||
command: parsed.command,
|
||||
args: parsed.args ?? undefined,
|
||||
});
|
||||
},
|
||||
[socket],
|
||||
);
|
||||
|
||||
const handleSwitchConversation = useCallback(
|
||||
(id: string) => {
|
||||
socket.switchConversation(id);
|
||||
appMode.setMode('chat');
|
||||
},
|
||||
[socket, appMode],
|
||||
);
|
||||
|
||||
const handleDeleteConversation = useCallback(
|
||||
(id: string) => {
|
||||
void conversations
|
||||
.deleteConversation(id)
|
||||
.then((ok) => {
|
||||
if (ok && id === socket.conversationId) {
|
||||
socket.clearMessages();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
[conversations, socket],
|
||||
);
|
||||
|
||||
useInput((ch, key) => {
|
||||
// Ctrl+C: clear input → show hint → second empty press exits
|
||||
if (key.ctrl && ch === 'c') {
|
||||
if (tuiInput) {
|
||||
setTuiInput('');
|
||||
ctrlCPendingExit.current = false;
|
||||
} else if (ctrlCPendingExit.current) {
|
||||
exit();
|
||||
} else {
|
||||
ctrlCPendingExit.current = true;
|
||||
socket.addSystemMessage('Press Ctrl+C again to exit.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Any other key resets the pending-exit flag
|
||||
ctrlCPendingExit.current = false;
|
||||
// Ctrl+L: toggle sidebar (refresh on open)
|
||||
if (key.ctrl && ch === 'l') {
|
||||
ctrlJustFired.current = true;
|
||||
queueMicrotask(() => {
|
||||
ctrlJustFired.current = false;
|
||||
});
|
||||
const willOpen = !appMode.sidebarOpen;
|
||||
appMode.toggleSidebar();
|
||||
if (willOpen) {
|
||||
void conversations.refresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Ctrl+N: create new conversation and switch to it
|
||||
if (key.ctrl && ch === 'n') {
|
||||
ctrlJustFired.current = true;
|
||||
queueMicrotask(() => {
|
||||
ctrlJustFired.current = false;
|
||||
});
|
||||
void conversations
|
||||
.createConversation()
|
||||
.then((conv) => {
|
||||
if (conv) {
|
||||
socket.switchConversation(conv.id);
|
||||
appMode.setMode('chat');
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
// Ctrl+K: toggle search mode
|
||||
if (key.ctrl && ch === 'k') {
|
||||
ctrlJustFired.current = true;
|
||||
queueMicrotask(() => {
|
||||
ctrlJustFired.current = false;
|
||||
});
|
||||
if (appMode.mode === 'search') {
|
||||
search.clear();
|
||||
appMode.setMode('chat');
|
||||
} else {
|
||||
appMode.setMode('search');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Page Up / Page Down: scroll message history (only in chat mode)
|
||||
if (appMode.mode === 'chat') {
|
||||
if (key.pageUp) {
|
||||
viewport.scrollBy(-viewport.viewportSize);
|
||||
}
|
||||
if (key.pageDown) {
|
||||
viewport.scrollBy(viewport.viewportSize);
|
||||
}
|
||||
}
|
||||
// Ctrl+T: cycle thinking level
|
||||
if (key.ctrl && ch === 't') {
|
||||
ctrlJustFired.current = true;
|
||||
queueMicrotask(() => {
|
||||
ctrlJustFired.current = false;
|
||||
});
|
||||
const levels = socket.availableThinkingLevels;
|
||||
if (levels.length > 0) {
|
||||
const currentIdx = levels.indexOf(socket.thinkingLevel);
|
||||
const nextIdx = (currentIdx + 1) % levels.length;
|
||||
const next = levels[nextIdx];
|
||||
if (next) {
|
||||
socket.setThinkingLevel(next);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Escape: return to chat from sidebar/search; in chat, scroll to bottom
|
||||
if (key.escape) {
|
||||
if (appMode.mode === 'search') {
|
||||
search.clear();
|
||||
appMode.setMode('chat');
|
||||
} else if (appMode.mode === 'sidebar') {
|
||||
appMode.setMode('chat');
|
||||
} else if (appMode.mode === 'chat') {
|
||||
viewport.scrollToBottom();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const inputPlaceholder =
|
||||
appMode.mode === 'sidebar'
|
||||
? 'focus is on sidebar… press Esc to return'
|
||||
: appMode.mode === 'search'
|
||||
? 'search mode… press Esc to return'
|
||||
: undefined;
|
||||
|
||||
const isSearchMode = appMode.mode === 'search';
|
||||
|
||||
const messageArea = (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<MessageList
|
||||
messages={socket.messages}
|
||||
isStreaming={socket.isStreaming}
|
||||
currentStreamText={socket.currentStreamText}
|
||||
currentThinkingText={socket.currentThinkingText}
|
||||
activeToolCalls={socket.activeToolCalls}
|
||||
scrollOffset={viewport.scrollOffset}
|
||||
viewportSize={viewport.viewportSize}
|
||||
isScrolledUp={viewport.isScrolledUp}
|
||||
highlightedMessageIndices={highlightedMessageIndices}
|
||||
currentHighlightIndex={currentHighlightIndex}
|
||||
/>
|
||||
|
||||
{isSearchMode && (
|
||||
<SearchBar
|
||||
query={search.query}
|
||||
onQueryChange={search.setQuery}
|
||||
totalMatches={search.totalMatches}
|
||||
currentMatch={search.currentMatchIndex}
|
||||
onNext={search.nextMatch}
|
||||
onPrev={search.prevMatch}
|
||||
onClose={() => {
|
||||
search.clear();
|
||||
appMode.setMode('chat');
|
||||
}}
|
||||
focused={isSearchMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
<InputBar
|
||||
value={tuiInput}
|
||||
onChange={(val: string) => {
|
||||
// Suppress the character that ink-text-input leaks when a Ctrl+key
|
||||
// combo fires (e.g. Ctrl+T inserts 't'). The ctrlJustFired ref is
|
||||
// set synchronously in the useInput handler and cleared via a
|
||||
// microtask, so this callback sees it as still true on the same
|
||||
// event-loop tick.
|
||||
if (ctrlJustFired.current) {
|
||||
ctrlJustFired.current = false;
|
||||
return;
|
||||
}
|
||||
setTuiInput(val);
|
||||
}}
|
||||
onSubmit={sendMessageWithFileRefs}
|
||||
onSystemMessage={socket.addSystemMessage}
|
||||
onLocalCommand={handleLocalCommand}
|
||||
onGatewayCommand={handleGatewayCommand}
|
||||
isStreaming={socket.isStreaming}
|
||||
connected={socket.connected}
|
||||
focused={appMode.mode === 'chat'}
|
||||
placeholder={inputPlaceholder}
|
||||
allCommands={commandRegistry.getAll()}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" height="100%">
|
||||
<Box marginTop={1} />
|
||||
<TopBar
|
||||
gatewayUrl={gatewayUrl}
|
||||
version={version}
|
||||
modelName={socket.modelName}
|
||||
thinkingLevel={socket.thinkingLevel}
|
||||
contextWindow={socket.tokenUsage.contextWindow}
|
||||
agentName={agentName ?? 'default'}
|
||||
connected={socket.connected}
|
||||
connecting={socket.connecting}
|
||||
/>
|
||||
|
||||
{appMode.sidebarOpen ? (
|
||||
<Box flexDirection="row" flexGrow={1}>
|
||||
<Sidebar
|
||||
conversations={conversations.conversations}
|
||||
activeConversationId={socket.conversationId}
|
||||
selectedIndex={sidebarSelectedIndex}
|
||||
onSelectIndex={setSidebarSelectedIndex}
|
||||
onSwitchConversation={handleSwitchConversation}
|
||||
onDeleteConversation={handleDeleteConversation}
|
||||
loading={conversations.loading}
|
||||
focused={appMode.mode === 'sidebar'}
|
||||
width={30}
|
||||
/>
|
||||
{messageArea}
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexGrow={1}>{messageArea}</Box>
|
||||
)}
|
||||
|
||||
<BottomBar
|
||||
gitInfo={gitInfo}
|
||||
tokenUsage={socket.tokenUsage}
|
||||
connected={socket.connected}
|
||||
connecting={socket.connecting}
|
||||
modelName={socket.modelName}
|
||||
providerName={socket.providerName}
|
||||
thinkingLevel={socket.thinkingLevel}
|
||||
conversationId={socket.conversationId}
|
||||
routingDecision={socket.routingDecision}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
348
packages/mosaic/src/tui/commands/commands.integration.spec.ts
Normal file
348
packages/mosaic/src/tui/commands/commands.integration.spec.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Integration tests for TUI command parsing + registry (P8-019)
|
||||
*
|
||||
* Covers:
|
||||
* - parseSlashCommand() + commandRegistry.find() round-trip for all aliases
|
||||
* - /help, /stop, /cost, /status resolve to 'local' execution
|
||||
* - Unknown commands return null from find()
|
||||
* - Alias resolution: /h → help, /m → model, /n → new, etc.
|
||||
* - filterCommands prefix filtering
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { parseSlashCommand } from './parse.js';
|
||||
import { CommandRegistry } from './registry.js';
|
||||
import type { CommandDef } from '@mosaic/types';
|
||||
|
||||
// ─── Parse + Registry Round-trip ─────────────────────────────────────────────
|
||||
|
||||
describe('parseSlashCommand + CommandRegistry — integration', () => {
|
||||
let registry: CommandRegistry;
|
||||
|
||||
// Gateway-style commands to simulate a live manifest
|
||||
const gatewayCommands: CommandDef[] = [
|
||||
{
|
||||
name: 'model',
|
||||
description: 'Switch the active model',
|
||||
aliases: ['m'],
|
||||
args: [{ name: 'model-name', type: 'string', optional: false, description: 'Model name' }],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'thinking',
|
||||
description: 'Set thinking level',
|
||||
aliases: ['t'],
|
||||
args: [
|
||||
{
|
||||
name: 'level',
|
||||
type: 'enum',
|
||||
optional: false,
|
||||
values: ['none', 'low', 'medium', 'high', 'auto'],
|
||||
description: 'Thinking level',
|
||||
},
|
||||
],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'new',
|
||||
description: 'Start a new conversation',
|
||||
aliases: ['n'],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'agent',
|
||||
description: 'Switch or list available agents',
|
||||
aliases: ['a'],
|
||||
args: [{ name: 'args', type: 'string', optional: true, description: 'list or <agent-id>' }],
|
||||
scope: 'agent',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'preferences',
|
||||
description: 'View or set user preferences',
|
||||
aliases: ['pref'],
|
||||
args: [
|
||||
{
|
||||
name: 'action',
|
||||
type: 'enum',
|
||||
optional: true,
|
||||
values: ['show', 'set', 'reset'],
|
||||
description: 'Action',
|
||||
},
|
||||
],
|
||||
scope: 'core',
|
||||
execution: 'rest',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'gc',
|
||||
description: 'Trigger garbage collection sweep',
|
||||
aliases: [],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'mission',
|
||||
description: 'View or set active mission',
|
||||
aliases: [],
|
||||
args: [{ name: 'args', type: 'string', optional: true, description: 'status | set <id>' }],
|
||||
scope: 'agent',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new CommandRegistry();
|
||||
registry.updateManifest({ version: 1, commands: gatewayCommands, skills: [] });
|
||||
});
|
||||
|
||||
// ── parseSlashCommand tests ──
|
||||
|
||||
it('returns null for non-slash input', () => {
|
||||
expect(parseSlashCommand('hello world')).toBeNull();
|
||||
expect(parseSlashCommand('')).toBeNull();
|
||||
expect(parseSlashCommand('model')).toBeNull();
|
||||
});
|
||||
|
||||
it('parses "/model claude-3-opus" → command=model args=claude-3-opus', () => {
|
||||
const parsed = parseSlashCommand('/model claude-3-opus');
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.command).toBe('model');
|
||||
expect(parsed!.args).toBe('claude-3-opus');
|
||||
expect(parsed!.raw).toBe('/model claude-3-opus');
|
||||
});
|
||||
|
||||
it('parses "/gc" with no args → command=gc args=null', () => {
|
||||
const parsed = parseSlashCommand('/gc');
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.command).toBe('gc');
|
||||
expect(parsed!.args).toBeNull();
|
||||
});
|
||||
|
||||
it('parses "/system you are a helpful assistant" → args contains full text', () => {
|
||||
const parsed = parseSlashCommand('/system you are a helpful assistant');
|
||||
expect(parsed!.command).toBe('system');
|
||||
expect(parsed!.args).toBe('you are a helpful assistant');
|
||||
});
|
||||
|
||||
it('parses "/help" → command=help args=null', () => {
|
||||
const parsed = parseSlashCommand('/help');
|
||||
expect(parsed!.command).toBe('help');
|
||||
expect(parsed!.args).toBeNull();
|
||||
});
|
||||
|
||||
// ── Round-trip: parse then find ──
|
||||
|
||||
it('round-trip: /m → resolves to "model" command via alias', () => {
|
||||
const parsed = parseSlashCommand('/m claude-3-haiku');
|
||||
expect(parsed).not.toBeNull();
|
||||
const cmd = registry.find(parsed!.command);
|
||||
expect(cmd).not.toBeNull();
|
||||
// /m → model (alias map in registry)
|
||||
expect(cmd!.name === 'model' || cmd!.aliases.includes('m')).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trip: /h → resolves to "help" (local command)', () => {
|
||||
const parsed = parseSlashCommand('/h');
|
||||
expect(parsed).not.toBeNull();
|
||||
const cmd = registry.find(parsed!.command);
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.name === 'help' || cmd!.aliases.includes('h')).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trip: /n → resolves to "new" via gateway manifest', () => {
|
||||
const parsed = parseSlashCommand('/n');
|
||||
expect(parsed).not.toBeNull();
|
||||
const cmd = registry.find(parsed!.command);
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.name === 'new' || cmd!.aliases.includes('n')).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trip: /a → resolves to "agent" via gateway manifest', () => {
|
||||
const parsed = parseSlashCommand('/a list');
|
||||
expect(parsed).not.toBeNull();
|
||||
const cmd = registry.find(parsed!.command);
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.name === 'agent' || cmd!.aliases.includes('a')).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trip: /pref → resolves to "preferences" via alias', () => {
|
||||
const parsed = parseSlashCommand('/pref show');
|
||||
expect(parsed).not.toBeNull();
|
||||
const cmd = registry.find(parsed!.command);
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.name === 'preferences' || cmd!.aliases.includes('pref')).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trip: /t → resolves to "thinking" via alias', () => {
|
||||
const parsed = parseSlashCommand('/t high');
|
||||
expect(parsed).not.toBeNull();
|
||||
const cmd = registry.find(parsed!.command);
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.name === 'thinking' || cmd!.aliases.includes('t')).toBe(true);
|
||||
});
|
||||
|
||||
// ── Local commands resolve to 'local' execution ──
|
||||
|
||||
it('/help resolves to local execution', () => {
|
||||
const cmd = registry.find('help');
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.execution).toBe('local');
|
||||
});
|
||||
|
||||
it('/stop resolves to local execution', () => {
|
||||
const cmd = registry.find('stop');
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.execution).toBe('local');
|
||||
});
|
||||
|
||||
it('/cost resolves to local execution', () => {
|
||||
const cmd = registry.find('cost');
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.execution).toBe('local');
|
||||
});
|
||||
|
||||
it('/status resolves to local execution (TUI local override)', () => {
|
||||
const cmd = registry.find('status');
|
||||
expect(cmd).not.toBeNull();
|
||||
// status is 'local' in the TUI registry (local takes precedence over gateway)
|
||||
expect(cmd!.execution).toBe('local');
|
||||
});
|
||||
|
||||
// ── Unknown commands return null ──
|
||||
|
||||
it('find() returns null for unknown command', () => {
|
||||
expect(registry.find('nonexistent')).toBeNull();
|
||||
expect(registry.find('xyz')).toBeNull();
|
||||
expect(registry.find('')).toBeNull();
|
||||
});
|
||||
|
||||
it('find() returns null when no gateway manifest and command not local', () => {
|
||||
const emptyRegistry = new CommandRegistry();
|
||||
expect(emptyRegistry.find('model')).toBeNull();
|
||||
expect(emptyRegistry.find('gc')).toBeNull();
|
||||
});
|
||||
|
||||
// ── getAll returns combined local + gateway ──
|
||||
|
||||
it('getAll() includes both local and gateway commands', () => {
|
||||
const all = registry.getAll();
|
||||
const names = all.map((c) => c.name);
|
||||
// Local commands
|
||||
expect(names).toContain('help');
|
||||
expect(names).toContain('stop');
|
||||
expect(names).toContain('cost');
|
||||
expect(names).toContain('status');
|
||||
// Gateway commands
|
||||
expect(names).toContain('model');
|
||||
expect(names).toContain('gc');
|
||||
});
|
||||
|
||||
it('getLocalCommands() returns only local commands', () => {
|
||||
const local = registry.getLocalCommands();
|
||||
expect(local.every((c) => c.execution === 'local')).toBe(true);
|
||||
expect(local.some((c) => c.name === 'help')).toBe(true);
|
||||
expect(local.some((c) => c.name === 'stop')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── filterCommands (autocomplete) ────────────────────────────────────────────
|
||||
|
||||
describe('filterCommands (from CommandAutocomplete)', () => {
|
||||
// Import inline since filterCommands is not exported — replicate the logic here
|
||||
function filterCommands(commands: CommandDef[], query: string): CommandDef[] {
|
||||
if (!query) return commands;
|
||||
const q = query.toLowerCase();
|
||||
return commands.filter(
|
||||
(c) =>
|
||||
c.name.includes(q) ||
|
||||
c.aliases.some((a) => a.includes(q)) ||
|
||||
c.description.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
const commands: CommandDef[] = [
|
||||
{
|
||||
name: 'model',
|
||||
description: 'Switch the active model',
|
||||
aliases: ['m'],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'mission',
|
||||
description: 'View or set active mission',
|
||||
aliases: [],
|
||||
scope: 'agent',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'help',
|
||||
description: 'Show available commands',
|
||||
aliases: ['h'],
|
||||
scope: 'core',
|
||||
execution: 'local',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'gc',
|
||||
description: 'Trigger garbage collection sweep',
|
||||
aliases: [],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
];
|
||||
|
||||
it('returns all commands when query is empty', () => {
|
||||
expect(filterCommands(commands, '')).toHaveLength(commands.length);
|
||||
});
|
||||
|
||||
it('filters by name prefix "mi" → mission only (not model, as "mi" not in model name or aliases)', () => {
|
||||
const result = filterCommands(commands, 'mi');
|
||||
const names = result.map((c) => c.name);
|
||||
expect(names).toContain('mission');
|
||||
expect(names).not.toContain('gc');
|
||||
});
|
||||
|
||||
it('filters by name prefix "mo" → model only', () => {
|
||||
const result = filterCommands(commands, 'mo');
|
||||
const names = result.map((c) => c.name);
|
||||
expect(names).toContain('model');
|
||||
expect(names).not.toContain('mission');
|
||||
expect(names).not.toContain('gc');
|
||||
});
|
||||
|
||||
it('filters by exact name "gc" → gc only', () => {
|
||||
const result = filterCommands(commands, 'gc');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.name).toBe('gc');
|
||||
});
|
||||
|
||||
it('filters by alias "h" → help', () => {
|
||||
const result = filterCommands(commands, 'h');
|
||||
const names = result.map((c) => c.name);
|
||||
expect(names).toContain('help');
|
||||
});
|
||||
|
||||
it('filters by description keyword "switch" → model', () => {
|
||||
const result = filterCommands(commands, 'switch');
|
||||
const names = result.map((c) => c.name);
|
||||
expect(names).toContain('model');
|
||||
});
|
||||
|
||||
it('returns empty array when no commands match', () => {
|
||||
const result = filterCommands(commands, 'zzznotfound');
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
7
packages/mosaic/src/tui/commands/index.ts
Normal file
7
packages/mosaic/src/tui/commands/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { parseSlashCommand } from './parse.js';
|
||||
export { commandRegistry, CommandRegistry } from './registry.js';
|
||||
export { executeHelp } from './local/help.js';
|
||||
export { executeStatus } from './local/status.js';
|
||||
export type { StatusContext } from './local/status.js';
|
||||
export { executeHistory } from './local/history.js';
|
||||
export type { HistoryContext } from './local/history.js';
|
||||
19
packages/mosaic/src/tui/commands/local/help.ts
Normal file
19
packages/mosaic/src/tui/commands/local/help.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ParsedCommand } from '@mosaic/types';
|
||||
import { commandRegistry } from '../registry.js';
|
||||
|
||||
export function executeHelp(_parsed: ParsedCommand): string {
|
||||
const commands = commandRegistry.getAll();
|
||||
const lines = ['Available commands:', ''];
|
||||
|
||||
for (const cmd of commands) {
|
||||
const aliases =
|
||||
cmd.aliases.length > 0 ? ` (${cmd.aliases.map((a) => `/${a}`).join(', ')})` : '';
|
||||
const argsStr =
|
||||
cmd.args && cmd.args.length > 0
|
||||
? ' ' + cmd.args.map((a) => (a.optional ? `[${a.name}]` : `<${a.name}>`)).join(' ')
|
||||
: '';
|
||||
lines.push(` /${cmd.name}${argsStr}${aliases} — ${cmd.description}`);
|
||||
}
|
||||
|
||||
return lines.join('\n').trimEnd();
|
||||
}
|
||||
53
packages/mosaic/src/tui/commands/local/history.ts
Normal file
53
packages/mosaic/src/tui/commands/local/history.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { ConversationMessage } from '../../gateway-api.js';
|
||||
|
||||
const CONTEXT_WINDOW = 200_000;
|
||||
const CHARS_PER_TOKEN = 4;
|
||||
|
||||
function estimateTokens(messages: ConversationMessage[]): number {
|
||||
const totalChars = messages.reduce((sum, m) => sum + (m.content?.length ?? 0), 0);
|
||||
return Math.round(totalChars / CHARS_PER_TOKEN);
|
||||
}
|
||||
|
||||
export interface HistoryContext {
|
||||
conversationId: string | undefined;
|
||||
conversationTitle?: string | null;
|
||||
gatewayUrl: string;
|
||||
sessionCookie: string | undefined;
|
||||
fetchMessages: (
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
conversationId: string,
|
||||
) => Promise<ConversationMessage[]>;
|
||||
}
|
||||
|
||||
export async function executeHistory(ctx: HistoryContext): Promise<string> {
|
||||
const { conversationId, conversationTitle, gatewayUrl, sessionCookie, fetchMessages } = ctx;
|
||||
|
||||
if (!conversationId) {
|
||||
return 'No active conversation.';
|
||||
}
|
||||
|
||||
if (!sessionCookie) {
|
||||
return 'Not authenticated — cannot fetch conversation messages.';
|
||||
}
|
||||
|
||||
const messages = await fetchMessages(gatewayUrl, sessionCookie, conversationId);
|
||||
|
||||
const userMessages = messages.filter((m) => m.role === 'user').length;
|
||||
const assistantMessages = messages.filter((m) => m.role === 'assistant').length;
|
||||
const totalMessages = messages.length;
|
||||
|
||||
const estimatedTokens = estimateTokens(messages);
|
||||
const contextPercent = Math.round((estimatedTokens / CONTEXT_WINDOW) * 100);
|
||||
|
||||
const label = conversationTitle ?? conversationId;
|
||||
|
||||
const lines = [
|
||||
`Conversation: ${label}`,
|
||||
`Messages: ${totalMessages} (${userMessages} user, ${assistantMessages} assistant)`,
|
||||
`Estimated tokens: ~${estimatedTokens.toLocaleString()}`,
|
||||
`Context usage: ~${contextPercent}% of ${(CONTEXT_WINDOW / 1000).toFixed(0)}K`,
|
||||
];
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
20
packages/mosaic/src/tui/commands/local/status.ts
Normal file
20
packages/mosaic/src/tui/commands/local/status.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { ParsedCommand } from '@mosaic/types';
|
||||
|
||||
export interface StatusContext {
|
||||
connected: boolean;
|
||||
model: string | null;
|
||||
provider: string | null;
|
||||
sessionId: string | null;
|
||||
tokenCount: number;
|
||||
}
|
||||
|
||||
export function executeStatus(_parsed: ParsedCommand, ctx: StatusContext): string {
|
||||
const lines = [
|
||||
`Connection: ${ctx.connected ? 'connected' : 'disconnected'}`,
|
||||
`Model: ${ctx.model ?? 'unknown'}`,
|
||||
`Provider: ${ctx.provider ?? 'unknown'}`,
|
||||
`Session: ${ctx.sessionId ?? 'none'}`,
|
||||
`Tokens (session): ${ctx.tokenCount}`,
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
11
packages/mosaic/src/tui/commands/parse.ts
Normal file
11
packages/mosaic/src/tui/commands/parse.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { ParsedCommand } from '@mosaic/types';
|
||||
|
||||
export function parseSlashCommand(input: string): ParsedCommand | null {
|
||||
const match = input.match(/^\/([a-z][a-z0-9:_-]*)\s*(.*)?$/i);
|
||||
if (!match) return null;
|
||||
return {
|
||||
command: match[1]!,
|
||||
args: match[2]?.trim() || null,
|
||||
raw: input,
|
||||
};
|
||||
}
|
||||
137
packages/mosaic/src/tui/commands/registry.ts
Normal file
137
packages/mosaic/src/tui/commands/registry.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { CommandDef, CommandManifest } from '@mosaic/types';
|
||||
|
||||
// Local-only commands (work even when gateway is disconnected)
|
||||
const LOCAL_COMMANDS: CommandDef[] = [
|
||||
{
|
||||
name: 'help',
|
||||
description: 'Show available commands',
|
||||
aliases: ['h'],
|
||||
args: undefined,
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'stop',
|
||||
description: 'Cancel current streaming response',
|
||||
aliases: [],
|
||||
args: undefined,
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'cost',
|
||||
description: 'Show token usage and cost for current session',
|
||||
aliases: [],
|
||||
args: undefined,
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
description: 'Show connection and session status',
|
||||
aliases: ['s'],
|
||||
args: undefined,
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'history',
|
||||
description: 'Show conversation message count and context usage',
|
||||
aliases: ['hist'],
|
||||
args: undefined,
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'clear',
|
||||
description: 'Clear the current conversation display',
|
||||
aliases: [],
|
||||
args: undefined,
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'attach',
|
||||
description: 'Attach a file to the next message (@file syntax also works inline)',
|
||||
aliases: [],
|
||||
args: [
|
||||
{
|
||||
name: 'path',
|
||||
type: 'string' as const,
|
||||
optional: false,
|
||||
description: 'File path to attach',
|
||||
},
|
||||
],
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'new',
|
||||
description: 'Start a new conversation',
|
||||
aliases: ['n'],
|
||||
args: undefined,
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
];
|
||||
|
||||
const ALIASES: Record<string, string> = {
|
||||
m: 'model',
|
||||
t: 'thinking',
|
||||
a: 'agent',
|
||||
s: 'status',
|
||||
h: 'help',
|
||||
hist: 'history',
|
||||
pref: 'preferences',
|
||||
};
|
||||
|
||||
export class CommandRegistry {
|
||||
private gatewayManifest: CommandManifest | null = null;
|
||||
|
||||
updateManifest(manifest: CommandManifest): void {
|
||||
this.gatewayManifest = manifest;
|
||||
}
|
||||
|
||||
resolveAlias(command: string): string {
|
||||
return ALIASES[command] ?? command;
|
||||
}
|
||||
|
||||
find(command: string): CommandDef | null {
|
||||
const resolved = this.resolveAlias(command);
|
||||
// Search local first, then gateway manifest
|
||||
const local = LOCAL_COMMANDS.find((c) => c.name === resolved || c.aliases.includes(resolved));
|
||||
if (local) return local;
|
||||
if (this.gatewayManifest) {
|
||||
return (
|
||||
this.gatewayManifest.commands.find(
|
||||
(c) => c.name === resolved || c.aliases.includes(resolved),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getAll(): CommandDef[] {
|
||||
const gateway = this.gatewayManifest?.commands ?? [];
|
||||
// Local commands take precedence; deduplicate gateway commands that share
|
||||
// a name with a local command to avoid duplicate React keys and confusing
|
||||
// autocomplete entries.
|
||||
const localNames = new Set(LOCAL_COMMANDS.map((c) => c.name));
|
||||
const dedupedGateway = gateway.filter((c) => !localNames.has(c.name));
|
||||
return [...LOCAL_COMMANDS, ...dedupedGateway];
|
||||
}
|
||||
|
||||
getLocalCommands(): CommandDef[] {
|
||||
return LOCAL_COMMANDS;
|
||||
}
|
||||
}
|
||||
|
||||
export const commandRegistry = new CommandRegistry();
|
||||
138
packages/mosaic/src/tui/components/bottom-bar.tsx
Normal file
138
packages/mosaic/src/tui/components/bottom-bar.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { RoutingDecisionInfo } from '@mosaic/types';
|
||||
import type { TokenUsage } from '../hooks/use-socket.js';
|
||||
import type { GitInfo } from '../hooks/use-git-info.js';
|
||||
|
||||
export interface BottomBarProps {
|
||||
gitInfo: GitInfo;
|
||||
tokenUsage: TokenUsage;
|
||||
connected: boolean;
|
||||
connecting: boolean;
|
||||
modelName: string | null;
|
||||
providerName: string | null;
|
||||
thinkingLevel: string;
|
||||
conversationId: string | undefined;
|
||||
/** Routing decision info for transparency display (M4-008) */
|
||||
routingDecision?: RoutingDecisionInfo | null;
|
||||
}
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/** Compact the cwd — replace home with ~ */
|
||||
function compactCwd(cwd: string): string {
|
||||
const home = process.env['HOME'] ?? '';
|
||||
if (home && cwd.startsWith(home)) {
|
||||
return '~' + cwd.slice(home.length);
|
||||
}
|
||||
return cwd;
|
||||
}
|
||||
|
||||
export function BottomBar({
|
||||
gitInfo,
|
||||
tokenUsage,
|
||||
connected,
|
||||
connecting,
|
||||
modelName,
|
||||
providerName,
|
||||
thinkingLevel,
|
||||
conversationId,
|
||||
routingDecision,
|
||||
}: BottomBarProps) {
|
||||
const gatewayStatus = connected ? 'Connected' : connecting ? 'Connecting…' : 'Disconnected';
|
||||
const gatewayColor = connected ? 'green' : connecting ? 'yellow' : 'red';
|
||||
|
||||
const hasTokens = tokenUsage.total > 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={0} marginTop={0}>
|
||||
{/* Line 0: keybinding hints */}
|
||||
<Box>
|
||||
<Text dimColor>^L sidebar · ^N new · ^K search · ^T thinking · PgUp/Dn scroll</Text>
|
||||
</Box>
|
||||
|
||||
{/* Line 1: blank ····· Gateway: Status */}
|
||||
<Box justifyContent="space-between">
|
||||
<Box />
|
||||
<Box>
|
||||
<Text dimColor>Gateway: </Text>
|
||||
<Text color={gatewayColor}>{gatewayStatus}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Line 2: cwd (branch) ····· Session: id */}
|
||||
<Box justifyContent="space-between">
|
||||
<Box>
|
||||
<Text dimColor>{compactCwd(gitInfo.cwd)}</Text>
|
||||
{gitInfo.branch && <Text dimColor> ({gitInfo.branch})</Text>}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
{conversationId ? `Session: ${conversationId.slice(0, 8)}` : 'No session'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Line 3: token stats ····· (provider) model */}
|
||||
<Box justifyContent="space-between" minHeight={1}>
|
||||
<Box>
|
||||
{hasTokens ? (
|
||||
<>
|
||||
<Text dimColor>↑{formatTokens(tokenUsage.input)}</Text>
|
||||
<Text dimColor>{' '}</Text>
|
||||
<Text dimColor>↓{formatTokens(tokenUsage.output)}</Text>
|
||||
{tokenUsage.cacheRead > 0 && (
|
||||
<>
|
||||
<Text dimColor>{' '}</Text>
|
||||
<Text dimColor>R{formatTokens(tokenUsage.cacheRead)}</Text>
|
||||
</>
|
||||
)}
|
||||
{tokenUsage.cacheWrite > 0 && (
|
||||
<>
|
||||
<Text dimColor>{' '}</Text>
|
||||
<Text dimColor>W{formatTokens(tokenUsage.cacheWrite)}</Text>
|
||||
</>
|
||||
)}
|
||||
{tokenUsage.cost > 0 && (
|
||||
<>
|
||||
<Text dimColor>{' '}</Text>
|
||||
<Text dimColor>${tokenUsage.cost.toFixed(3)}</Text>
|
||||
</>
|
||||
)}
|
||||
{tokenUsage.contextPercent > 0 && (
|
||||
<>
|
||||
<Text dimColor>{' '}</Text>
|
||||
<Text dimColor>
|
||||
{tokenUsage.contextPercent.toFixed(1)}%/{formatTokens(tokenUsage.contextWindow)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text dimColor>↑0 ↓0 $0.000</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
{providerName ? `(${providerName}) ` : ''}
|
||||
{modelName ?? 'awaiting model'}
|
||||
{thinkingLevel !== 'off' ? ` • ${thinkingLevel}` : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Line 4: routing transparency (M4-008) — only shown when a routing decision is available */}
|
||||
{routingDecision && (
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
Routed: {routingDecision.model} ({routingDecision.reason})
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
66
packages/mosaic/src/tui/components/command-autocomplete.tsx
Normal file
66
packages/mosaic/src/tui/components/command-autocomplete.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { CommandDef, CommandArgDef } from '@mosaic/types';
|
||||
|
||||
interface CommandAutocompleteProps {
|
||||
commands: CommandDef[];
|
||||
selectedIndex: number;
|
||||
inputValue: string; // the current input after '/'
|
||||
}
|
||||
|
||||
export function CommandAutocomplete({
|
||||
commands,
|
||||
selectedIndex,
|
||||
inputValue,
|
||||
}: CommandAutocompleteProps) {
|
||||
if (commands.length === 0) return null;
|
||||
|
||||
// Filter by inputValue prefix/fuzzy match
|
||||
const query = inputValue.startsWith('/') ? inputValue.slice(1) : inputValue;
|
||||
const filtered = filterCommands(commands, query);
|
||||
|
||||
if (filtered.length === 0) return null;
|
||||
|
||||
const clampedIndex = Math.min(selectedIndex, filtered.length - 1);
|
||||
const selected = filtered[clampedIndex];
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
|
||||
{filtered.slice(0, 8).map((cmd, i) => (
|
||||
<Box key={`${cmd.execution}-${cmd.name}`}>
|
||||
<Text color={i === clampedIndex ? 'cyan' : 'white'} bold={i === clampedIndex}>
|
||||
{i === clampedIndex ? '▶ ' : ' '}/{cmd.name}
|
||||
</Text>
|
||||
{cmd.aliases.length > 0 && (
|
||||
<Text color="gray"> ({cmd.aliases.map((a) => `/${a}`).join(', ')})</Text>
|
||||
)}
|
||||
<Text color="gray"> — {cmd.description}</Text>
|
||||
</Box>
|
||||
))}
|
||||
{selected && selected.args && selected.args.length > 0 && (
|
||||
<Box marginTop={1} borderStyle="single" borderColor="gray" paddingX={1}>
|
||||
<Text color="yellow">
|
||||
/{selected.name} {getArgHint(selected.args)}
|
||||
</Text>
|
||||
<Text color="gray"> — {selected.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function filterCommands(commands: CommandDef[], query: string): CommandDef[] {
|
||||
if (!query) return commands;
|
||||
const q = query.toLowerCase();
|
||||
return commands.filter(
|
||||
(c) =>
|
||||
c.name.includes(q) ||
|
||||
c.aliases.some((a) => a.includes(q)) ||
|
||||
c.description.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
function getArgHint(args: CommandArgDef[]): string {
|
||||
if (!args || args.length === 0) return '';
|
||||
return args.map((a) => (a.optional ? `[${a.name}]` : `<${a.name}>`)).join(' ');
|
||||
}
|
||||
225
packages/mosaic/src/tui/components/input-bar.tsx
Normal file
225
packages/mosaic/src/tui/components/input-bar.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
import type { ParsedCommand, CommandDef } from '@mosaic/types';
|
||||
import { parseSlashCommand, commandRegistry } from '../commands/index.js';
|
||||
import { CommandAutocomplete } from './command-autocomplete.js';
|
||||
import { useInputHistory } from '../hooks/use-input-history.js';
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface InputBarProps {
|
||||
/** Controlled input value — caller owns the state */
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
onSubmit: (value: string) => void;
|
||||
onSystemMessage?: (message: string) => void;
|
||||
onLocalCommand?: (parsed: ParsedCommand) => void;
|
||||
onGatewayCommand?: (parsed: ParsedCommand) => void;
|
||||
isStreaming: boolean;
|
||||
connected: boolean;
|
||||
/** Whether this input bar is focused/active (default true). When false,
|
||||
* keyboard input is not captured — e.g. when the sidebar has focus. */
|
||||
focused?: boolean;
|
||||
placeholder?: string;
|
||||
allCommands?: CommandDef[];
|
||||
}
|
||||
|
||||
export function InputBar({
|
||||
value: input,
|
||||
onChange: setInput,
|
||||
onSubmit,
|
||||
onSystemMessage,
|
||||
onLocalCommand,
|
||||
onGatewayCommand,
|
||||
isStreaming,
|
||||
connected,
|
||||
focused = true,
|
||||
placeholder: placeholderOverride,
|
||||
allCommands,
|
||||
}: InputBarProps) {
|
||||
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||
const [autocompleteIndex, setAutocompleteIndex] = useState(0);
|
||||
|
||||
const { addToHistory, navigateUp, navigateDown } = useInputHistory();
|
||||
|
||||
// Determine which commands to show in autocomplete
|
||||
const availableCommands = allCommands ?? commandRegistry.getAll();
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
setInput(value);
|
||||
if (value.startsWith('/')) {
|
||||
setShowAutocomplete(true);
|
||||
setAutocompleteIndex(0);
|
||||
} else {
|
||||
setShowAutocomplete(false);
|
||||
}
|
||||
},
|
||||
[setInput],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(value: string) => {
|
||||
if (!value.trim() || isStreaming || !connected) return;
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
addToHistory(trimmed);
|
||||
setShowAutocomplete(false);
|
||||
setAutocompleteIndex(0);
|
||||
|
||||
if (trimmed.startsWith('/')) {
|
||||
const parsed = parseSlashCommand(trimmed);
|
||||
if (!parsed) {
|
||||
// Bare "/" or malformed — ignore silently (autocomplete handles discovery)
|
||||
return;
|
||||
}
|
||||
const def = commandRegistry.find(parsed.command);
|
||||
if (!def) {
|
||||
onSystemMessage?.(
|
||||
`Unknown command: /${parsed.command}. Type /help for available commands.`,
|
||||
);
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
if (def.execution === 'local') {
|
||||
onLocalCommand?.(parsed);
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
// Gateway-executed commands
|
||||
onGatewayCommand?.(parsed);
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(value);
|
||||
setInput('');
|
||||
},
|
||||
[
|
||||
onSubmit,
|
||||
onSystemMessage,
|
||||
onLocalCommand,
|
||||
onGatewayCommand,
|
||||
isStreaming,
|
||||
connected,
|
||||
addToHistory,
|
||||
setInput,
|
||||
],
|
||||
);
|
||||
|
||||
// Handle Tab: fill in selected autocomplete command
|
||||
const fillAutocompleteSelection = useCallback(() => {
|
||||
if (!showAutocomplete) return false;
|
||||
const query = input.startsWith('/') ? input.slice(1) : input;
|
||||
const filtered = availableCommands.filter(
|
||||
(c) =>
|
||||
!query ||
|
||||
c.name.includes(query.toLowerCase()) ||
|
||||
c.aliases.some((a) => a.includes(query.toLowerCase())) ||
|
||||
c.description.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
if (filtered.length === 0) return false;
|
||||
const idx = Math.min(autocompleteIndex, filtered.length - 1);
|
||||
const selected = filtered[idx];
|
||||
if (selected) {
|
||||
setInput(`/${selected.name} `);
|
||||
setShowAutocomplete(false);
|
||||
setAutocompleteIndex(0);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [showAutocomplete, input, availableCommands, autocompleteIndex, setInput]);
|
||||
|
||||
useInput(
|
||||
(_ch, key) => {
|
||||
if (key.escape && showAutocomplete) {
|
||||
setShowAutocomplete(false);
|
||||
setAutocompleteIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tab: fill autocomplete selection
|
||||
if (key.tab) {
|
||||
fillAutocompleteSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
// Up arrow
|
||||
if (key.upArrow) {
|
||||
if (showAutocomplete) {
|
||||
setAutocompleteIndex((prev) => Math.max(0, prev - 1));
|
||||
} else {
|
||||
const prev = navigateUp(input);
|
||||
if (prev !== null) {
|
||||
setInput(prev);
|
||||
if (prev.startsWith('/')) setShowAutocomplete(true);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Down arrow
|
||||
if (key.downArrow) {
|
||||
if (showAutocomplete) {
|
||||
const query = input.startsWith('/') ? input.slice(1) : input;
|
||||
const filteredLen = availableCommands.filter(
|
||||
(c) =>
|
||||
!query ||
|
||||
c.name.includes(query.toLowerCase()) ||
|
||||
c.aliases.some((a) => a.includes(query.toLowerCase())) ||
|
||||
c.description.toLowerCase().includes(query.toLowerCase()),
|
||||
).length;
|
||||
const maxVisible = Math.min(filteredLen, 8);
|
||||
setAutocompleteIndex((prev) => Math.min(prev + 1, maxVisible - 1));
|
||||
} else {
|
||||
const next = navigateDown();
|
||||
if (next !== null) {
|
||||
setInput(next);
|
||||
setShowAutocomplete(next.startsWith('/'));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Return/Enter on autocomplete: fill selected command
|
||||
if (key.return && showAutocomplete) {
|
||||
fillAutocompleteSelection();
|
||||
return;
|
||||
}
|
||||
},
|
||||
{ isActive: focused },
|
||||
);
|
||||
|
||||
const placeholder =
|
||||
placeholderOverride ??
|
||||
(!connected
|
||||
? 'disconnected — waiting for gateway…'
|
||||
: isStreaming
|
||||
? 'waiting for response…'
|
||||
: 'message mosaic…');
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{showAutocomplete && (
|
||||
<CommandAutocomplete
|
||||
commands={availableCommands}
|
||||
selectedIndex={autocompleteIndex}
|
||||
inputValue={input}
|
||||
/>
|
||||
)}
|
||||
<Box paddingX={1} borderStyle="single" borderColor="gray">
|
||||
<Text bold color="green">
|
||||
{'❯ '}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={input}
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder={placeholder}
|
||||
focus={focused}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
192
packages/mosaic/src/tui/components/message-list.tsx
Normal file
192
packages/mosaic/src/tui/components/message-list.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import Spinner from 'ink-spinner';
|
||||
import type { Message, ToolCall } from '../hooks/use-socket.js';
|
||||
|
||||
export interface MessageListProps {
|
||||
messages: Message[];
|
||||
isStreaming: boolean;
|
||||
currentStreamText: string;
|
||||
currentThinkingText: string;
|
||||
activeToolCalls: ToolCall[];
|
||||
scrollOffset?: number;
|
||||
viewportSize?: number;
|
||||
isScrolledUp?: boolean;
|
||||
highlightedMessageIndices?: Set<number>;
|
||||
currentHighlightIndex?: number;
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function SystemMessageBubble({ msg }: { msg: Message }) {
|
||||
return (
|
||||
<Box flexDirection="row" marginBottom={1} marginLeft={2}>
|
||||
<Text dimColor>{'⚙ '}</Text>
|
||||
<Text dimColor wrap="wrap">
|
||||
{msg.content}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({
|
||||
msg,
|
||||
highlight,
|
||||
}: {
|
||||
msg: Message;
|
||||
highlight?: 'match' | 'current' | undefined;
|
||||
}) {
|
||||
if (msg.role === 'system') {
|
||||
return <SystemMessageBubble msg={msg} />;
|
||||
}
|
||||
|
||||
const isUser = msg.role === 'user';
|
||||
const prefix = isUser ? '❯' : '◆';
|
||||
const color = isUser ? 'green' : 'cyan';
|
||||
|
||||
const borderIndicator =
|
||||
highlight === 'current' ? (
|
||||
<Text color="yellowBright" bold>
|
||||
▌{' '}
|
||||
</Text>
|
||||
) : highlight === 'match' ? (
|
||||
<Text color="yellow">▌ </Text>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
{borderIndicator}
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text bold color={color}>
|
||||
{prefix}{' '}
|
||||
</Text>
|
||||
<Text bold color={color}>
|
||||
{isUser ? 'you' : 'assistant'}
|
||||
</Text>
|
||||
<Text dimColor> {formatTime(msg.timestamp)}</Text>
|
||||
</Box>
|
||||
<Box marginLeft={2}>
|
||||
<Text wrap="wrap">{msg.content}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCallIndicator({ toolCall }: { toolCall: ToolCall }) {
|
||||
const icon = toolCall.status === 'running' ? null : toolCall.status === 'success' ? '✓' : '✗';
|
||||
const color =
|
||||
toolCall.status === 'running' ? 'yellow' : toolCall.status === 'success' ? 'green' : 'red';
|
||||
|
||||
return (
|
||||
<Box marginLeft={2}>
|
||||
{toolCall.status === 'running' ? (
|
||||
<Text color="yellow">
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={color}>{icon}</Text>
|
||||
)}
|
||||
<Text dimColor> tool: </Text>
|
||||
<Text color={color}>{toolCall.toolName}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
messages,
|
||||
isStreaming,
|
||||
currentStreamText,
|
||||
currentThinkingText,
|
||||
activeToolCalls,
|
||||
scrollOffset,
|
||||
viewportSize,
|
||||
isScrolledUp,
|
||||
highlightedMessageIndices,
|
||||
currentHighlightIndex,
|
||||
}: MessageListProps) {
|
||||
const useSlicing = scrollOffset != null && viewportSize != null;
|
||||
const visibleMessages = useSlicing
|
||||
? messages.slice(scrollOffset, scrollOffset + viewportSize)
|
||||
: messages;
|
||||
const hiddenAbove = useSlicing ? scrollOffset : 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1} paddingX={1}>
|
||||
{isScrolledUp && hiddenAbove > 0 && (
|
||||
<Box justifyContent="center">
|
||||
<Text dimColor>↑ {hiddenAbove} more messages ↑</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{messages.length === 0 && !isStreaming && (
|
||||
<Box justifyContent="center" marginY={1}>
|
||||
<Text dimColor>No messages yet. Type below to start a conversation.</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{visibleMessages.map((msg, i) => {
|
||||
const globalIndex = hiddenAbove + i;
|
||||
const highlight =
|
||||
globalIndex === currentHighlightIndex
|
||||
? ('current' as const)
|
||||
: highlightedMessageIndices?.has(globalIndex)
|
||||
? ('match' as const)
|
||||
: undefined;
|
||||
return <MessageBubble key={globalIndex} msg={msg} highlight={highlight} />;
|
||||
})}
|
||||
|
||||
{/* Active thinking */}
|
||||
{isStreaming && currentThinkingText && (
|
||||
<Box flexDirection="column" marginBottom={1} marginLeft={2}>
|
||||
<Text dimColor italic>
|
||||
💭 {currentThinkingText}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Active tool calls */}
|
||||
{activeToolCalls.length > 0 && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{activeToolCalls.map((tc) => (
|
||||
<ToolCallIndicator key={tc.toolCallId} toolCall={tc} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Streaming response */}
|
||||
{isStreaming && currentStreamText && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text bold color="cyan">
|
||||
◆{' '}
|
||||
</Text>
|
||||
<Text bold color="cyan">
|
||||
assistant
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginLeft={2}>
|
||||
<Text wrap="wrap">{currentStreamText}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Waiting spinner */}
|
||||
{isStreaming && !currentStreamText && activeToolCalls.length === 0 && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color="cyan">
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text dimColor> thinking…</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
60
packages/mosaic/src/tui/components/search-bar.tsx
Normal file
60
packages/mosaic/src/tui/components/search-bar.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
|
||||
export interface SearchBarProps {
|
||||
query: string;
|
||||
onQueryChange: (q: string) => void;
|
||||
totalMatches: number;
|
||||
currentMatch: number;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
onClose: () => void;
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
export function SearchBar({
|
||||
query,
|
||||
onQueryChange,
|
||||
totalMatches,
|
||||
currentMatch,
|
||||
onNext,
|
||||
onPrev,
|
||||
onClose,
|
||||
focused,
|
||||
}: SearchBarProps) {
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (key.upArrow) {
|
||||
onPrev();
|
||||
}
|
||||
if (key.downArrow) {
|
||||
onNext();
|
||||
}
|
||||
if (key.escape) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
{ isActive: focused },
|
||||
);
|
||||
|
||||
const borderColor = focused ? 'yellow' : 'gray';
|
||||
|
||||
const matchDisplay =
|
||||
query.length >= 2
|
||||
? totalMatches > 0
|
||||
? `${String(currentMatch + 1)}/${String(totalMatches)}`
|
||||
: 'no matches'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Box borderStyle="round" borderColor={borderColor} paddingX={1} flexDirection="row" gap={1}>
|
||||
<Text>🔍</Text>
|
||||
<Box flexGrow={1}>
|
||||
<TextInput value={query} onChange={onQueryChange} focus={focused} />
|
||||
</Box>
|
||||
{matchDisplay && <Text dimColor>{matchDisplay}</Text>}
|
||||
<Text dimColor>↑↓ navigate · Esc close</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
143
packages/mosaic/src/tui/components/sidebar.tsx
Normal file
143
packages/mosaic/src/tui/components/sidebar.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import type { ConversationSummary } from '../hooks/use-conversations.js';
|
||||
|
||||
export interface SidebarProps {
|
||||
conversations: ConversationSummary[];
|
||||
activeConversationId: string | undefined;
|
||||
selectedIndex: number;
|
||||
onSelectIndex: (index: number) => void;
|
||||
onSwitchConversation: (id: string) => void;
|
||||
onDeleteConversation: (id: string) => void;
|
||||
loading: boolean;
|
||||
focused: boolean;
|
||||
width: number;
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
const hh = String(date.getHours()).padStart(2, '0');
|
||||
const mm = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
const months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
const mon = months[date.getMonth()];
|
||||
const dd = String(date.getDate()).padStart(2, '0');
|
||||
return `${mon} ${dd}`;
|
||||
}
|
||||
|
||||
function truncate(text: string, maxLen: number): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen - 1) + '…';
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
conversations,
|
||||
activeConversationId,
|
||||
selectedIndex,
|
||||
onSelectIndex,
|
||||
onSwitchConversation,
|
||||
onDeleteConversation,
|
||||
loading,
|
||||
focused,
|
||||
width,
|
||||
}: SidebarProps) {
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (key.upArrow) {
|
||||
onSelectIndex(Math.max(0, selectedIndex - 1));
|
||||
}
|
||||
if (key.downArrow) {
|
||||
onSelectIndex(Math.min(conversations.length - 1, selectedIndex + 1));
|
||||
}
|
||||
if (key.return) {
|
||||
const conv = conversations[selectedIndex];
|
||||
if (conv) {
|
||||
onSwitchConversation(conv.id);
|
||||
}
|
||||
}
|
||||
if (_input === 'd') {
|
||||
const conv = conversations[selectedIndex];
|
||||
if (conv) {
|
||||
onDeleteConversation(conv.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: focused },
|
||||
);
|
||||
|
||||
const borderColor = focused ? 'cyan' : 'gray';
|
||||
// Available width for content inside border + padding
|
||||
const innerWidth = width - 4; // 2 border + 2 padding
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={width}
|
||||
borderStyle="single"
|
||||
borderColor={borderColor}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text bold color="cyan">
|
||||
Conversations
|
||||
</Text>
|
||||
<Box marginTop={0} flexDirection="column" flexGrow={1}>
|
||||
{loading && conversations.length === 0 ? (
|
||||
<Text dimColor>Loading…</Text>
|
||||
) : conversations.length === 0 ? (
|
||||
<Text dimColor>No conversations</Text>
|
||||
) : (
|
||||
conversations.map((conv, idx) => {
|
||||
const isActive = conv.id === activeConversationId;
|
||||
const isSelected = idx === selectedIndex && focused;
|
||||
const marker = isActive ? '● ' : ' ';
|
||||
const time = formatRelativeTime(conv.updatedAt);
|
||||
const title = conv.title ?? 'Untitled';
|
||||
// marker(2) + title + space(1) + time
|
||||
const maxTitleLen = Math.max(4, innerWidth - marker.length - time.length - 1);
|
||||
const displayTitle = truncate(title, maxTitleLen);
|
||||
|
||||
return (
|
||||
<Box key={conv.id}>
|
||||
<Text
|
||||
inverse={isSelected}
|
||||
color={isActive ? 'cyan' : undefined}
|
||||
dimColor={!isActive && !isSelected}
|
||||
>
|
||||
{marker}
|
||||
{displayTitle}
|
||||
{' '.repeat(
|
||||
Math.max(0, innerWidth - marker.length - displayTitle.length - time.length),
|
||||
)}
|
||||
{time}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
{focused && <Text dimColor>↑↓ navigate • enter switch • d delete</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
99
packages/mosaic/src/tui/components/top-bar.tsx
Normal file
99
packages/mosaic/src/tui/components/top-bar.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
|
||||
export interface TopBarProps {
|
||||
gatewayUrl: string;
|
||||
version: string;
|
||||
modelName: string | null;
|
||||
thinkingLevel: string;
|
||||
contextWindow: number;
|
||||
agentName: string;
|
||||
connected: boolean;
|
||||
connecting: boolean;
|
||||
}
|
||||
|
||||
/** Compact the URL — strip protocol */
|
||||
function compactHost(url: string): string {
|
||||
return url.replace(/^https?:\/\//, '');
|
||||
}
|
||||
|
||||
function formatContextWindow(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(0)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mosaic 3×3 icon — brand tiles with black gaps (windmill cross pattern)
|
||||
*
|
||||
* Layout:
|
||||
* blue ·· purple
|
||||
* ·· pink ··
|
||||
* amber ·· teal
|
||||
*/
|
||||
// Two-space gap between tiles (extracted to avoid prettier collapse)
|
||||
const GAP = ' ';
|
||||
|
||||
function MosaicIcon() {
|
||||
return (
|
||||
<Box flexDirection="column" marginRight={2}>
|
||||
<Text>
|
||||
<Text color="#2f80ff">██</Text>
|
||||
<Text>{GAP}</Text>
|
||||
<Text color="#8b5cf6">██</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
<Text>{GAP}</Text>
|
||||
<Text color="#ec4899">██</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
<Text color="#f59e0b">██</Text>
|
||||
<Text>{GAP}</Text>
|
||||
<Text color="#14b8a6">██</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopBar({
|
||||
gatewayUrl,
|
||||
version,
|
||||
modelName,
|
||||
thinkingLevel,
|
||||
contextWindow,
|
||||
agentName,
|
||||
connected,
|
||||
connecting,
|
||||
}: TopBarProps) {
|
||||
const host = compactHost(gatewayUrl);
|
||||
const connectionIndicator = connected ? '●' : '○';
|
||||
const connectionColor = connected ? 'green' : connecting ? 'yellow' : 'red';
|
||||
|
||||
// Build model description line like: "claude-opus-4-6 (1M context) · default"
|
||||
const modelDisplay = modelName ?? 'awaiting model';
|
||||
const contextStr = contextWindow > 0 ? ` (${formatContextWindow(contextWindow)} context)` : '';
|
||||
const thinkingStr = thinkingLevel !== 'off' ? ` · ${thinkingLevel}` : '';
|
||||
|
||||
return (
|
||||
<Box paddingX={1} paddingY={0} marginBottom={1}>
|
||||
<MosaicIcon />
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<Text>
|
||||
<Text bold color="#56a0ff">
|
||||
Mosaic Stack
|
||||
</Text>
|
||||
<Text dimColor> v{version}</Text>
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{modelDisplay}
|
||||
{contextStr}
|
||||
{thinkingStr} · {agentName}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text color={connectionColor}>{connectionIndicator}</Text>
|
||||
<Text dimColor> {host}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
202
packages/mosaic/src/tui/file-ref.ts
Normal file
202
packages/mosaic/src/tui/file-ref.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* File reference expansion for TUI chat input.
|
||||
*
|
||||
* Detects @path/to/file patterns in user messages, reads the file contents,
|
||||
* and inlines them as fenced code blocks in the message.
|
||||
*
|
||||
* Supports:
|
||||
* - @relative/path.ts
|
||||
* - @./relative/path.ts
|
||||
* - @/absolute/path.ts
|
||||
* - @~/home-relative/path.ts
|
||||
*
|
||||
* Also provides an /attach <path> command handler.
|
||||
*/
|
||||
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { resolve, extname, basename } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const MAX_FILE_SIZE = 256 * 1024; // 256 KB
|
||||
const MAX_FILES_PER_MESSAGE = 10;
|
||||
|
||||
/**
|
||||
* Regex to detect @file references in user input.
|
||||
* Matches @<path> where path starts with /, ./, ~/, or a word char,
|
||||
* and continues until whitespace or end of string.
|
||||
* Excludes @mentions that look like usernames (no dots/slashes).
|
||||
*/
|
||||
const FILE_REF_PATTERN = /(?:^|\s)@((?:\.{0,2}\/|~\/|[a-zA-Z0-9_])[^\s]+)/g;
|
||||
|
||||
interface FileRefResult {
|
||||
/** The expanded message text with file contents inlined */
|
||||
expandedMessage: string;
|
||||
/** Files that were successfully read */
|
||||
filesAttached: string[];
|
||||
/** Errors encountered while reading files */
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
function resolveFilePath(ref: string): string {
|
||||
if (ref.startsWith('~/')) {
|
||||
return resolve(homedir(), ref.slice(2));
|
||||
}
|
||||
return resolve(process.cwd(), ref);
|
||||
}
|
||||
|
||||
function getLanguageHint(filePath: string): string {
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
const map: Record<string, string> = {
|
||||
'.ts': 'typescript',
|
||||
'.tsx': 'typescript',
|
||||
'.js': 'javascript',
|
||||
'.jsx': 'javascript',
|
||||
'.py': 'python',
|
||||
'.rb': 'ruby',
|
||||
'.rs': 'rust',
|
||||
'.go': 'go',
|
||||
'.java': 'java',
|
||||
'.c': 'c',
|
||||
'.cpp': 'cpp',
|
||||
'.h': 'c',
|
||||
'.hpp': 'cpp',
|
||||
'.cs': 'csharp',
|
||||
'.sh': 'bash',
|
||||
'.bash': 'bash',
|
||||
'.zsh': 'zsh',
|
||||
'.fish': 'fish',
|
||||
'.json': 'json',
|
||||
'.yaml': 'yaml',
|
||||
'.yml': 'yaml',
|
||||
'.toml': 'toml',
|
||||
'.xml': 'xml',
|
||||
'.html': 'html',
|
||||
'.css': 'css',
|
||||
'.scss': 'scss',
|
||||
'.md': 'markdown',
|
||||
'.sql': 'sql',
|
||||
'.graphql': 'graphql',
|
||||
'.dockerfile': 'dockerfile',
|
||||
'.tf': 'terraform',
|
||||
'.vue': 'vue',
|
||||
'.svelte': 'svelte',
|
||||
};
|
||||
return map[ext] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the input contains any @file references.
|
||||
*/
|
||||
export function hasFileRefs(input: string): boolean {
|
||||
FILE_REF_PATTERN.lastIndex = 0;
|
||||
return FILE_REF_PATTERN.test(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand @file references in a message by reading file contents
|
||||
* and appending them as fenced code blocks.
|
||||
*/
|
||||
export async function expandFileRefs(input: string): Promise<FileRefResult> {
|
||||
const refs: string[] = [];
|
||||
FILE_REF_PATTERN.lastIndex = 0;
|
||||
let match;
|
||||
while ((match = FILE_REF_PATTERN.exec(input)) !== null) {
|
||||
const ref = match[1]!;
|
||||
if (!refs.includes(ref)) {
|
||||
refs.push(ref);
|
||||
}
|
||||
}
|
||||
|
||||
if (refs.length === 0) {
|
||||
return { expandedMessage: input, filesAttached: [], errors: [] };
|
||||
}
|
||||
|
||||
if (refs.length > MAX_FILES_PER_MESSAGE) {
|
||||
return {
|
||||
expandedMessage: input,
|
||||
filesAttached: [],
|
||||
errors: [`Too many file references (${refs.length}). Maximum is ${MAX_FILES_PER_MESSAGE}.`],
|
||||
};
|
||||
}
|
||||
|
||||
const filesAttached: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const attachments: string[] = [];
|
||||
|
||||
for (const ref of refs) {
|
||||
const filePath = resolveFilePath(ref);
|
||||
try {
|
||||
const info = await stat(filePath);
|
||||
if (!info.isFile()) {
|
||||
errors.push(`@${ref}: not a file`);
|
||||
continue;
|
||||
}
|
||||
if (info.size > MAX_FILE_SIZE) {
|
||||
errors.push(
|
||||
`@${ref}: file too large (${(info.size / 1024).toFixed(0)} KB, limit ${MAX_FILE_SIZE / 1024} KB)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const lang = getLanguageHint(filePath);
|
||||
const name = basename(filePath);
|
||||
attachments.push(`\n📎 ${ref} (${name}):\n\`\`\`${lang}\n${content}\n\`\`\``);
|
||||
filesAttached.push(ref);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// Only report meaningful errors — ENOENT is common for false @mention matches
|
||||
if (msg.includes('ENOENT')) {
|
||||
// Check if this looks like a file path (has extension or slash)
|
||||
if (ref.includes('/') || ref.includes('.')) {
|
||||
errors.push(`@${ref}: file not found`);
|
||||
}
|
||||
// Otherwise silently skip — likely an @mention, not a file ref
|
||||
} else {
|
||||
errors.push(`@${ref}: ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (attachments.length === 0) {
|
||||
return { expandedMessage: input, filesAttached, errors };
|
||||
}
|
||||
|
||||
const expandedMessage = input + '\n' + attachments.join('\n');
|
||||
return { expandedMessage, filesAttached, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the /attach <path> command.
|
||||
* Reads a file and returns the content formatted for inclusion in the chat.
|
||||
*/
|
||||
export async function handleAttachCommand(
|
||||
args: string,
|
||||
): Promise<{ content: string; error?: string }> {
|
||||
const filePath = args.trim();
|
||||
if (!filePath) {
|
||||
return { content: '', error: 'Usage: /attach <file-path>' };
|
||||
}
|
||||
|
||||
const resolved = resolveFilePath(filePath);
|
||||
try {
|
||||
const info = await stat(resolved);
|
||||
if (!info.isFile()) {
|
||||
return { content: '', error: `Not a file: ${filePath}` };
|
||||
}
|
||||
if (info.size > MAX_FILE_SIZE) {
|
||||
return {
|
||||
content: '',
|
||||
error: `File too large (${(info.size / 1024).toFixed(0)} KB, limit ${MAX_FILE_SIZE / 1024} KB)`,
|
||||
};
|
||||
}
|
||||
const content = await readFile(resolved, 'utf8');
|
||||
const lang = getLanguageHint(resolved);
|
||||
const name = basename(resolved);
|
||||
return {
|
||||
content: `📎 Attached file: ${name} (${filePath})\n\`\`\`${lang}\n${content}\n\`\`\``,
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { content: '', error: `Failed to read file: ${msg}` };
|
||||
}
|
||||
}
|
||||
438
packages/mosaic/src/tui/gateway-api.ts
Normal file
438
packages/mosaic/src/tui/gateway-api.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* Minimal gateway REST API client for the TUI and CLI commands.
|
||||
*/
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
provider: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ProviderInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
available: boolean;
|
||||
models: ModelInfo[];
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
id: string;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
createdAt: string;
|
||||
promptCount: number;
|
||||
channels: string[];
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface SessionListResult {
|
||||
sessions: SessionInfo[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ── Agent Config types ──
|
||||
|
||||
export interface AgentConfigInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
status: string;
|
||||
projectId: string | null;
|
||||
ownerId: string | null;
|
||||
systemPrompt: string | null;
|
||||
allowedTools: string[] | null;
|
||||
skills: string[] | null;
|
||||
isSystem: boolean;
|
||||
config: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── Project types ──
|
||||
|
||||
export interface ProjectInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
ownerId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── Mission types ──
|
||||
|
||||
export interface MissionInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
projectId: string | null;
|
||||
userId: string | null;
|
||||
phase: string | null;
|
||||
milestones: Record<string, unknown>[] | null;
|
||||
config: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── Mission Task types ──
|
||||
|
||||
export interface MissionTaskInfo {
|
||||
id: string;
|
||||
missionId: string;
|
||||
taskId: string | null;
|
||||
userId: string;
|
||||
status: string;
|
||||
description: string | null;
|
||||
notes: string | null;
|
||||
pr: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function headers(sessionCookie: string, gatewayUrl: string) {
|
||||
return { Cookie: sessionCookie, Origin: gatewayUrl };
|
||||
}
|
||||
|
||||
function jsonHeaders(sessionCookie: string, gatewayUrl: string) {
|
||||
return { ...headers(sessionCookie, gatewayUrl), 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
async function handleResponse<T>(res: Response, errorPrefix: string): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`${errorPrefix} (${res.status}): ${body}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
// ── Conversation types ──
|
||||
|
||||
export interface ConversationInfo {
|
||||
id: string;
|
||||
title: string | null;
|
||||
archived: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── Conversation endpoints ──
|
||||
|
||||
export async function createConversation(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
data: { title?: string; projectId?: string } = {},
|
||||
): Promise<ConversationInfo> {
|
||||
const res = await fetch(`${gatewayUrl}/api/conversations`, {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleResponse<ConversationInfo>(res, 'Failed to create conversation');
|
||||
}
|
||||
|
||||
// ── Provider / Model endpoints ──
|
||||
|
||||
export async function fetchAvailableModels(
|
||||
gatewayUrl: string,
|
||||
sessionCookie?: string,
|
||||
): Promise<ModelInfo[]> {
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/providers/models`, {
|
||||
headers: {
|
||||
...(sessionCookie ? { Cookie: sessionCookie } : {}),
|
||||
Origin: gatewayUrl,
|
||||
},
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as ModelInfo[];
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchProviders(
|
||||
gatewayUrl: string,
|
||||
sessionCookie?: string,
|
||||
): Promise<ProviderInfo[]> {
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/providers`, {
|
||||
headers: {
|
||||
...(sessionCookie ? { Cookie: sessionCookie } : {}),
|
||||
Origin: gatewayUrl,
|
||||
},
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as ProviderInfo[];
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Session endpoints ──
|
||||
|
||||
export async function fetchSessions(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
): Promise<SessionListResult> {
|
||||
const res = await fetch(`${gatewayUrl}/api/sessions`, {
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
return handleResponse<SessionListResult>(res, 'Failed to list sessions');
|
||||
}
|
||||
|
||||
export async function deleteSession(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${gatewayUrl}/api/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
method: 'DELETE',
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
if (!res.ok && res.status !== 204) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Failed to destroy session (${res.status}): ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Agent Config endpoints ──
|
||||
|
||||
export async function fetchAgentConfigs(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
): Promise<AgentConfigInfo[]> {
|
||||
const res = await fetch(`${gatewayUrl}/api/agents`, {
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
return handleResponse<AgentConfigInfo[]>(res, 'Failed to list agents');
|
||||
}
|
||||
|
||||
export async function fetchAgentConfig(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
id: string,
|
||||
): Promise<AgentConfigInfo> {
|
||||
const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, {
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
return handleResponse<AgentConfigInfo>(res, 'Failed to get agent');
|
||||
}
|
||||
|
||||
export async function createAgentConfig(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
data: {
|
||||
name: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
projectId?: string;
|
||||
systemPrompt?: string;
|
||||
allowedTools?: string[];
|
||||
skills?: string[];
|
||||
config?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<AgentConfigInfo> {
|
||||
const res = await fetch(`${gatewayUrl}/api/agents`, {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleResponse<AgentConfigInfo>(res, 'Failed to create agent');
|
||||
}
|
||||
|
||||
export async function updateAgentConfig(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
id: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<AgentConfigInfo> {
|
||||
const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleResponse<AgentConfigInfo>(res, 'Failed to update agent');
|
||||
}
|
||||
|
||||
export async function deleteAgentConfig(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
if (!res.ok && res.status !== 204) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Failed to delete agent (${res.status}): ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Project endpoints ──
|
||||
|
||||
export async function fetchProjects(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
): Promise<ProjectInfo[]> {
|
||||
const res = await fetch(`${gatewayUrl}/api/projects`, {
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
return handleResponse<ProjectInfo[]>(res, 'Failed to list projects');
|
||||
}
|
||||
|
||||
// ── Mission endpoints ──
|
||||
|
||||
export async function fetchMissions(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
): Promise<MissionInfo[]> {
|
||||
const res = await fetch(`${gatewayUrl}/api/missions`, {
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
return handleResponse<MissionInfo[]>(res, 'Failed to list missions');
|
||||
}
|
||||
|
||||
export async function fetchMission(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
id: string,
|
||||
): Promise<MissionInfo> {
|
||||
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, {
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
return handleResponse<MissionInfo>(res, 'Failed to get mission');
|
||||
}
|
||||
|
||||
export async function createMission(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
projectId?: string;
|
||||
status?: string;
|
||||
phase?: string;
|
||||
milestones?: Record<string, unknown>[];
|
||||
config?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<MissionInfo> {
|
||||
const res = await fetch(`${gatewayUrl}/api/missions`, {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleResponse<MissionInfo>(res, 'Failed to create mission');
|
||||
}
|
||||
|
||||
export async function updateMission(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
id: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<MissionInfo> {
|
||||
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleResponse<MissionInfo>(res, 'Failed to update mission');
|
||||
}
|
||||
|
||||
export async function deleteMission(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
if (!res.ok && res.status !== 204) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Failed to delete mission (${res.status}): ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Conversation Message types ──
|
||||
|
||||
export interface ConversationMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ── Conversation Message endpoints ──
|
||||
|
||||
export async function fetchConversationMessages(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
conversationId: string,
|
||||
): Promise<ConversationMessage[]> {
|
||||
const res = await fetch(
|
||||
`${gatewayUrl}/api/conversations/${encodeURIComponent(conversationId)}/messages`,
|
||||
{
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
},
|
||||
);
|
||||
return handleResponse<ConversationMessage[]>(res, 'Failed to fetch conversation messages');
|
||||
}
|
||||
|
||||
// ── Mission Task endpoints ──
|
||||
|
||||
export async function fetchMissionTasks(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
missionId: string,
|
||||
): Promise<MissionTaskInfo[]> {
|
||||
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks`, {
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
return handleResponse<MissionTaskInfo[]>(res, 'Failed to list mission tasks');
|
||||
}
|
||||
|
||||
export async function createMissionTask(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
missionId: string,
|
||||
data: {
|
||||
description?: string;
|
||||
status?: string;
|
||||
notes?: string;
|
||||
pr?: string;
|
||||
taskId?: string;
|
||||
},
|
||||
): Promise<MissionTaskInfo> {
|
||||
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks`, {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleResponse<MissionTaskInfo>(res, 'Failed to create mission task');
|
||||
}
|
||||
|
||||
export async function updateMissionTask(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
missionId: string,
|
||||
taskId: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<MissionTaskInfo> {
|
||||
const res = await fetch(
|
||||
`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks/${encodeURIComponent(taskId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
);
|
||||
return handleResponse<MissionTaskInfo>(res, 'Failed to update mission task');
|
||||
}
|
||||
37
packages/mosaic/src/tui/hooks/use-app-mode.ts
Normal file
37
packages/mosaic/src/tui/hooks/use-app-mode.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export type AppMode = 'chat' | 'sidebar' | 'search';
|
||||
|
||||
export interface UseAppModeReturn {
|
||||
mode: AppMode;
|
||||
setMode: (mode: AppMode) => void;
|
||||
toggleSidebar: () => void;
|
||||
sidebarOpen: boolean;
|
||||
}
|
||||
|
||||
export function useAppMode(): UseAppModeReturn {
|
||||
const [mode, setModeState] = useState<AppMode>('chat');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const setMode = useCallback((next: AppMode) => {
|
||||
setModeState(next);
|
||||
if (next === 'sidebar') {
|
||||
setSidebarOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleSidebar = useCallback(() => {
|
||||
setSidebarOpen((prev) => {
|
||||
if (prev) {
|
||||
// Closing sidebar — return to chat
|
||||
setModeState('chat');
|
||||
return false;
|
||||
}
|
||||
// Opening sidebar — set mode to sidebar
|
||||
setModeState('sidebar');
|
||||
return true;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { mode, setMode, toggleSidebar, sidebarOpen };
|
||||
}
|
||||
143
packages/mosaic/src/tui/hooks/use-conversations.ts
Normal file
143
packages/mosaic/src/tui/hooks/use-conversations.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
export interface ConversationSummary {
|
||||
id: string;
|
||||
title: string | null;
|
||||
archived: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UseConversationsOptions {
|
||||
gatewayUrl: string;
|
||||
sessionCookie?: string;
|
||||
}
|
||||
|
||||
export interface UseConversationsReturn {
|
||||
conversations: ConversationSummary[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
createConversation: (title?: string) => Promise<ConversationSummary | null>;
|
||||
deleteConversation: (id: string) => Promise<boolean>;
|
||||
renameConversation: (id: string, title: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function useConversations(opts: UseConversationsOptions): UseConversationsReturn {
|
||||
const { gatewayUrl, sessionCookie } = opts;
|
||||
|
||||
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const headers = useCallback(
|
||||
(includeContentType = true): Record<string, string> => {
|
||||
const h: Record<string, string> = { Origin: gatewayUrl };
|
||||
if (includeContentType) h['Content-Type'] = 'application/json';
|
||||
if (sessionCookie) h['Cookie'] = sessionCookie;
|
||||
return h;
|
||||
},
|
||||
[gatewayUrl, sessionCookie],
|
||||
);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!mountedRef.current) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/conversations`, { headers: headers(false) });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as ConversationSummary[];
|
||||
if (mountedRef.current) {
|
||||
setConversations(data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mountedRef.current) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [gatewayUrl, headers]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
void refresh();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const createConversation = useCallback(
|
||||
async (title?: string): Promise<ConversationSummary | null> => {
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/conversations`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ title: title ?? null }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as ConversationSummary;
|
||||
if (mountedRef.current) {
|
||||
setConversations((prev) => [data, ...prev]);
|
||||
}
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[gatewayUrl, headers],
|
||||
);
|
||||
|
||||
const deleteConversation = useCallback(
|
||||
async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: headers(false),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
if (mountedRef.current) {
|
||||
setConversations((prev) => prev.filter((c) => c.id !== id));
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[gatewayUrl, headers],
|
||||
);
|
||||
|
||||
const renameConversation = useCallback(
|
||||
async (id: string, title: string): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
if (mountedRef.current) {
|
||||
setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, title } : c)));
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[gatewayUrl, headers],
|
||||
);
|
||||
|
||||
return {
|
||||
conversations,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
createConversation,
|
||||
deleteConversation,
|
||||
renameConversation,
|
||||
};
|
||||
}
|
||||
29
packages/mosaic/src/tui/hooks/use-git-info.ts
Normal file
29
packages/mosaic/src/tui/hooks/use-git-info.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
export interface GitInfo {
|
||||
branch: string | null;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export function useGitInfo(): GitInfo {
|
||||
const [info, setInfo] = useState<GitInfo>({
|
||||
branch: null,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const branch = execSync('git rev-parse --abbrev-ref HEAD', {
|
||||
encoding: 'utf-8',
|
||||
timeout: 3000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
setInfo({ branch, cwd: process.cwd() });
|
||||
} catch {
|
||||
setInfo({ branch: null, cwd: process.cwd() });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return info;
|
||||
}
|
||||
126
packages/mosaic/src/tui/hooks/use-input-history.test.ts
Normal file
126
packages/mosaic/src/tui/hooks/use-input-history.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
/**
|
||||
* Tests for input history logic extracted from useInputHistory.
|
||||
* We test the pure state transitions directly rather than using
|
||||
* React testing utilities to avoid react-dom version conflicts.
|
||||
*/
|
||||
|
||||
const MAX_HISTORY = 50;
|
||||
|
||||
function createHistoryState() {
|
||||
let history: string[] = [];
|
||||
let historyIndex = -1;
|
||||
let savedInput = '';
|
||||
|
||||
function addToHistory(input: string): void {
|
||||
if (!input.trim()) return;
|
||||
if (history[0] === input) return;
|
||||
history = [input, ...history].slice(0, MAX_HISTORY);
|
||||
historyIndex = -1;
|
||||
}
|
||||
|
||||
function navigateUp(currentInput: string): string | null {
|
||||
if (history.length === 0) return null;
|
||||
if (historyIndex === -1) {
|
||||
savedInput = currentInput;
|
||||
}
|
||||
const nextIndex = Math.min(historyIndex + 1, history.length - 1);
|
||||
historyIndex = nextIndex;
|
||||
return history[nextIndex] ?? null;
|
||||
}
|
||||
|
||||
function navigateDown(): string | null {
|
||||
if (historyIndex <= 0) {
|
||||
historyIndex = -1;
|
||||
return savedInput;
|
||||
}
|
||||
const nextIndex = historyIndex - 1;
|
||||
historyIndex = nextIndex;
|
||||
return history[nextIndex] ?? null;
|
||||
}
|
||||
|
||||
function resetNavigation(): void {
|
||||
historyIndex = -1;
|
||||
}
|
||||
|
||||
function getHistoryLength(): number {
|
||||
return history.length;
|
||||
}
|
||||
|
||||
return { addToHistory, navigateUp, navigateDown, resetNavigation, getHistoryLength };
|
||||
}
|
||||
|
||||
describe('useInputHistory (logic)', () => {
|
||||
let h: ReturnType<typeof createHistoryState>;
|
||||
|
||||
beforeEach(() => {
|
||||
h = createHistoryState();
|
||||
});
|
||||
|
||||
it('adds to history on submit', () => {
|
||||
h.addToHistory('hello');
|
||||
h.addToHistory('world');
|
||||
// navigateUp should return 'world' first (most recent)
|
||||
const val = h.navigateUp('');
|
||||
expect(val).toBe('world');
|
||||
});
|
||||
|
||||
it('does not add empty strings to history', () => {
|
||||
h.addToHistory('');
|
||||
h.addToHistory(' ');
|
||||
const val = h.navigateUp('');
|
||||
expect(val).toBeNull();
|
||||
});
|
||||
|
||||
it('navigateDown after up returns saved input', () => {
|
||||
h.addToHistory('first');
|
||||
const up = h.navigateUp('current');
|
||||
expect(up).toBe('first');
|
||||
const down = h.navigateDown();
|
||||
expect(down).toBe('current');
|
||||
});
|
||||
|
||||
it('does not add duplicate consecutive entries', () => {
|
||||
h.addToHistory('same');
|
||||
h.addToHistory('same');
|
||||
expect(h.getHistoryLength()).toBe(1);
|
||||
});
|
||||
|
||||
it('caps history at MAX_HISTORY entries', () => {
|
||||
for (let i = 0; i < 55; i++) {
|
||||
h.addToHistory(`entry-${i}`);
|
||||
}
|
||||
expect(h.getHistoryLength()).toBe(50);
|
||||
// Navigate to the oldest entry
|
||||
let val: string | null = null;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
val = h.navigateUp('');
|
||||
}
|
||||
// Oldest entry at index 49 = entry-5 (entries 54 down to 5, 50 total)
|
||||
expect(val).toBe('entry-5');
|
||||
});
|
||||
|
||||
it('navigateUp returns null when history is empty', () => {
|
||||
const val = h.navigateUp('something');
|
||||
expect(val).toBeNull();
|
||||
});
|
||||
|
||||
it('navigateUp cycles through multiple entries', () => {
|
||||
h.addToHistory('a');
|
||||
h.addToHistory('b');
|
||||
h.addToHistory('c');
|
||||
expect(h.navigateUp('')).toBe('c');
|
||||
expect(h.navigateUp('c')).toBe('b');
|
||||
expect(h.navigateUp('b')).toBe('a');
|
||||
});
|
||||
|
||||
it('resetNavigation resets index to -1', () => {
|
||||
h.addToHistory('test');
|
||||
h.navigateUp('');
|
||||
h.resetNavigation();
|
||||
// After reset, navigateUp from index -1 returns most recent again
|
||||
const val = h.navigateUp('');
|
||||
expect(val).toBe('test');
|
||||
});
|
||||
});
|
||||
48
packages/mosaic/src/tui/hooks/use-input-history.ts
Normal file
48
packages/mosaic/src/tui/hooks/use-input-history.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
const MAX_HISTORY = 50;
|
||||
|
||||
export function useInputHistory() {
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState<number>(-1);
|
||||
const [savedInput, setSavedInput] = useState<string>('');
|
||||
|
||||
const addToHistory = useCallback((input: string) => {
|
||||
if (!input.trim()) return;
|
||||
setHistory((prev) => {
|
||||
// Avoid duplicate consecutive entries
|
||||
if (prev[0] === input) return prev;
|
||||
return [input, ...prev].slice(0, MAX_HISTORY);
|
||||
});
|
||||
setHistoryIndex(-1);
|
||||
}, []);
|
||||
|
||||
const navigateUp = useCallback(
|
||||
(currentInput: string): string | null => {
|
||||
if (history.length === 0) return null;
|
||||
if (historyIndex === -1) {
|
||||
setSavedInput(currentInput);
|
||||
}
|
||||
const nextIndex = Math.min(historyIndex + 1, history.length - 1);
|
||||
setHistoryIndex(nextIndex);
|
||||
return history[nextIndex] ?? null;
|
||||
},
|
||||
[history, historyIndex],
|
||||
);
|
||||
|
||||
const navigateDown = useCallback((): string | null => {
|
||||
if (historyIndex <= 0) {
|
||||
setHistoryIndex(-1);
|
||||
return savedInput;
|
||||
}
|
||||
const nextIndex = historyIndex - 1;
|
||||
setHistoryIndex(nextIndex);
|
||||
return history[nextIndex] ?? null;
|
||||
}, [history, historyIndex, savedInput]);
|
||||
|
||||
const resetNavigation = useCallback(() => {
|
||||
setHistoryIndex(-1);
|
||||
}, []);
|
||||
|
||||
return { addToHistory, navigateUp, navigateDown, resetNavigation };
|
||||
}
|
||||
76
packages/mosaic/src/tui/hooks/use-search.ts
Normal file
76
packages/mosaic/src/tui/hooks/use-search.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import type { Message } from './use-socket.js';
|
||||
|
||||
export interface SearchMatch {
|
||||
messageIndex: number;
|
||||
charOffset: number;
|
||||
}
|
||||
|
||||
export interface UseSearchReturn {
|
||||
query: string;
|
||||
setQuery: (q: string) => void;
|
||||
matches: SearchMatch[];
|
||||
currentMatchIndex: number;
|
||||
nextMatch: () => void;
|
||||
prevMatch: () => void;
|
||||
clear: () => void;
|
||||
totalMatches: number;
|
||||
}
|
||||
|
||||
export function useSearch(messages: Message[]): UseSearchReturn {
|
||||
const [query, setQuery] = useState('');
|
||||
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
|
||||
|
||||
const matches = useMemo<SearchMatch[]>(() => {
|
||||
if (query.length < 2) return [];
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const result: SearchMatch[] = [];
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
if (!msg) continue;
|
||||
const content = msg.content.toLowerCase();
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
const idx = content.indexOf(lowerQuery, offset);
|
||||
if (idx === -1) break;
|
||||
result.push({ messageIndex: i, charOffset: idx });
|
||||
offset = idx + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [query, messages]);
|
||||
|
||||
// Reset match index when matches change
|
||||
useMemo(() => {
|
||||
setCurrentMatchIndex(0);
|
||||
}, [matches]);
|
||||
|
||||
const nextMatch = useCallback(() => {
|
||||
if (matches.length === 0) return;
|
||||
setCurrentMatchIndex((prev) => (prev + 1) % matches.length);
|
||||
}, [matches.length]);
|
||||
|
||||
const prevMatch = useCallback(() => {
|
||||
if (matches.length === 0) return;
|
||||
setCurrentMatchIndex((prev) => (prev - 1 + matches.length) % matches.length);
|
||||
}, [matches.length]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setQuery('');
|
||||
setCurrentMatchIndex(0);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
query,
|
||||
setQuery,
|
||||
matches,
|
||||
currentMatchIndex,
|
||||
nextMatch,
|
||||
prevMatch,
|
||||
clear,
|
||||
totalMatches: matches.length,
|
||||
};
|
||||
}
|
||||
339
packages/mosaic/src/tui/hooks/use-socket.ts
Normal file
339
packages/mosaic/src/tui/hooks/use-socket.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import { type MutableRefObject, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import type {
|
||||
ServerToClientEvents,
|
||||
ClientToServerEvents,
|
||||
MessageAckPayload,
|
||||
AgentEndPayload,
|
||||
AgentTextPayload,
|
||||
AgentThinkingPayload,
|
||||
ToolStartPayload,
|
||||
ToolEndPayload,
|
||||
SessionInfoPayload,
|
||||
ErrorPayload,
|
||||
CommandManifestPayload,
|
||||
SlashCommandResultPayload,
|
||||
SystemReloadPayload,
|
||||
RoutingDecisionInfo,
|
||||
} from '@mosaic/types';
|
||||
import { commandRegistry } from '../commands/index.js';
|
||||
|
||||
export interface ToolCall {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
status: 'running' | 'success' | 'error';
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
role: 'user' | 'assistant' | 'thinking' | 'tool' | 'system';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
toolCalls?: ToolCall[];
|
||||
}
|
||||
|
||||
export interface TokenUsage {
|
||||
input: number;
|
||||
output: number;
|
||||
total: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
cost: number;
|
||||
contextPercent: number;
|
||||
contextWindow: number;
|
||||
}
|
||||
|
||||
export interface UseSocketOptions {
|
||||
gatewayUrl: string;
|
||||
sessionCookie?: string;
|
||||
initialConversationId?: string;
|
||||
initialModel?: string;
|
||||
initialProvider?: string;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
type TypedSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
||||
|
||||
export interface UseSocketReturn {
|
||||
connected: boolean;
|
||||
connecting: boolean;
|
||||
messages: Message[];
|
||||
conversationId: string | undefined;
|
||||
isStreaming: boolean;
|
||||
currentStreamText: string;
|
||||
currentThinkingText: string;
|
||||
activeToolCalls: ToolCall[];
|
||||
tokenUsage: TokenUsage;
|
||||
modelName: string | null;
|
||||
providerName: string | null;
|
||||
thinkingLevel: string;
|
||||
availableThinkingLevels: string[];
|
||||
/** Last routing decision received from the gateway (M4-008) */
|
||||
routingDecision: RoutingDecisionInfo | null;
|
||||
sendMessage: (content: string) => void;
|
||||
addSystemMessage: (content: string) => void;
|
||||
setThinkingLevel: (level: string) => void;
|
||||
switchConversation: (id: string) => void;
|
||||
clearMessages: () => void;
|
||||
connectionError: string | null;
|
||||
socketRef: MutableRefObject<TypedSocket | null>;
|
||||
}
|
||||
|
||||
const EMPTY_USAGE: TokenUsage = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
total: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cost: 0,
|
||||
contextPercent: 0,
|
||||
contextWindow: 0,
|
||||
};
|
||||
|
||||
export function useSocket(opts: UseSocketOptions): UseSocketReturn {
|
||||
const {
|
||||
gatewayUrl,
|
||||
sessionCookie,
|
||||
initialConversationId,
|
||||
initialModel,
|
||||
initialProvider,
|
||||
agentId,
|
||||
} = opts;
|
||||
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [connecting, setConnecting] = useState(true);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [conversationId, setConversationId] = useState(initialConversationId);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [currentStreamText, setCurrentStreamText] = useState('');
|
||||
const [currentThinkingText, setCurrentThinkingText] = useState('');
|
||||
const [activeToolCalls, setActiveToolCalls] = useState<ToolCall[]>([]);
|
||||
const [tokenUsage, setTokenUsage] = useState<TokenUsage>(EMPTY_USAGE);
|
||||
const [modelName, setModelName] = useState<string | null>(null);
|
||||
const [providerName, setProviderName] = useState<string | null>(null);
|
||||
const [thinkingLevel, setThinkingLevelState] = useState<string>('off');
|
||||
const [availableThinkingLevels, setAvailableThinkingLevels] = useState<string[]>([]);
|
||||
const [routingDecision, setRoutingDecision] = useState<RoutingDecisionInfo | null>(null);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
|
||||
const socketRef = useRef<TypedSocket | null>(null);
|
||||
const conversationIdRef = useRef(conversationId);
|
||||
conversationIdRef.current = conversationId;
|
||||
|
||||
useEffect(() => {
|
||||
const socket = io(`${gatewayUrl}/chat`, {
|
||||
transports: ['websocket'],
|
||||
extraHeaders: sessionCookie ? { Cookie: sessionCookie } : undefined,
|
||||
reconnection: true,
|
||||
reconnectionDelay: 2000,
|
||||
reconnectionAttempts: Infinity,
|
||||
}) as TypedSocket;
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on('connect', () => {
|
||||
setConnected(true);
|
||||
setConnecting(false);
|
||||
setConnectionError(null);
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
setConnected(false);
|
||||
setIsStreaming(false);
|
||||
setCurrentStreamText('');
|
||||
setCurrentThinkingText('');
|
||||
setActiveToolCalls([]);
|
||||
});
|
||||
|
||||
socket.io.on('error', (err: Error) => {
|
||||
setConnecting(false);
|
||||
setConnectionError(err.message);
|
||||
});
|
||||
|
||||
socket.on('message:ack', (data: MessageAckPayload) => {
|
||||
setConversationId(data.conversationId);
|
||||
});
|
||||
|
||||
socket.on('session:info', (data: SessionInfoPayload) => {
|
||||
setProviderName(data.provider);
|
||||
setModelName(data.modelId);
|
||||
setThinkingLevelState(data.thinkingLevel);
|
||||
setAvailableThinkingLevels(data.availableThinkingLevels);
|
||||
// Update routing decision if provided (M4-008)
|
||||
if (data.routingDecision) {
|
||||
setRoutingDecision(data.routingDecision);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('agent:start', () => {
|
||||
setIsStreaming(true);
|
||||
setCurrentStreamText('');
|
||||
setCurrentThinkingText('');
|
||||
setActiveToolCalls([]);
|
||||
});
|
||||
|
||||
socket.on('agent:text', (data: AgentTextPayload) => {
|
||||
setCurrentStreamText((prev) => prev + data.text);
|
||||
});
|
||||
|
||||
socket.on('agent:thinking', (data: AgentThinkingPayload) => {
|
||||
setCurrentThinkingText((prev) => prev + data.text);
|
||||
});
|
||||
|
||||
socket.on('agent:tool:start', (data: ToolStartPayload) => {
|
||||
setActiveToolCalls((prev) => [
|
||||
...prev,
|
||||
{ toolCallId: data.toolCallId, toolName: data.toolName, status: 'running' },
|
||||
]);
|
||||
});
|
||||
|
||||
socket.on('agent:tool:end', (data: ToolEndPayload) => {
|
||||
setActiveToolCalls((prev) =>
|
||||
prev.map((tc) =>
|
||||
tc.toolCallId === data.toolCallId
|
||||
? { ...tc, status: data.isError ? 'error' : 'success' }
|
||||
: tc,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
socket.on('agent:end', (data: AgentEndPayload) => {
|
||||
setCurrentStreamText((prev) => {
|
||||
if (prev) {
|
||||
setMessages((msgs) => [
|
||||
...msgs,
|
||||
{ role: 'assistant', content: prev, timestamp: new Date() },
|
||||
]);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
setCurrentThinkingText('');
|
||||
setActiveToolCalls([]);
|
||||
setIsStreaming(false);
|
||||
|
||||
// Update usage from the payload
|
||||
if (data.usage) {
|
||||
setProviderName(data.usage.provider);
|
||||
setModelName(data.usage.modelId);
|
||||
setThinkingLevelState(data.usage.thinkingLevel);
|
||||
setTokenUsage({
|
||||
input: data.usage.tokens.input,
|
||||
output: data.usage.tokens.output,
|
||||
total: data.usage.tokens.total,
|
||||
cacheRead: data.usage.tokens.cacheRead,
|
||||
cacheWrite: data.usage.tokens.cacheWrite,
|
||||
cost: data.usage.cost,
|
||||
contextPercent: data.usage.context.percent ?? 0,
|
||||
contextWindow: data.usage.context.window,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (data: ErrorPayload) => {
|
||||
setMessages((msgs) => [
|
||||
...msgs,
|
||||
{ role: 'assistant', content: `Error: ${data.error}`, timestamp: new Date() },
|
||||
]);
|
||||
setIsStreaming(false);
|
||||
});
|
||||
|
||||
socket.on('commands:manifest', (data: CommandManifestPayload) => {
|
||||
commandRegistry.updateManifest(data.manifest);
|
||||
});
|
||||
|
||||
socket.on('command:result', (data: SlashCommandResultPayload) => {
|
||||
const prefix = data.success ? '' : 'Error: ';
|
||||
const text = data.message ?? (data.success ? 'Done.' : 'Command failed.');
|
||||
setMessages((msgs) => [
|
||||
...msgs,
|
||||
{ role: 'system', content: `${prefix}${text}`, timestamp: new Date() },
|
||||
]);
|
||||
});
|
||||
|
||||
socket.on('system:reload', (data: SystemReloadPayload) => {
|
||||
commandRegistry.updateManifest({
|
||||
commands: data.commands,
|
||||
skills: data.skills,
|
||||
version: Date.now(),
|
||||
});
|
||||
setMessages((msgs) => [
|
||||
...msgs,
|
||||
{ role: 'system', content: data.message, timestamp: new Date() },
|
||||
]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [gatewayUrl, sessionCookie]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(content: string) => {
|
||||
if (!content.trim() || isStreaming) return;
|
||||
if (!socketRef.current?.connected) return;
|
||||
|
||||
setMessages((msgs) => [...msgs, { role: 'user', content, timestamp: new Date() }]);
|
||||
|
||||
socketRef.current.emit('message', {
|
||||
conversationId,
|
||||
content,
|
||||
...(initialProvider ? { provider: initialProvider } : {}),
|
||||
...(initialModel ? { modelId: initialModel } : {}),
|
||||
...(agentId ? { agentId } : {}),
|
||||
});
|
||||
},
|
||||
[conversationId, isStreaming],
|
||||
);
|
||||
|
||||
const addSystemMessage = useCallback((content: string) => {
|
||||
setMessages((msgs) => [...msgs, { role: 'system', content, timestamp: new Date() }]);
|
||||
}, []);
|
||||
|
||||
const setThinkingLevel = useCallback((level: string) => {
|
||||
const cid = conversationIdRef.current;
|
||||
if (!socketRef.current?.connected || !cid) return;
|
||||
socketRef.current.emit('set:thinking', {
|
||||
conversationId: cid,
|
||||
level,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([]);
|
||||
setCurrentStreamText('');
|
||||
setCurrentThinkingText('');
|
||||
setActiveToolCalls([]);
|
||||
setIsStreaming(false);
|
||||
}, []);
|
||||
|
||||
const switchConversation = useCallback(
|
||||
(id: string) => {
|
||||
clearMessages();
|
||||
setConversationId(id);
|
||||
},
|
||||
[clearMessages],
|
||||
);
|
||||
|
||||
return {
|
||||
connected,
|
||||
connecting,
|
||||
messages,
|
||||
conversationId,
|
||||
isStreaming,
|
||||
currentStreamText,
|
||||
currentThinkingText,
|
||||
activeToolCalls,
|
||||
tokenUsage,
|
||||
modelName,
|
||||
providerName,
|
||||
thinkingLevel,
|
||||
availableThinkingLevels,
|
||||
routingDecision,
|
||||
sendMessage,
|
||||
addSystemMessage,
|
||||
setThinkingLevel,
|
||||
switchConversation,
|
||||
clearMessages,
|
||||
connectionError,
|
||||
socketRef,
|
||||
};
|
||||
}
|
||||
80
packages/mosaic/src/tui/hooks/use-viewport.ts
Normal file
80
packages/mosaic/src/tui/hooks/use-viewport.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useStdout } from 'ink';
|
||||
|
||||
export interface UseViewportOptions {
|
||||
totalItems: number;
|
||||
reservedLines?: number;
|
||||
}
|
||||
|
||||
export interface UseViewportReturn {
|
||||
scrollOffset: number;
|
||||
viewportSize: number;
|
||||
isScrolledUp: boolean;
|
||||
scrollToBottom: () => void;
|
||||
scrollBy: (delta: number) => void;
|
||||
scrollTo: (offset: number) => void;
|
||||
canScrollUp: boolean;
|
||||
canScrollDown: boolean;
|
||||
}
|
||||
|
||||
export function useViewport({
|
||||
totalItems,
|
||||
reservedLines = 10,
|
||||
}: UseViewportOptions): UseViewportReturn {
|
||||
const { stdout } = useStdout();
|
||||
const rows = stdout?.rows ?? 24;
|
||||
const viewportSize = Math.max(1, rows - reservedLines);
|
||||
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
const [autoFollow, setAutoFollow] = useState(true);
|
||||
|
||||
// Compute the maximum valid scroll offset
|
||||
const maxOffset = Math.max(0, totalItems - viewportSize);
|
||||
|
||||
// Auto-follow: when new items arrive and auto-follow is on, snap to bottom
|
||||
useEffect(() => {
|
||||
if (autoFollow) {
|
||||
setScrollOffset(maxOffset);
|
||||
}
|
||||
}, [autoFollow, maxOffset]);
|
||||
|
||||
const scrollTo = useCallback(
|
||||
(offset: number) => {
|
||||
const clamped = Math.max(0, Math.min(offset, maxOffset));
|
||||
setScrollOffset(clamped);
|
||||
setAutoFollow(clamped >= maxOffset);
|
||||
},
|
||||
[maxOffset],
|
||||
);
|
||||
|
||||
const scrollBy = useCallback(
|
||||
(delta: number) => {
|
||||
setScrollOffset((prev) => {
|
||||
const next = Math.max(0, Math.min(prev + delta, maxOffset));
|
||||
setAutoFollow(next >= maxOffset);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[maxOffset],
|
||||
);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
setScrollOffset(maxOffset);
|
||||
setAutoFollow(true);
|
||||
}, [maxOffset]);
|
||||
|
||||
const isScrolledUp = scrollOffset < maxOffset;
|
||||
const canScrollUp = scrollOffset > 0;
|
||||
const canScrollDown = scrollOffset < maxOffset;
|
||||
|
||||
return {
|
||||
scrollOffset,
|
||||
viewportSize,
|
||||
isScrolledUp,
|
||||
scrollToBottom,
|
||||
scrollBy,
|
||||
scrollTo,
|
||||
canScrollUp,
|
||||
canScrollDown,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user