feat(mosaic): merge @mosaic/cli into @mosaic/mosaic
@mosaic/mosaic is now the single package providing both: - 'mosaic' binary (CLI: yolo, coord, prdy, tui, gateway, etc.) - 'mosaic-wizard' binary (installation wizard) Changes: - Move packages/cli/src/* into packages/mosaic/src/ - Convert dynamic @mosaic/mosaic imports to static relative imports - Add CLI deps (ink, react, socket.io-client, @mosaic/config) to mosaic - Add jsx: react-jsx to mosaic's tsconfig - Exclude packages/cli from workspace (pnpm-workspace.yaml) - Update install.sh to install @mosaic/mosaic instead of @mosaic/cli - Bump version to 0.0.17 This eliminates the circular dependency between @mosaic/cli and @mosaic/mosaic that was blocking the build graph.
This commit is contained in:
225
packages/mosaic/src/tui/components/input-bar.tsx
Normal file
225
packages/mosaic/src/tui/components/input-bar.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
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 [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||
const [autocompleteIndex, setAutocompleteIndex] = useState(0);
|
||||
|
||||
const { addToHistory, navigateUp, navigateDown } = useInputHistory();
|
||||
|
||||
// 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);
|
||||
}
|
||||
},
|
||||
[setInput],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(value: string) => {
|
||||
if (!value.trim() || isStreaming || !connected) return;
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
addToHistory(trimmed);
|
||||
setShowAutocomplete(false);
|
||||
setAutocompleteIndex(0);
|
||||
|
||||
if (trimmed.startsWith('/')) {
|
||||
const parsed = parseSlashCommand(trimmed);
|
||||
if (!parsed) {
|
||||
// Bare "/" or malformed — ignore silently (autocomplete handles discovery)
|
||||
return;
|
||||
}
|
||||
const def = commandRegistry.find(parsed.command);
|
||||
if (!def) {
|
||||
onSystemMessage?.(
|
||||
`Unknown command: /${parsed.command}. Type /help for available commands.`,
|
||||
);
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
if (def.execution === 'local') {
|
||||
onLocalCommand?.(parsed);
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
// Gateway-executed commands
|
||||
onGatewayCommand?.(parsed);
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(value);
|
||||
setInput('');
|
||||
},
|
||||
[
|
||||
onSubmit,
|
||||
onSystemMessage,
|
||||
onLocalCommand,
|
||||
onGatewayCommand,
|
||||
isStreaming,
|
||||
connected,
|
||||
addToHistory,
|
||||
setInput,
|
||||
],
|
||||
);
|
||||
|
||||
// Handle Tab: fill in selected autocomplete command
|
||||
const fillAutocompleteSelection = useCallback(() => {
|
||||
if (!showAutocomplete) return false;
|
||||
const query = input.startsWith('/') ? input.slice(1) : input;
|
||||
const filtered = availableCommands.filter(
|
||||
(c) =>
|
||||
!query ||
|
||||
c.name.includes(query.toLowerCase()) ||
|
||||
c.aliases.some((a) => a.includes(query.toLowerCase())) ||
|
||||
c.description.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
if (filtered.length === 0) return false;
|
||||
const idx = Math.min(autocompleteIndex, filtered.length - 1);
|
||||
const selected = filtered[idx];
|
||||
if (selected) {
|
||||
setInput(`/${selected.name} `);
|
||||
setShowAutocomplete(false);
|
||||
setAutocompleteIndex(0);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [showAutocomplete, input, availableCommands, autocompleteIndex, setInput]);
|
||||
|
||||
useInput(
|
||||
(_ch, key) => {
|
||||
if (key.escape && showAutocomplete) {
|
||||
setShowAutocomplete(false);
|
||||
setAutocompleteIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tab: fill autocomplete selection
|
||||
if (key.tab) {
|
||||
fillAutocompleteSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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/Enter on autocomplete: fill selected command
|
||||
if (key.return && showAutocomplete) {
|
||||
fillAutocompleteSelection();
|
||||
return;
|
||||
}
|
||||
},
|
||||
{ isActive: focused },
|
||||
);
|
||||
|
||||
const placeholder =
|
||||
placeholderOverride ??
|
||||
(!connected
|
||||
? 'disconnected — waiting for gateway…'
|
||||
: isStreaming
|
||||
? 'waiting for response…'
|
||||
: 'message mosaic…');
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{showAutocomplete && (
|
||||
<CommandAutocomplete
|
||||
commands={availableCommands}
|
||||
selectedIndex={autocompleteIndex}
|
||||
inputValue={input}
|
||||
/>
|
||||
)}
|
||||
<Box paddingX={1} borderStyle="single" borderColor="gray">
|
||||
<Text bold color="green">
|
||||
{'❯ '}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={input}
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder={placeholder}
|
||||
focus={focused}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user