From f0476cae9252d5993a296efb7281e989f3739384 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 16 Mar 2026 13:21:11 +0000 Subject: [PATCH] fix(cli): wire command:result + system:reload socket events in TUI (#187) Co-authored-by: Jason Woltje Co-committed-by: Jason Woltje --- packages/cli/src/cli.ts | 19 ++- packages/cli/src/tui/app.tsx | 42 ++++- packages/cli/src/tui/commands/registry.ts | 10 +- packages/cli/src/tui/components/input-bar.tsx | 147 +++++++++--------- packages/cli/src/tui/gateway-api.ts | 25 +++ packages/cli/src/tui/hooks/use-socket.ts | 23 +++ 6 files changed, 189 insertions(+), 77 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b2b8954..339b936 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -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, diff --git a/packages/cli/src/tui/app.tsx b/packages/cli/src/tui/app.tsx index 66e3d73..c99b033 100644 --- a/packages/cli/src/tui/app.tsx +++ b/packages/cli/src/tui/app.tsx @@ -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({ )} diff --git a/packages/cli/src/tui/commands/registry.ts b/packages/cli/src/tui/commands/registry.ts index 79fbe7a..bbad4cf 100644 --- a/packages/cli/src/tui/commands/registry.ts +++ b/packages/cli/src/tui/commands/registry.ts @@ -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 = { - n: 'new', m: 'model', t: 'thinking', a: 'agent', diff --git a/packages/cli/src/tui/components/input-bar.tsx b/packages/cli/src/tui/components/input-bar.tsx index 15e72ba..457a400 100644 --- a/packages/cli/src/tui/components/input-bar.tsx +++ b/packages/cli/src/tui/components/input-bar.tsx @@ -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} /> diff --git a/packages/cli/src/tui/gateway-api.ts b/packages/cli/src/tui/gateway-api.ts index 8c63bd7..f41913d 100644 --- a/packages/cli/src/tui/gateway-api.ts +++ b/packages/cli/src/tui/gateway-api.ts @@ -110,6 +110,31 @@ async function handleResponse(res: Response, errorPrefix: string): Promise 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 { + const res = await fetch(`${gatewayUrl}/api/conversations`, { + method: 'POST', + headers: jsonHeaders(sessionCookie, gatewayUrl), + body: JSON.stringify(data), + }); + return handleResponse(res, 'Failed to create conversation'); +} + // ── Provider / Model endpoints ── export async function fetchAvailableModels( diff --git a/packages/cli/src/tui/hooks/use-socket.ts b/packages/cli/src/tui/hooks/use-socket.ts index 1bc5600..08ca792 100644 --- a/packages/cli/src/tui/hooks/use-socket.ts +++ b/packages/cli/src/tui/hooks/use-socket.ts @@ -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(); };