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} />