Files
stack/packages/cli/src/tui/components/input-bar.tsx
Jason Woltje f0476cae92
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
fix(cli): wire command:result + system:reload socket events in TUI (#187)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-16 13:21:11 +00:00

226 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}