From 31aea56345a6cbf2018115f4ad6fd38c23fc1c23 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 16 Mar 2026 08:02:18 -0500 Subject: [PATCH 1/5] fix(cli): wire command:result + system:reload socket events in TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway slash command responses were silently dropped — use-socket.ts had no handlers for command:result or system:reload. Added both: - command:result → renders success/error as a system message - system:reload → updates command registry manifest + shows reload message Also removes stray @testing-library/react devDependency that was never used in the CLI package. Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/tui/hooks/use-socket.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) 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(); }; -- 2.49.1 From 193d171acbccc89ab5ef770af7b7ac2b27bb6426 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 16 Mar 2026 08:07:00 -0500 Subject: [PATCH 2/5] =?UTF-8?q?fix(cli):=20slash=20command=20UX=20?= =?UTF-8?q?=E2=80=94=20ignore=20bare=20/,=20fix=20gateway=20connection=20c?= =?UTF-8?q?heck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Submitting bare "/" no longer shows "Unknown command format: /" — input is ignored silently so autocomplete can guide discovery instead - Gateway command handler no longer requires a conversationId before emitting — the socket connection check alone is sufficient; missing conversationId caused false "Not connected" errors on fresh sessions Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/tui/app.tsx | 4 ++-- packages/cli/src/tui/components/input-bar.tsx | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/tui/app.tsx b/packages/cli/src/tui/app.tsx index 66e3d73..21c0803 100644 --- a/packages/cli/src/tui/app.tsx +++ b/packages/cli/src/tui/app.tsx @@ -117,12 +117,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, }); diff --git a/packages/cli/src/tui/components/input-bar.tsx b/packages/cli/src/tui/components/input-bar.tsx index 15e72ba..1190f2a 100644 --- a/packages/cli/src/tui/components/input-bar.tsx +++ b/packages/cli/src/tui/components/input-bar.tsx @@ -59,8 +59,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); -- 2.49.1 From 1214a4cce7cb64a1f8567fa801b8edd739470ec3 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 16 Mar 2026 08:09:04 -0500 Subject: [PATCH 3/5] fix(cli): auto-create conversation on TUI start when no -c flag given Without --conversation/-c, the TUI was starting with no conversationId, leaving the session bar showing "No session" and gateway commands unable to dispatch. Now creates a new conversation via REST before rendering so the socket is immediately attached to an active session. Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/cli.ts | 19 ++++++++++++++++++- packages/cli/src/tui/gateway-api.ts | 25 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) 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/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( -- 2.49.1 From 931ae76df170b0a9429cd77b3f58a3aebdb42cc9 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 16 Mar 2026 08:12:51 -0500 Subject: [PATCH 4/5] fix(cli): Ctrl+C clears input, second Ctrl+C exits TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InputBar is now a controlled component — input state lives in app.tsx. Ctrl+C when input has text clears it; Ctrl+C with empty input exits, giving the standard two-press-to-quit behaviour expected in TUIs. Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/tui/app.tsx | 13 +++++++- packages/cli/src/tui/components/input-bar.tsx | 33 ++++++++++++------- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/tui/app.tsx b/packages/cli/src/tui/app.tsx index 21c0803..247b30e 100644 --- a/packages/cli/src/tui/app.tsx +++ b/packages/cli/src/tui/app.tsx @@ -73,6 +73,9 @@ export function TuiApp({ const [sidebarSelectedIndex, setSidebarSelectedIndex] = useState(0); + // Controlled input state — held here so Ctrl+C can clear it + const [tuiInput, setTuiInput] = useState(''); + const handleLocalCommand = useCallback( (parsed: ParsedCommand) => { switch (parsed.command) { @@ -153,8 +156,14 @@ export function TuiApp({ ); useInput((ch, key) => { + // Ctrl+C: clear input if non-empty; exit if already empty (second press) if (key.ctrl && ch === 'c') { - exit(); + if (tuiInput) { + setTuiInput(''); + } else { + exit(); + } + return; } // Ctrl+L: toggle sidebar (refresh on open) if (key.ctrl && ch === 'l') { @@ -260,6 +269,8 @@ export function TuiApp({ )} void; onSubmit: (value: string) => void; onSystemMessage?: (message: string) => void; onLocalCommand?: (parsed: ParsedCommand) => void; @@ -18,6 +22,8 @@ export interface InputBarProps { } export function InputBar({ + value: input, + onChange: setInput, onSubmit, onSystemMessage, onLocalCommand, @@ -27,7 +33,6 @@ export function InputBar({ placeholder: placeholderOverride, allCommands, }: InputBarProps) { - const [input, setInput] = useState(''); const [showAutocomplete, setShowAutocomplete] = useState(false); const [autocompleteIndex, setAutocompleteIndex] = useState(0); @@ -36,15 +41,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) => { @@ -92,6 +100,7 @@ export function InputBar({ isStreaming, connected, addToHistory, + setInput, ], ); @@ -116,7 +125,7 @@ export function InputBar({ return true; } return false; - }, [showAutocomplete, input, availableCommands, autocompleteIndex]); + }, [showAutocomplete, input, availableCommands, autocompleteIndex, setInput]); useInput((ch, key) => { // Escape: hide autocomplete -- 2.49.1 From 70f894f1487169ad6b3a3c19632e53724b3c21a9 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 16 Mar 2026 08:17:05 -0500 Subject: [PATCH 5/5] fix(cli): sidebar 'd' delete, /new command, Ctrl+C double-press exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - InputBar is now focus-gated: when mode != 'chat', TextInput focus=false and useInput isActive=false — sidebar 'd' key no longer leaks into input - /new added as a local command (execution:'local') so it creates a new conversation and switches to it, same as Ctrl+N; alias 'n' moved from ALIASES map into the CommandDef - Ctrl+C behaviour: clear input → show hint → second empty press exits (resets if any other key is pressed between the two Ctrl+Cs) Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/tui/app.tsx | 29 ++++- packages/cli/src/tui/commands/registry.ts | 10 +- packages/cli/src/tui/components/input-bar.tsx | 111 +++++++++--------- 3 files changed, 89 insertions(+), 61 deletions(-) diff --git a/packages/cli/src/tui/app.tsx b/packages/cli/src/tui/app.tsx index 247b30e..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'; @@ -75,6 +75,8 @@ export function TuiApp({ // 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) => { @@ -100,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.'); @@ -156,15 +172,21 @@ export function TuiApp({ ); useInput((ch, key) => { - // Ctrl+C: clear input if non-empty; exit if already empty (second press) + // Ctrl+C: clear input → show hint → second empty press exits if (key.ctrl && ch === 'c') { if (tuiInput) { setTuiInput(''); - } else { + 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; @@ -277,6 +299,7 @@ export function TuiApp({ onGatewayCommand={handleGatewayCommand} isStreaming={socket.isStreaming} connected={socket.connected} + focused={appMode.mode === 'chat'} placeholder={inputPlaceholder} allCommands={commandRegistry.getAll()} /> 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 ed49ad9..457a400 100644 --- a/packages/cli/src/tui/components/input-bar.tsx +++ b/packages/cli/src/tui/components/input-bar.tsx @@ -17,6 +17,9 @@ export interface InputBarProps { 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[]; } @@ -30,6 +33,7 @@ export function InputBar({ onGatewayCommand, isStreaming, connected, + focused = true, placeholder: placeholderOverride, allCommands, }: InputBarProps) { @@ -127,73 +131,65 @@ export function InputBar({ return false; }, [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 ?? @@ -221,6 +217,7 @@ export function InputBar({ onChange={handleChange} onSubmit={handleSubmit} placeholder={placeholder} + focus={focused} /> -- 2.49.1