feat: communication spine — gateway, TUI, Discord (#61)
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #61.
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "@mosaic/cli",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"bin": {
|
||||
"mosaic": "dist/index.js"
|
||||
"mosaic": "dist/cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
@@ -14,11 +15,22 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsx src/cli.ts",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"ink": "^5.0.0",
|
||||
"ink-text-input": "^6.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"react": "^18.3.0",
|
||||
"socket.io-client": "^4.8.0",
|
||||
"commander": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.0",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
|
||||
28
packages/cli/src/cli.ts
Normal file
28
packages/cli/src/cli.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Command } from 'commander';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program.name('mosaic').description('Mosaic Stack CLI').version('0.0.0');
|
||||
|
||||
program
|
||||
.command('tui')
|
||||
.description('Launch interactive TUI connected to the gateway')
|
||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
|
||||
.option('-c, --conversation <id>', 'Resume a conversation by ID')
|
||||
.action(async (opts: { gateway: string; conversation?: string }) => {
|
||||
// Dynamic import to avoid loading React/Ink for other commands
|
||||
const { render } = await import('ink');
|
||||
const React = await import('react');
|
||||
const { TuiApp } = await import('./tui/app.js');
|
||||
|
||||
render(
|
||||
React.createElement(TuiApp, {
|
||||
gatewayUrl: opts.gateway,
|
||||
conversationId: opts.conversation,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
program.parse();
|
||||
164
packages/cli/src/tui/app.tsx
Normal file
164
packages/cli/src/tui/app.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { Box, Text, useInput, useApp } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
import Spinner from 'ink-spinner';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface TuiAppProps {
|
||||
gatewayUrl: string;
|
||||
conversationId?: string;
|
||||
}
|
||||
|
||||
export function TuiApp({ gatewayUrl, conversationId: initialConversationId }: TuiAppProps) {
|
||||
const { exit } = useApp();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [conversationId, setConversationId] = useState(initialConversationId);
|
||||
const [currentStreamText, setCurrentStreamText] = useState('');
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = io(`${gatewayUrl}/chat`, {
|
||||
transports: ['websocket'],
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on('connect', () => setConnected(true));
|
||||
socket.on('disconnect', () => {
|
||||
setConnected(false);
|
||||
setIsStreaming(false);
|
||||
setCurrentStreamText('');
|
||||
});
|
||||
socket.on('connect_error', (err: Error) => {
|
||||
setMessages((msgs) => [
|
||||
...msgs,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: `Connection failed: ${err.message}. Check that the gateway is running at ${gatewayUrl}.`,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
socket.on('message:ack', (data: { conversationId: string }) => {
|
||||
setConversationId(data.conversationId);
|
||||
});
|
||||
|
||||
socket.on('agent:start', () => {
|
||||
setIsStreaming(true);
|
||||
setCurrentStreamText('');
|
||||
});
|
||||
|
||||
socket.on('agent:text', (data: { text: string }) => {
|
||||
setCurrentStreamText((prev) => prev + data.text);
|
||||
});
|
||||
|
||||
socket.on('agent:end', () => {
|
||||
setCurrentStreamText((prev) => {
|
||||
if (prev) {
|
||||
setMessages((msgs) => [...msgs, { role: 'assistant', content: prev }]);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
setIsStreaming(false);
|
||||
});
|
||||
|
||||
socket.on('error', (data: { error: string }) => {
|
||||
setMessages((msgs) => [...msgs, { role: 'assistant', content: `Error: ${data.error}` }]);
|
||||
setIsStreaming(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [gatewayUrl]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(value: string) => {
|
||||
if (!value.trim() || isStreaming) return;
|
||||
if (!socketRef.current?.connected) {
|
||||
setMessages((msgs) => [
|
||||
...msgs,
|
||||
{ role: 'assistant', content: 'Not connected to gateway. Message not sent.' },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages((msgs) => [...msgs, { role: 'user', content: value }]);
|
||||
setInput('');
|
||||
|
||||
socketRef.current.emit('message', {
|
||||
conversationId,
|
||||
content: value,
|
||||
});
|
||||
},
|
||||
[conversationId, isStreaming],
|
||||
);
|
||||
|
||||
useInput((ch, key) => {
|
||||
if (key.ctrl && ch === 'c') {
|
||||
exit();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color="blue">
|
||||
Mosaic
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text dimColor>{connected ? `connected` : 'connecting...'}</Text>
|
||||
{conversationId && <Text dimColor> | {conversationId.slice(0, 8)}</Text>}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{messages.map((msg, i) => (
|
||||
<Box key={i} marginBottom={1}>
|
||||
<Text bold color={msg.role === 'user' ? 'green' : 'cyan'}>
|
||||
{msg.role === 'user' ? '> ' : ' '}
|
||||
</Text>
|
||||
<Text wrap="wrap">{msg.content}</Text>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{isStreaming && currentStreamText && (
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color="cyan">
|
||||
{' '}
|
||||
</Text>
|
||||
<Text wrap="wrap">{currentStreamText}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isStreaming && !currentStreamText && (
|
||||
<Box>
|
||||
<Text color="cyan">
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text dimColor> thinking...</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text bold color="green">
|
||||
{'> '}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder={isStreaming ? 'waiting...' : 'type a message'}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
"rootDir": "src",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
Reference in New Issue
Block a user