feat(cli): add --model/--provider flags and /model /provider TUI commands (#144)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #144.
This commit is contained in:
@@ -49,55 +49,62 @@ program
|
|||||||
.description('Launch interactive TUI connected to the gateway')
|
.description('Launch interactive TUI connected to the gateway')
|
||||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
|
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
|
||||||
.option('-c, --conversation <id>', 'Resume a conversation by ID')
|
.option('-c, --conversation <id>', 'Resume a conversation by ID')
|
||||||
.action(async (opts: { gateway: string; conversation?: string }) => {
|
.option('-m, --model <modelId>', 'Model ID to use (e.g. gpt-4o, llama3.2)')
|
||||||
const { loadSession, validateSession, signIn, saveSession } = await import('./auth.js');
|
.option('-p, --provider <provider>', 'Provider to use (e.g. openai, ollama)')
|
||||||
|
.action(
|
||||||
|
async (opts: { gateway: string; conversation?: string; model?: string; provider?: string }) => {
|
||||||
|
const { loadSession, validateSession, signIn, saveSession } = await import('./auth.js');
|
||||||
|
|
||||||
// Try loading saved session
|
// Try loading saved session
|
||||||
let session = loadSession(opts.gateway);
|
let session = loadSession(opts.gateway);
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
const valid = await validateSession(opts.gateway, session.cookie);
|
const valid = await validateSession(opts.gateway, session.cookie);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
console.log('Session expired. Please sign in again.');
|
console.log('Session expired. Please sign in again.');
|
||||||
session = null;
|
session = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// No valid session — prompt for credentials
|
// No valid session — prompt for credentials
|
||||||
if (!session) {
|
if (!session) {
|
||||||
const readline = await import('node:readline');
|
const readline = await import('node:readline');
|
||||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
const ask = (q: string): Promise<string> =>
|
||||||
|
new Promise((resolve) => rl.question(q, resolve));
|
||||||
|
|
||||||
console.log(`Sign in to ${opts.gateway}`);
|
console.log(`Sign in to ${opts.gateway}`);
|
||||||
const email = await ask('Email: ');
|
const email = await ask('Email: ');
|
||||||
const password = await ask('Password: ');
|
const password = await ask('Password: ');
|
||||||
rl.close();
|
rl.close();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const auth = await signIn(opts.gateway, email, password);
|
const auth = await signIn(opts.gateway, email, password);
|
||||||
saveSession(opts.gateway, auth);
|
saveSession(opts.gateway, auth);
|
||||||
session = auth;
|
session = auth;
|
||||||
console.log(`Signed in as ${auth.email}\n`);
|
console.log(`Signed in as ${auth.email}\n`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Dynamic import to avoid loading React/Ink for other commands
|
// Dynamic import to avoid loading React/Ink for other commands
|
||||||
const { render } = await import('ink');
|
const { render } = await import('ink');
|
||||||
const React = await import('react');
|
const React = await import('react');
|
||||||
const { TuiApp } = await import('./tui/app.js');
|
const { TuiApp } = await import('./tui/app.js');
|
||||||
|
|
||||||
render(
|
render(
|
||||||
React.createElement(TuiApp, {
|
React.createElement(TuiApp, {
|
||||||
gatewayUrl: opts.gateway,
|
gatewayUrl: opts.gateway,
|
||||||
conversationId: opts.conversation,
|
conversationId: opts.conversation,
|
||||||
sessionCookie: session.cookie,
|
sessionCookie: session.cookie,
|
||||||
}),
|
initialModel: opts.model,
|
||||||
);
|
initialProvider: opts.provider,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// ─── prdy ───────────────────────────────────────────────────────────────
|
// ─── prdy ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { Box, Text, useInput, useApp } from 'ink';
|
|||||||
import TextInput from 'ink-text-input';
|
import TextInput from 'ink-text-input';
|
||||||
import Spinner from 'ink-spinner';
|
import Spinner from 'ink-spinner';
|
||||||
import { io, type Socket } from 'socket.io-client';
|
import { io, type Socket } from 'socket.io-client';
|
||||||
|
import { fetchAvailableModels, type ModelInfo } from './gateway-api.js';
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant' | 'system';
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,12 +14,29 @@ interface TuiAppProps {
|
|||||||
gatewayUrl: string;
|
gatewayUrl: string;
|
||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
sessionCookie?: string;
|
sessionCookie?: string;
|
||||||
|
initialModel?: string;
|
||||||
|
initialProvider?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a slash command from user input.
|
||||||
|
* Returns null if the input is not a slash command.
|
||||||
|
*/
|
||||||
|
function parseSlashCommand(value: string): { command: string; args: string[] } | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed.startsWith('/')) return null;
|
||||||
|
const parts = trimmed.slice(1).split(/\s+/);
|
||||||
|
const command = parts[0]?.toLowerCase() ?? '';
|
||||||
|
const args = parts.slice(1);
|
||||||
|
return { command, args };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TuiApp({
|
export function TuiApp({
|
||||||
gatewayUrl,
|
gatewayUrl,
|
||||||
conversationId: initialConversationId,
|
conversationId: initialConversationId,
|
||||||
sessionCookie,
|
sessionCookie,
|
||||||
|
initialModel,
|
||||||
|
initialProvider,
|
||||||
}: TuiAppProps) {
|
}: TuiAppProps) {
|
||||||
const { exit } = useApp();
|
const { exit } = useApp();
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
@@ -27,8 +45,33 @@ export function TuiApp({
|
|||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const [conversationId, setConversationId] = useState(initialConversationId);
|
const [conversationId, setConversationId] = useState(initialConversationId);
|
||||||
const [currentStreamText, setCurrentStreamText] = useState('');
|
const [currentStreamText, setCurrentStreamText] = useState('');
|
||||||
|
|
||||||
|
// Model/provider state
|
||||||
|
const [currentModel, setCurrentModel] = useState<string | undefined>(initialModel);
|
||||||
|
const [currentProvider, setCurrentProvider] = useState<string | undefined>(initialProvider);
|
||||||
|
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||||
|
|
||||||
const socketRef = useRef<Socket | null>(null);
|
const socketRef = useRef<Socket | null>(null);
|
||||||
|
|
||||||
|
// Fetch available models on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAvailableModels(gatewayUrl, sessionCookie)
|
||||||
|
.then((models) => {
|
||||||
|
setAvailableModels(models);
|
||||||
|
// If no model/provider specified and models are available, show the default
|
||||||
|
if (!initialModel && !initialProvider && models.length > 0) {
|
||||||
|
const first = models[0];
|
||||||
|
if (first) {
|
||||||
|
setCurrentModel(first.id);
|
||||||
|
setCurrentProvider(first.provider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Non-fatal: TUI works without model list
|
||||||
|
});
|
||||||
|
}, [gatewayUrl, sessionCookie, initialModel, initialProvider]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = io(`${gatewayUrl}/chat`, {
|
const socket = io(`${gatewayUrl}/chat`, {
|
||||||
transports: ['websocket'],
|
transports: ['websocket'],
|
||||||
@@ -86,9 +129,164 @@ export function TuiApp({
|
|||||||
};
|
};
|
||||||
}, [gatewayUrl]);
|
}, [gatewayUrl]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle /model and /provider slash commands.
|
||||||
|
* Returns true if the input was a handled slash command (should not be sent to gateway).
|
||||||
|
*/
|
||||||
|
const handleSlashCommand = useCallback(
|
||||||
|
(value: string): boolean => {
|
||||||
|
const parsed = parseSlashCommand(value);
|
||||||
|
if (!parsed) return false;
|
||||||
|
|
||||||
|
const { command, args } = parsed;
|
||||||
|
|
||||||
|
if (command === 'model') {
|
||||||
|
if (args.length === 0) {
|
||||||
|
// List available models
|
||||||
|
if (availableModels.length === 0) {
|
||||||
|
setMessages((msgs) => [
|
||||||
|
...msgs,
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content:
|
||||||
|
'No models available (could not reach gateway). Use /model <modelId> to set one manually.',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
const lines = availableModels.map(
|
||||||
|
(m) =>
|
||||||
|
` ${m.provider}/${m.id}${m.id === currentModel && m.provider === currentProvider ? ' (active)' : ''}`,
|
||||||
|
);
|
||||||
|
setMessages((msgs) => [
|
||||||
|
...msgs,
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: `Available models:\n${lines.join('\n')}`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Switch model: /model <modelId> or /model <provider>/<modelId>
|
||||||
|
const arg = args[0]!;
|
||||||
|
const slashIdx = arg.indexOf('/');
|
||||||
|
let newProvider: string | undefined;
|
||||||
|
let newModelId: string;
|
||||||
|
|
||||||
|
if (slashIdx !== -1) {
|
||||||
|
newProvider = arg.slice(0, slashIdx);
|
||||||
|
newModelId = arg.slice(slashIdx + 1);
|
||||||
|
} else {
|
||||||
|
newModelId = arg;
|
||||||
|
// Try to find provider from available models list
|
||||||
|
const match = availableModels.find((m) => m.id === newModelId);
|
||||||
|
newProvider = match?.provider ?? currentProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentModel(newModelId);
|
||||||
|
if (newProvider) setCurrentProvider(newProvider);
|
||||||
|
|
||||||
|
setMessages((msgs) => [
|
||||||
|
...msgs,
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: `Switched to model: ${newProvider ? `${newProvider}/` : ''}${newModelId}. Takes effect on next message.`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === 'provider') {
|
||||||
|
if (args.length === 0) {
|
||||||
|
// List providers from available models
|
||||||
|
const providers = [...new Set(availableModels.map((m) => m.provider))];
|
||||||
|
if (providers.length === 0) {
|
||||||
|
setMessages((msgs) => [
|
||||||
|
...msgs,
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content:
|
||||||
|
'No providers available (could not reach gateway). Use /provider <name> to set one manually.',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
const lines = providers.map((p) => ` ${p}${p === currentProvider ? ' (active)' : ''}`);
|
||||||
|
setMessages((msgs) => [
|
||||||
|
...msgs,
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: `Available providers:\n${lines.join('\n')}`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const newProvider = args[0]!;
|
||||||
|
setCurrentProvider(newProvider);
|
||||||
|
// If switching provider, auto-select first model for that provider
|
||||||
|
const providerModels = availableModels.filter((m) => m.provider === newProvider);
|
||||||
|
if (providerModels.length > 0 && providerModels[0]) {
|
||||||
|
setCurrentModel(providerModels[0].id);
|
||||||
|
setMessages((msgs) => [
|
||||||
|
...msgs,
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: `Switched to provider: ${newProvider} (model: ${providerModels[0]!.id}). Takes effect on next message.`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
setMessages((msgs) => [
|
||||||
|
...msgs,
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: `Switched to provider: ${newProvider}. Takes effect on next message.`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === 'help') {
|
||||||
|
setMessages((msgs) => [
|
||||||
|
...msgs,
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: [
|
||||||
|
'Available commands:',
|
||||||
|
' /model — list available models',
|
||||||
|
' /model <id> — switch model (e.g. /model gpt-4o)',
|
||||||
|
' /model <p>/<id> — switch model with provider (e.g. /model ollama/llama3.2)',
|
||||||
|
' /provider — list available providers',
|
||||||
|
' /provider <name> — switch provider (e.g. /provider ollama)',
|
||||||
|
' /help — show this help',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown slash command — let the user know
|
||||||
|
setMessages((msgs) => [
|
||||||
|
...msgs,
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: `Unknown command: /${command}. Type /help for available commands.`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[availableModels, currentModel, currentProvider],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
if (!value.trim() || isStreaming) return;
|
if (!value.trim() || isStreaming) return;
|
||||||
|
|
||||||
|
setInput('');
|
||||||
|
|
||||||
|
// Handle slash commands first
|
||||||
|
if (handleSlashCommand(value)) return;
|
||||||
|
|
||||||
if (!socketRef.current?.connected) {
|
if (!socketRef.current?.connected) {
|
||||||
setMessages((msgs) => [
|
setMessages((msgs) => [
|
||||||
...msgs,
|
...msgs,
|
||||||
@@ -98,14 +296,15 @@ export function TuiApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setMessages((msgs) => [...msgs, { role: 'user', content: value }]);
|
setMessages((msgs) => [...msgs, { role: 'user', content: value }]);
|
||||||
setInput('');
|
|
||||||
|
|
||||||
socketRef.current.emit('message', {
|
socketRef.current.emit('message', {
|
||||||
conversationId,
|
conversationId,
|
||||||
content: value,
|
content: value,
|
||||||
|
provider: currentProvider,
|
||||||
|
modelId: currentModel,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[conversationId, isStreaming],
|
[conversationId, isStreaming, currentModel, currentProvider, handleSlashCommand],
|
||||||
);
|
);
|
||||||
|
|
||||||
useInput((ch, key) => {
|
useInput((ch, key) => {
|
||||||
@@ -114,6 +313,12 @@ export function TuiApp({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const modelLabel = currentModel
|
||||||
|
? currentProvider
|
||||||
|
? `${currentProvider}/${currentModel}`
|
||||||
|
: currentModel
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" padding={1}>
|
<Box flexDirection="column" padding={1}>
|
||||||
<Box marginBottom={1}>
|
<Box marginBottom={1}>
|
||||||
@@ -123,15 +328,29 @@ export function TuiApp({
|
|||||||
<Text> </Text>
|
<Text> </Text>
|
||||||
<Text dimColor>{connected ? `connected` : 'connecting...'}</Text>
|
<Text dimColor>{connected ? `connected` : 'connecting...'}</Text>
|
||||||
{conversationId && <Text dimColor> | {conversationId.slice(0, 8)}</Text>}
|
{conversationId && <Text dimColor> | {conversationId.slice(0, 8)}</Text>}
|
||||||
|
{modelLabel && (
|
||||||
|
<>
|
||||||
|
<Text dimColor> | </Text>
|
||||||
|
<Text color="yellow">{modelLabel}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
{messages.map((msg, i) => (
|
{messages.map((msg, i) => (
|
||||||
<Box key={i} marginBottom={1}>
|
<Box key={i} marginBottom={1}>
|
||||||
<Text bold color={msg.role === 'user' ? 'green' : 'cyan'}>
|
{msg.role === 'system' ? (
|
||||||
{msg.role === 'user' ? '> ' : ' '}
|
<Text dimColor italic>
|
||||||
</Text>
|
{msg.content}
|
||||||
<Text wrap="wrap">{msg.content}</Text>
|
</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text bold color={msg.role === 'user' ? 'green' : 'cyan'}>
|
||||||
|
{msg.role === 'user' ? '> ' : ' '}
|
||||||
|
</Text>
|
||||||
|
<Text wrap="wrap">{msg.content}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -162,7 +381,7 @@ export function TuiApp({
|
|||||||
value={input}
|
value={input}
|
||||||
onChange={setInput}
|
onChange={setInput}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
placeholder={isStreaming ? 'waiting...' : 'type a message'}
|
placeholder={isStreaming ? 'waiting...' : 'type a message or /help'}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
62
packages/cli/src/tui/gateway-api.ts
Normal file
62
packages/cli/src/tui/gateway-api.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Minimal gateway REST API client for the TUI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ModelInfo {
|
||||||
|
id: string;
|
||||||
|
provider: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
available: boolean;
|
||||||
|
models: ModelInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the list of available models from the gateway.
|
||||||
|
* Returns an empty array on network or auth errors so the TUI can still function.
|
||||||
|
*/
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the list of providers (with their models) from the gateway.
|
||||||
|
* Returns an empty array on network or auth errors.
|
||||||
|
*/
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user