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
|
||||
const { render } = await import('ink');
|
||||
const React = await import('react');
|
||||
@@ -152,7 +169,7 @@ program
|
||||
render(
|
||||
React.createElement(TuiApp, {
|
||||
gatewayUrl: opts.gateway,
|
||||
conversationId: opts.conversation,
|
||||
conversationId,
|
||||
sessionCookie: session.cookie,
|
||||
initialModel: opts.model,
|
||||
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 type { ParsedCommand } from '@mosaic/types';
|
||||
import { TopBar } from './components/top-bar.js';
|
||||
@@ -73,6 +73,11 @@ export function TuiApp({
|
||||
|
||||
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(
|
||||
(parsed: ParsedCommand) => {
|
||||
switch (parsed.command) {
|
||||
@@ -97,6 +102,20 @@ export function TuiApp({
|
||||
case 'clear':
|
||||
socket.clearMessages();
|
||||
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':
|
||||
// Currently no stop mechanism exposed — show feedback
|
||||
socket.addSystemMessage('Stop is not available for the current session.');
|
||||
@@ -117,12 +136,12 @@ export function TuiApp({
|
||||
|
||||
const handleGatewayCommand = useCallback(
|
||||
(parsed: ParsedCommand) => {
|
||||
if (!socket.socketRef.current?.connected || !socket.conversationId) {
|
||||
if (!socket.socketRef.current?.connected) {
|
||||
socket.addSystemMessage('Not connected to gateway. Command cannot be executed.');
|
||||
return;
|
||||
}
|
||||
socket.socketRef.current.emit('command:execute', {
|
||||
conversationId: socket.conversationId,
|
||||
conversationId: socket.conversationId ?? '',
|
||||
command: parsed.command,
|
||||
args: parsed.args ?? undefined,
|
||||
});
|
||||
@@ -153,9 +172,21 @@ export function TuiApp({
|
||||
);
|
||||
|
||||
useInput((ch, key) => {
|
||||
// Ctrl+C: clear input → show hint → second empty press exits
|
||||
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)
|
||||
if (key.ctrl && ch === 'l') {
|
||||
const willOpen = !appMode.sidebarOpen;
|
||||
@@ -260,12 +291,15 @@ export function TuiApp({
|
||||
)}
|
||||
|
||||
<InputBar
|
||||
value={tuiInput}
|
||||
onChange={setTuiInput}
|
||||
onSubmit={socket.sendMessage}
|
||||
onSystemMessage={socket.addSystemMessage}
|
||||
onLocalCommand={handleLocalCommand}
|
||||
onGatewayCommand={handleGatewayCommand}
|
||||
isStreaming={socket.isStreaming}
|
||||
connected={socket.connected}
|
||||
focused={appMode.mode === 'chat'}
|
||||
placeholder={inputPlaceholder}
|
||||
allCommands={commandRegistry.getAll()}
|
||||
/>
|
||||
|
||||
@@ -47,10 +47,18 @@ const LOCAL_COMMANDS: CommandDef[] = [
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'new',
|
||||
description: 'Start a new conversation',
|
||||
aliases: ['n'],
|
||||
args: undefined,
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
];
|
||||
|
||||
const ALIASES: Record<string, string> = {
|
||||
n: 'new',
|
||||
m: 'model',
|
||||
t: 'thinking',
|
||||
a: 'agent',
|
||||
|
||||
@@ -1,33 +1,42 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
import type { ParsedCommand, CommandDef } from '@mosaic/types';
|
||||
import { parseSlashCommand, commandRegistry } from '../commands/index.js';
|
||||
import { CommandAutocomplete } from './command-autocomplete.js';
|
||||
import { useInputHistory } from '../hooks/use-input-history.js';
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface InputBarProps {
|
||||
/** Controlled input value — caller owns the state */
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
onSubmit: (value: string) => void;
|
||||
onSystemMessage?: (message: string) => void;
|
||||
onLocalCommand?: (parsed: ParsedCommand) => void;
|
||||
onGatewayCommand?: (parsed: ParsedCommand) => void;
|
||||
isStreaming: 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;
|
||||
allCommands?: CommandDef[];
|
||||
}
|
||||
|
||||
export function InputBar({
|
||||
value: input,
|
||||
onChange: setInput,
|
||||
onSubmit,
|
||||
onSystemMessage,
|
||||
onLocalCommand,
|
||||
onGatewayCommand,
|
||||
isStreaming,
|
||||
connected,
|
||||
focused = true,
|
||||
placeholder: placeholderOverride,
|
||||
allCommands,
|
||||
}: InputBarProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||
const [autocompleteIndex, setAutocompleteIndex] = useState(0);
|
||||
|
||||
@@ -36,15 +45,18 @@ export function InputBar({
|
||||
// Determine which commands to show in autocomplete
|
||||
const availableCommands = allCommands ?? commandRegistry.getAll();
|
||||
|
||||
const handleChange = useCallback((value: string) => {
|
||||
setInput(value);
|
||||
if (value.startsWith('/')) {
|
||||
setShowAutocomplete(true);
|
||||
setAutocompleteIndex(0);
|
||||
} else {
|
||||
setShowAutocomplete(false);
|
||||
}
|
||||
}, []);
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
setInput(value);
|
||||
if (value.startsWith('/')) {
|
||||
setShowAutocomplete(true);
|
||||
setAutocompleteIndex(0);
|
||||
} else {
|
||||
setShowAutocomplete(false);
|
||||
}
|
||||
},
|
||||
[setInput],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(value: string) => {
|
||||
@@ -59,8 +71,7 @@ export function InputBar({
|
||||
if (trimmed.startsWith('/')) {
|
||||
const parsed = parseSlashCommand(trimmed);
|
||||
if (!parsed) {
|
||||
onSystemMessage?.(`Unknown command format: ${trimmed}`);
|
||||
setInput('');
|
||||
// Bare "/" or malformed — ignore silently (autocomplete handles discovery)
|
||||
return;
|
||||
}
|
||||
const def = commandRegistry.find(parsed.command);
|
||||
@@ -93,6 +104,7 @@ export function InputBar({
|
||||
isStreaming,
|
||||
connected,
|
||||
addToHistory,
|
||||
setInput,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -117,75 +129,67 @@ export function InputBar({
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [showAutocomplete, input, availableCommands, autocompleteIndex]);
|
||||
}, [showAutocomplete, input, availableCommands, autocompleteIndex, setInput]);
|
||||
|
||||
useInput((ch, key) => {
|
||||
// Escape: hide autocomplete
|
||||
if (key.escape && showAutocomplete) {
|
||||
setShowAutocomplete(false);
|
||||
setAutocompleteIndex(0);
|
||||
return;
|
||||
}
|
||||
useInput(
|
||||
(_ch, key) => {
|
||||
if (key.escape && showAutocomplete) {
|
||||
setShowAutocomplete(false);
|
||||
setAutocompleteIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tab: fill autocomplete selection
|
||||
if (key.tab) {
|
||||
fillAutocompleteSelection();
|
||||
return;
|
||||
}
|
||||
// Tab: fill autocomplete selection
|
||||
if (key.tab) {
|
||||
fillAutocompleteSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
// Up arrow
|
||||
if (key.upArrow) {
|
||||
if (showAutocomplete) {
|
||||
// Navigate autocomplete list up
|
||||
setAutocompleteIndex((prev) => Math.max(0, prev - 1));
|
||||
} else {
|
||||
// Navigate input history
|
||||
const prev = navigateUp(input);
|
||||
if (prev !== null) {
|
||||
setInput(prev);
|
||||
if (prev.startsWith('/')) {
|
||||
setShowAutocomplete(true);
|
||||
// Up arrow
|
||||
if (key.upArrow) {
|
||||
if (showAutocomplete) {
|
||||
setAutocompleteIndex((prev) => Math.max(0, prev - 1));
|
||||
} else {
|
||||
const prev = navigateUp(input);
|
||||
if (prev !== null) {
|
||||
setInput(prev);
|
||||
if (prev.startsWith('/')) setShowAutocomplete(true);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Down arrow
|
||||
if (key.downArrow) {
|
||||
if (showAutocomplete) {
|
||||
// Navigate autocomplete list down — compute filtered length
|
||||
const query = input.startsWith('/') ? input.slice(1) : input;
|
||||
const filteredLen = availableCommands.filter(
|
||||
(c) =>
|
||||
!query ||
|
||||
c.name.includes(query.toLowerCase()) ||
|
||||
c.aliases.some((a) => a.includes(query.toLowerCase())) ||
|
||||
c.description.toLowerCase().includes(query.toLowerCase()),
|
||||
).length;
|
||||
const maxVisible = Math.min(filteredLen, 8);
|
||||
setAutocompleteIndex((prev) => Math.min(prev + 1, maxVisible - 1));
|
||||
} else {
|
||||
// Navigate input history downward
|
||||
const next = navigateDown();
|
||||
if (next !== null) {
|
||||
setInput(next);
|
||||
if (next.startsWith('/')) {
|
||||
setShowAutocomplete(true);
|
||||
} else {
|
||||
setShowAutocomplete(false);
|
||||
// Down arrow
|
||||
if (key.downArrow) {
|
||||
if (showAutocomplete) {
|
||||
const query = input.startsWith('/') ? input.slice(1) : input;
|
||||
const filteredLen = availableCommands.filter(
|
||||
(c) =>
|
||||
!query ||
|
||||
c.name.includes(query.toLowerCase()) ||
|
||||
c.aliases.some((a) => a.includes(query.toLowerCase())) ||
|
||||
c.description.toLowerCase().includes(query.toLowerCase()),
|
||||
).length;
|
||||
const maxVisible = Math.min(filteredLen, 8);
|
||||
setAutocompleteIndex((prev) => Math.min(prev + 1, maxVisible - 1));
|
||||
} else {
|
||||
const next = navigateDown();
|
||||
if (next !== null) {
|
||||
setInput(next);
|
||||
setShowAutocomplete(next.startsWith('/'));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Return/Enter on autocomplete: fill selected command
|
||||
if (key.return && showAutocomplete) {
|
||||
fillAutocompleteSelection();
|
||||
return;
|
||||
}
|
||||
});
|
||||
// Return/Enter on autocomplete: fill selected command
|
||||
if (key.return && showAutocomplete) {
|
||||
fillAutocompleteSelection();
|
||||
return;
|
||||
}
|
||||
},
|
||||
{ isActive: focused },
|
||||
);
|
||||
|
||||
const placeholder =
|
||||
placeholderOverride ??
|
||||
@@ -213,6 +217,7 @@ export function InputBar({
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder={placeholder}
|
||||
focus={focused}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -110,6 +110,31 @@ async function handleResponse<T>(res: Response, errorPrefix: string): Promise<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 ──
|
||||
|
||||
export async function fetchAvailableModels(
|
||||
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
SessionInfoPayload,
|
||||
ErrorPayload,
|
||||
CommandManifestPayload,
|
||||
SlashCommandResultPayload,
|
||||
SystemReloadPayload,
|
||||
} from '@mosaic/types';
|
||||
import { commandRegistry } from '../commands/index.js';
|
||||
|
||||
@@ -230,6 +232,27 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn {
|
||||
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 () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user