@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.
226 lines
6.3 KiB
TypeScript
226 lines
6.3 KiB
TypeScript
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>
|
||
);
|
||
}
|