feat(cli): add --model/--provider flags and /model /provider TUI commands (#144)
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:
2026-03-15 18:41:36 +00:00
committed by jason.woltje
parent 3bb401641e
commit 6a4c020179
3 changed files with 336 additions and 48 deletions

View File

@@ -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 ───────────────────────────────────────────────────────────────

View File

@@ -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>

View 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 [];
}
}