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:
@@ -13,7 +13,7 @@ import { useViewport } from './hooks/use-viewport.js';
|
||||
import { useAppMode } from './hooks/use-app-mode.js';
|
||||
import { useConversations } from './hooks/use-conversations.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 {
|
||||
gatewayUrl: string;
|
||||
@@ -267,6 +267,7 @@ export function TuiApp({
|
||||
isStreaming={socket.isStreaming}
|
||||
connected={socket.connected}
|
||||
placeholder={inputPlaceholder}
|
||||
allCommands={commandRegistry.getAll()}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
66
packages/cli/src/tui/components/command-autocomplete.tsx
Normal file
66
packages/cli/src/tui/components/command-autocomplete.tsx
Normal 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(' ');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
126
packages/cli/src/tui/hooks/use-input-history.test.ts
Normal file
126
packages/cli/src/tui/hooks/use-input-history.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
48
packages/cli/src/tui/hooks/use-input-history.ts
Normal file
48
packages/cli/src/tui/hooks/use-input-history.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user