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

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:
2026-03-16 03:30:15 +00:00
committed by jason.woltje
parent ff27e944a1
commit a989b5e549
5 changed files with 388 additions and 14 deletions

View File

@@ -13,7 +13,7 @@ import { useViewport } from './hooks/use-viewport.js';
import { useAppMode } from './hooks/use-app-mode.js'; import { useAppMode } from './hooks/use-app-mode.js';
import { useConversations } from './hooks/use-conversations.js'; import { useConversations } from './hooks/use-conversations.js';
import { useSearch } from './hooks/use-search.js'; import { useSearch } from './hooks/use-search.js';
import { executeHelp, executeStatus } from './commands/index.js'; import { executeHelp, executeStatus, commandRegistry } from './commands/index.js';
export interface TuiAppProps { export interface TuiAppProps {
gatewayUrl: string; gatewayUrl: string;
@@ -267,6 +267,7 @@ export function TuiApp({
isStreaming={socket.isStreaming} isStreaming={socket.isStreaming}
connected={socket.connected} connected={socket.connected}
placeholder={inputPlaceholder} placeholder={inputPlaceholder}
allCommands={commandRegistry.getAll()}
/> />
</Box> </Box>
); );

View File

@@ -0,0 +1,66 @@
import React from 'react';
import { Box, Text } from 'ink';
import type { CommandDef, CommandArgDef } from '@mosaic/types';
interface CommandAutocompleteProps {
commands: CommandDef[];
selectedIndex: number;
inputValue: string; // the current input after '/'
}
export function CommandAutocomplete({
commands,
selectedIndex,
inputValue,
}: CommandAutocompleteProps) {
if (commands.length === 0) return null;
// Filter by inputValue prefix/fuzzy match
const query = inputValue.startsWith('/') ? inputValue.slice(1) : inputValue;
const filtered = filterCommands(commands, query);
if (filtered.length === 0) return null;
const clampedIndex = Math.min(selectedIndex, filtered.length - 1);
const selected = filtered[clampedIndex];
return (
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
{filtered.slice(0, 8).map((cmd, i) => (
<Box key={cmd.name}>
<Text color={i === clampedIndex ? 'cyan' : 'white'} bold={i === clampedIndex}>
{i === clampedIndex ? '▶ ' : ' '}/{cmd.name}
</Text>
{cmd.aliases.length > 0 && (
<Text color="gray"> ({cmd.aliases.map((a) => `/${a}`).join(', ')})</Text>
)}
<Text color="gray"> {cmd.description}</Text>
</Box>
))}
{selected && selected.args && selected.args.length > 0 && (
<Box marginTop={1} borderStyle="single" borderColor="gray" paddingX={1}>
<Text color="yellow">
/{selected.name} {getArgHint(selected.args)}
</Text>
<Text color="gray"> {selected.description}</Text>
</Box>
)}
</Box>
);
}
function filterCommands(commands: CommandDef[], query: string): CommandDef[] {
if (!query) return commands;
const q = query.toLowerCase();
return commands.filter(
(c) =>
c.name.includes(q) ||
c.aliases.some((a) => a.includes(q)) ||
c.description.toLowerCase().includes(q),
);
}
function getArgHint(args: CommandArgDef[]): string {
if (!args || args.length === 0) return '';
return args.map((a) => (a.optional ? `[${a.name}]` : `<${a.name}>`)).join(' ');
}

View File

@@ -1,8 +1,10 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { Box, Text } from 'ink'; import { Box, Text, useInput } from 'ink';
import TextInput from 'ink-text-input'; 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 { parseSlashCommand, commandRegistry } from '../commands/index.js';
import { CommandAutocomplete } from './command-autocomplete.js';
import { useInputHistory } from '../hooks/use-input-history.js';
export interface InputBarProps { export interface InputBarProps {
onSubmit: (value: string) => void; onSubmit: (value: string) => void;
@@ -12,6 +14,7 @@ export interface InputBarProps {
isStreaming: boolean; isStreaming: boolean;
connected: boolean; connected: boolean;
placeholder?: string; placeholder?: string;
allCommands?: CommandDef[];
} }
export function InputBar({ export function InputBar({
@@ -22,8 +25,26 @@ export function InputBar({
isStreaming, isStreaming,
connected, connected,
placeholder: placeholderOverride, placeholder: placeholderOverride,
allCommands,
}: InputBarProps) { }: InputBarProps) {
const [input, setInput] = useState(''); 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( const handleSubmit = useCallback(
(value: string) => { (value: string) => {
@@ -31,6 +52,10 @@ export function InputBar({
const trimmed = value.trim(); const trimmed = value.trim();
addToHistory(trimmed);
setShowAutocomplete(false);
setAutocompleteIndex(0);
if (trimmed.startsWith('/')) { if (trimmed.startsWith('/')) {
const parsed = parseSlashCommand(trimmed); const parsed = parseSlashCommand(trimmed);
if (!parsed) { if (!parsed) {
@@ -60,9 +85,108 @@ export function InputBar({
onSubmit(value); onSubmit(value);
setInput(''); 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 = const placeholder =
placeholderOverride ?? placeholderOverride ??
(!connected (!connected
@@ -72,16 +196,25 @@ export function InputBar({
: 'message mosaic…'); : 'message mosaic…');
return ( return (
<Box flexDirection="column">
{showAutocomplete && (
<CommandAutocomplete
commands={availableCommands}
selectedIndex={autocompleteIndex}
inputValue={input}
/>
)}
<Box paddingX={1} borderStyle="single" borderColor="gray"> <Box paddingX={1} borderStyle="single" borderColor="gray">
<Text bold color="green"> <Text bold color="green">
{' '} {' '}
</Text> </Text>
<TextInput <TextInput
value={input} value={input}
onChange={setInput} onChange={handleChange}
onSubmit={handleSubmit} onSubmit={handleSubmit}
placeholder={placeholder} placeholder={placeholder}
/> />
</Box> </Box>
</Box>
); );
} }

View File

@@ -0,0 +1,126 @@
import { describe, it, expect, beforeEach } from 'vitest';
/**
* Tests for input history logic extracted from useInputHistory.
* We test the pure state transitions directly rather than using
* React testing utilities to avoid react-dom version conflicts.
*/
const MAX_HISTORY = 50;
function createHistoryState() {
let history: string[] = [];
let historyIndex = -1;
let savedInput = '';
function addToHistory(input: string): void {
if (!input.trim()) return;
if (history[0] === input) return;
history = [input, ...history].slice(0, MAX_HISTORY);
historyIndex = -1;
}
function navigateUp(currentInput: string): string | null {
if (history.length === 0) return null;
if (historyIndex === -1) {
savedInput = currentInput;
}
const nextIndex = Math.min(historyIndex + 1, history.length - 1);
historyIndex = nextIndex;
return history[nextIndex] ?? null;
}
function navigateDown(): string | null {
if (historyIndex <= 0) {
historyIndex = -1;
return savedInput;
}
const nextIndex = historyIndex - 1;
historyIndex = nextIndex;
return history[nextIndex] ?? null;
}
function resetNavigation(): void {
historyIndex = -1;
}
function getHistoryLength(): number {
return history.length;
}
return { addToHistory, navigateUp, navigateDown, resetNavigation, getHistoryLength };
}
describe('useInputHistory (logic)', () => {
let h: ReturnType<typeof createHistoryState>;
beforeEach(() => {
h = createHistoryState();
});
it('adds to history on submit', () => {
h.addToHistory('hello');
h.addToHistory('world');
// navigateUp should return 'world' first (most recent)
const val = h.navigateUp('');
expect(val).toBe('world');
});
it('does not add empty strings to history', () => {
h.addToHistory('');
h.addToHistory(' ');
const val = h.navigateUp('');
expect(val).toBeNull();
});
it('navigateDown after up returns saved input', () => {
h.addToHistory('first');
const up = h.navigateUp('current');
expect(up).toBe('first');
const down = h.navigateDown();
expect(down).toBe('current');
});
it('does not add duplicate consecutive entries', () => {
h.addToHistory('same');
h.addToHistory('same');
expect(h.getHistoryLength()).toBe(1);
});
it('caps history at MAX_HISTORY entries', () => {
for (let i = 0; i < 55; i++) {
h.addToHistory(`entry-${i}`);
}
expect(h.getHistoryLength()).toBe(50);
// Navigate to the oldest entry
let val: string | null = null;
for (let i = 0; i < 60; i++) {
val = h.navigateUp('');
}
// Oldest entry at index 49 = entry-5 (entries 54 down to 5, 50 total)
expect(val).toBe('entry-5');
});
it('navigateUp returns null when history is empty', () => {
const val = h.navigateUp('something');
expect(val).toBeNull();
});
it('navigateUp cycles through multiple entries', () => {
h.addToHistory('a');
h.addToHistory('b');
h.addToHistory('c');
expect(h.navigateUp('')).toBe('c');
expect(h.navigateUp('c')).toBe('b');
expect(h.navigateUp('b')).toBe('a');
});
it('resetNavigation resets index to -1', () => {
h.addToHistory('test');
h.navigateUp('');
h.resetNavigation();
// After reset, navigateUp from index -1 returns most recent again
const val = h.navigateUp('');
expect(val).toBe('test');
});
});

View File

@@ -0,0 +1,48 @@
import { useState, useCallback } from 'react';
const MAX_HISTORY = 50;
export function useInputHistory() {
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState<number>(-1);
const [savedInput, setSavedInput] = useState<string>('');
const addToHistory = useCallback((input: string) => {
if (!input.trim()) return;
setHistory((prev) => {
// Avoid duplicate consecutive entries
if (prev[0] === input) return prev;
return [input, ...prev].slice(0, MAX_HISTORY);
});
setHistoryIndex(-1);
}, []);
const navigateUp = useCallback(
(currentInput: string): string | null => {
if (history.length === 0) return null;
if (historyIndex === -1) {
setSavedInput(currentInput);
}
const nextIndex = Math.min(historyIndex + 1, history.length - 1);
setHistoryIndex(nextIndex);
return history[nextIndex] ?? null;
},
[history, historyIndex],
);
const navigateDown = useCallback((): string | null => {
if (historyIndex <= 0) {
setHistoryIndex(-1);
return savedInput;
}
const nextIndex = historyIndex - 1;
setHistoryIndex(nextIndex);
return history[nextIndex] ?? null;
}, [history, historyIndex, savedInput]);
const resetNavigation = useCallback(() => {
setHistoryIndex(-1);
}, []);
return { addToHistory, navigateUp, navigateDown, resetNavigation };
}