feat(cli): TUI autocomplete sidebar + fuzzy match + arg hints + input history (P8-017) (#184)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #184.
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
import type { ParsedCommand } from '@mosaic/types';
|
||||
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';
|
||||
|
||||
export interface InputBarProps {
|
||||
onSubmit: (value: string) => void;
|
||||
@@ -12,6 +14,7 @@ export interface InputBarProps {
|
||||
isStreaming: boolean;
|
||||
connected: boolean;
|
||||
placeholder?: string;
|
||||
allCommands?: CommandDef[];
|
||||
}
|
||||
|
||||
export function InputBar({
|
||||
@@ -22,8 +25,26 @@ export function InputBar({
|
||||
isStreaming,
|
||||
connected,
|
||||
placeholder: placeholderOverride,
|
||||
allCommands,
|
||||
}: InputBarProps) {
|
||||
const [input, setInput] = useState('');
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(value: string) => {
|
||||
@@ -31,6 +52,10 @@ export function InputBar({
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
addToHistory(trimmed);
|
||||
setShowAutocomplete(false);
|
||||
setAutocompleteIndex(0);
|
||||
|
||||
if (trimmed.startsWith('/')) {
|
||||
const parsed = parseSlashCommand(trimmed);
|
||||
if (!parsed) {
|
||||
@@ -60,9 +85,108 @@ export function InputBar({
|
||||
onSubmit(value);
|
||||
setInput('');
|
||||
},
|
||||
[onSubmit, onSystemMessage, onLocalCommand, onGatewayCommand, isStreaming, connected],
|
||||
[
|
||||
onSubmit,
|
||||
onSystemMessage,
|
||||
onLocalCommand,
|
||||
onGatewayCommand,
|
||||
isStreaming,
|
||||
connected,
|
||||
addToHistory,
|
||||
],
|
||||
);
|
||||
|
||||
// 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]);
|
||||
|
||||
useInput((ch, key) => {
|
||||
// Escape: hide autocomplete
|
||||
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) {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Return/Enter on autocomplete: fill selected command
|
||||
if (key.return && showAutocomplete) {
|
||||
fillAutocompleteSelection();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const placeholder =
|
||||
placeholderOverride ??
|
||||
(!connected
|
||||
@@ -72,16 +196,25 @@ export function InputBar({
|
||||
: 'message mosaic…');
|
||||
|
||||
return (
|
||||
<Box paddingX={1} borderStyle="single" borderColor="gray">
|
||||
<Text bold color="green">
|
||||
{'❯ '}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<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}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user