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();
};