fix(cli): wire command:result + system:reload socket events in TUI #187

Merged
jason.woltje merged 5 commits from fix/tui-command-result-socket into main 2026-03-16 13:21:12 +00:00
6 changed files with 189 additions and 77 deletions

View File

@@ -144,6 +144,23 @@ program
} }
} }
// Auto-create a conversation if none was specified
let conversationId = opts.conversation;
if (!conversationId) {
try {
const { createConversation } = await import('./tui/gateway-api.js');
const conv = await createConversation(opts.gateway, session.cookie, {
...(projectId ? { projectId } : {}),
});
conversationId = conv.id;
} catch (err) {
console.error(
`Failed to create conversation: ${err instanceof Error ? err.message : String(err)}`,
);
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');
@@ -152,7 +169,7 @@ program
render( render(
React.createElement(TuiApp, { React.createElement(TuiApp, {
gatewayUrl: opts.gateway, gatewayUrl: opts.gateway,
conversationId: opts.conversation, conversationId,
sessionCookie: session.cookie, sessionCookie: session.cookie,
initialModel: opts.model, initialModel: opts.model,
initialProvider: opts.provider, initialProvider: opts.provider,

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback, useEffect, useMemo } from 'react'; import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { Box, useApp, useInput } from 'ink'; import { Box, useApp, useInput } from 'ink';
import type { ParsedCommand } from '@mosaic/types'; import type { ParsedCommand } from '@mosaic/types';
import { TopBar } from './components/top-bar.js'; import { TopBar } from './components/top-bar.js';
@@ -73,6 +73,11 @@ export function TuiApp({
const [sidebarSelectedIndex, setSidebarSelectedIndex] = useState(0); 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);
const handleLocalCommand = useCallback( const handleLocalCommand = useCallback(
(parsed: ParsedCommand) => { (parsed: ParsedCommand) => {
switch (parsed.command) { switch (parsed.command) {
@@ -97,6 +102,20 @@ export function TuiApp({
case 'clear': case 'clear':
socket.clearMessages(); socket.clearMessages();
break; 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 'stop': case 'stop':
// Currently no stop mechanism exposed — show feedback // Currently no stop mechanism exposed — show feedback
socket.addSystemMessage('Stop is not available for the current session.'); socket.addSystemMessage('Stop is not available for the current session.');
@@ -117,12 +136,12 @@ export function TuiApp({
const handleGatewayCommand = useCallback( const handleGatewayCommand = useCallback(
(parsed: ParsedCommand) => { (parsed: ParsedCommand) => {
if (!socket.socketRef.current?.connected || !socket.conversationId) { if (!socket.socketRef.current?.connected) {
socket.addSystemMessage('Not connected to gateway. Command cannot be executed.'); socket.addSystemMessage('Not connected to gateway. Command cannot be executed.');
return; return;
} }
socket.socketRef.current.emit('command:execute', { socket.socketRef.current.emit('command:execute', {
conversationId: socket.conversationId, conversationId: socket.conversationId ?? '',
command: parsed.command, command: parsed.command,
args: parsed.args ?? undefined, args: parsed.args ?? undefined,
}); });
@@ -153,9 +172,21 @@ export function TuiApp({
); );
useInput((ch, key) => { useInput((ch, key) => {
// Ctrl+C: clear input → show hint → second empty press exits
if (key.ctrl && ch === 'c') { if (key.ctrl && ch === 'c') {
exit(); 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) // Ctrl+L: toggle sidebar (refresh on open)
if (key.ctrl && ch === 'l') { if (key.ctrl && ch === 'l') {
const willOpen = !appMode.sidebarOpen; const willOpen = !appMode.sidebarOpen;
@@ -260,12 +291,15 @@ export function TuiApp({
)} )}
<InputBar <InputBar
value={tuiInput}
onChange={setTuiInput}
onSubmit={socket.sendMessage} onSubmit={socket.sendMessage}
onSystemMessage={socket.addSystemMessage} onSystemMessage={socket.addSystemMessage}
onLocalCommand={handleLocalCommand} onLocalCommand={handleLocalCommand}
onGatewayCommand={handleGatewayCommand} onGatewayCommand={handleGatewayCommand}
isStreaming={socket.isStreaming} isStreaming={socket.isStreaming}
connected={socket.connected} connected={socket.connected}
focused={appMode.mode === 'chat'}
placeholder={inputPlaceholder} placeholder={inputPlaceholder}
allCommands={commandRegistry.getAll()} allCommands={commandRegistry.getAll()}
/> />

View File

@@ -47,10 +47,18 @@ const LOCAL_COMMANDS: CommandDef[] = [
available: true, available: true,
scope: 'core', scope: 'core',
}, },
{
name: 'new',
description: 'Start a new conversation',
aliases: ['n'],
args: undefined,
execution: 'local',
available: true,
scope: 'core',
},
]; ];
const ALIASES: Record<string, string> = { const ALIASES: Record<string, string> = {
n: 'new',
m: 'model', m: 'model',
t: 'thinking', t: 'thinking',
a: 'agent', a: 'agent',

View File

@@ -1,33 +1,42 @@
import React, { useState, useCallback } from 'react'; import React, { useCallback } from 'react';
import { Box, Text, useInput } from 'ink'; import { Box, Text, useInput } from 'ink';
import TextInput from 'ink-text-input'; import TextInput from 'ink-text-input';
import type { ParsedCommand, CommandDef } from '@mosaic/types'; import type { ParsedCommand, CommandDef } from '@mosaic/types';
import { parseSlashCommand, commandRegistry } from '../commands/index.js'; import { parseSlashCommand, commandRegistry } from '../commands/index.js';
import { CommandAutocomplete } from './command-autocomplete.js'; import { CommandAutocomplete } from './command-autocomplete.js';
import { useInputHistory } from '../hooks/use-input-history.js'; import { useInputHistory } from '../hooks/use-input-history.js';
import { useState } from 'react';
export interface InputBarProps { export interface InputBarProps {
/** Controlled input value — caller owns the state */
value: string;
onChange: (val: string) => void;
onSubmit: (value: string) => void; onSubmit: (value: string) => void;
onSystemMessage?: (message: string) => void; onSystemMessage?: (message: string) => void;
onLocalCommand?: (parsed: ParsedCommand) => void; onLocalCommand?: (parsed: ParsedCommand) => void;
onGatewayCommand?: (parsed: ParsedCommand) => void; onGatewayCommand?: (parsed: ParsedCommand) => void;
isStreaming: boolean; isStreaming: boolean;
connected: 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; placeholder?: string;
allCommands?: CommandDef[]; allCommands?: CommandDef[];
} }
export function InputBar({ export function InputBar({
value: input,
onChange: setInput,
onSubmit, onSubmit,
onSystemMessage, onSystemMessage,
onLocalCommand, onLocalCommand,
onGatewayCommand, onGatewayCommand,
isStreaming, isStreaming,
connected, connected,
focused = true,
placeholder: placeholderOverride, placeholder: placeholderOverride,
allCommands, allCommands,
}: InputBarProps) { }: InputBarProps) {
const [input, setInput] = useState('');
const [showAutocomplete, setShowAutocomplete] = useState(false); const [showAutocomplete, setShowAutocomplete] = useState(false);
const [autocompleteIndex, setAutocompleteIndex] = useState(0); const [autocompleteIndex, setAutocompleteIndex] = useState(0);
@@ -36,15 +45,18 @@ export function InputBar({
// Determine which commands to show in autocomplete // Determine which commands to show in autocomplete
const availableCommands = allCommands ?? commandRegistry.getAll(); const availableCommands = allCommands ?? commandRegistry.getAll();
const handleChange = useCallback((value: string) => { const handleChange = useCallback(
setInput(value); (value: string) => {
if (value.startsWith('/')) { setInput(value);
setShowAutocomplete(true); if (value.startsWith('/')) {
setAutocompleteIndex(0); setShowAutocomplete(true);
} else { setAutocompleteIndex(0);
setShowAutocomplete(false); } else {
} setShowAutocomplete(false);
}, []); }
},
[setInput],
);
const handleSubmit = useCallback( const handleSubmit = useCallback(
(value: string) => { (value: string) => {
@@ -59,8 +71,7 @@ export function InputBar({
if (trimmed.startsWith('/')) { if (trimmed.startsWith('/')) {
const parsed = parseSlashCommand(trimmed); const parsed = parseSlashCommand(trimmed);
if (!parsed) { if (!parsed) {
onSystemMessage?.(`Unknown command format: ${trimmed}`); // Bare "/" or malformed — ignore silently (autocomplete handles discovery)
setInput('');
return; return;
} }
const def = commandRegistry.find(parsed.command); const def = commandRegistry.find(parsed.command);
@@ -93,6 +104,7 @@ export function InputBar({
isStreaming, isStreaming,
connected, connected,
addToHistory, addToHistory,
setInput,
], ],
); );
@@ -117,75 +129,67 @@ export function InputBar({
return true; return true;
} }
return false; return false;
}, [showAutocomplete, input, availableCommands, autocompleteIndex]); }, [showAutocomplete, input, availableCommands, autocompleteIndex, setInput]);
useInput((ch, key) => { useInput(
// Escape: hide autocomplete (_ch, key) => {
if (key.escape && showAutocomplete) { if (key.escape && showAutocomplete) {
setShowAutocomplete(false); setShowAutocomplete(false);
setAutocompleteIndex(0); setAutocompleteIndex(0);
return; return;
} }
// Tab: fill autocomplete selection // Tab: fill autocomplete selection
if (key.tab) { if (key.tab) {
fillAutocompleteSelection(); fillAutocompleteSelection();
return; return;
} }
// Up arrow // Up arrow
if (key.upArrow) { if (key.upArrow) {
if (showAutocomplete) { if (showAutocomplete) {
// Navigate autocomplete list up setAutocompleteIndex((prev) => Math.max(0, prev - 1));
setAutocompleteIndex((prev) => Math.max(0, prev - 1)); } else {
} else { const prev = navigateUp(input);
// Navigate input history if (prev !== null) {
const prev = navigateUp(input); setInput(prev);
if (prev !== null) { if (prev.startsWith('/')) setShowAutocomplete(true);
setInput(prev);
if (prev.startsWith('/')) {
setShowAutocomplete(true);
} }
} }
return;
} }
return;
}
// Down arrow // Down arrow
if (key.downArrow) { if (key.downArrow) {
if (showAutocomplete) { if (showAutocomplete) {
// Navigate autocomplete list down — compute filtered length const query = input.startsWith('/') ? input.slice(1) : input;
const query = input.startsWith('/') ? input.slice(1) : input; const filteredLen = availableCommands.filter(
const filteredLen = availableCommands.filter( (c) =>
(c) => !query ||
!query || c.name.includes(query.toLowerCase()) ||
c.name.includes(query.toLowerCase()) || c.aliases.some((a) => a.includes(query.toLowerCase())) ||
c.aliases.some((a) => a.includes(query.toLowerCase())) || c.description.toLowerCase().includes(query.toLowerCase()),
c.description.toLowerCase().includes(query.toLowerCase()), ).length;
).length; const maxVisible = Math.min(filteredLen, 8);
const maxVisible = Math.min(filteredLen, 8); setAutocompleteIndex((prev) => Math.min(prev + 1, maxVisible - 1));
setAutocompleteIndex((prev) => Math.min(prev + 1, maxVisible - 1)); } else {
} else { const next = navigateDown();
// Navigate input history downward if (next !== null) {
const next = navigateDown(); setInput(next);
if (next !== null) { setShowAutocomplete(next.startsWith('/'));
setInput(next);
if (next.startsWith('/')) {
setShowAutocomplete(true);
} else {
setShowAutocomplete(false);
} }
} }
return;
} }
return;
}
// Return/Enter on autocomplete: fill selected command // Return/Enter on autocomplete: fill selected command
if (key.return && showAutocomplete) { if (key.return && showAutocomplete) {
fillAutocompleteSelection(); fillAutocompleteSelection();
return; return;
} }
}); },
{ isActive: focused },
);
const placeholder = const placeholder =
placeholderOverride ?? placeholderOverride ??
@@ -213,6 +217,7 @@ export function InputBar({
onChange={handleChange} onChange={handleChange}
onSubmit={handleSubmit} onSubmit={handleSubmit}
placeholder={placeholder} placeholder={placeholder}
focus={focused}
/> />
</Box> </Box>
</Box> </Box>

View File

@@ -110,6 +110,31 @@ async function handleResponse<T>(res: Response, errorPrefix: string): Promise<T>
return (await res.json()) as T; 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 ── // ── Provider / Model endpoints ──
export async function fetchAvailableModels( export async function fetchAvailableModels(

View File

@@ -12,6 +12,8 @@ import type {
SessionInfoPayload, SessionInfoPayload,
ErrorPayload, ErrorPayload,
CommandManifestPayload, CommandManifestPayload,
SlashCommandResultPayload,
SystemReloadPayload,
} from '@mosaic/types'; } from '@mosaic/types';
import { commandRegistry } from '../commands/index.js'; import { commandRegistry } from '../commands/index.js';
@@ -230,6 +232,27 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn {
commandRegistry.updateManifest(data.manifest); 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 () => { return () => {
socket.disconnect(); socket.disconnect();
}; };