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 { 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>
|
||||||
);
|
);
|
||||||
|
|||||||
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 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 paddingX={1} borderStyle="single" borderColor="gray">
|
<Box flexDirection="column">
|
||||||
<Text bold color="green">
|
{showAutocomplete && (
|
||||||
{'❯ '}
|
<CommandAutocomplete
|
||||||
</Text>
|
commands={availableCommands}
|
||||||
<TextInput
|
selectedIndex={autocompleteIndex}
|
||||||
value={input}
|
inputValue={input}
|
||||||
onChange={setInput}
|
/>
|
||||||
onSubmit={handleSubmit}
|
)}
|
||||||
placeholder={placeholder}
|
<Box paddingX={1} borderStyle="single" borderColor="gray">
|
||||||
/>
|
<Text bold color="green">
|
||||||
|
{'❯ '}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={input}
|
||||||
|
onChange={handleChange}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</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