fix(cli): wire command:result + system:reload socket events in TUI (#187)
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 #187.
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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()}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user