From a989b5e5497a8da6cac113554d4f0badc01a71b2 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 16 Mar 2026 03:30:15 +0000 Subject: [PATCH] feat(cli): TUI autocomplete sidebar + fuzzy match + arg hints + input history (P8-017) (#184) Co-authored-by: Jason Woltje Co-committed-by: Jason Woltje --- packages/cli/src/tui/app.tsx | 3 +- .../tui/components/command-autocomplete.tsx | 66 ++++++++ packages/cli/src/tui/components/input-bar.tsx | 159 ++++++++++++++++-- .../src/tui/hooks/use-input-history.test.ts | 126 ++++++++++++++ .../cli/src/tui/hooks/use-input-history.ts | 48 ++++++ 5 files changed, 388 insertions(+), 14 deletions(-) create mode 100644 packages/cli/src/tui/components/command-autocomplete.tsx create mode 100644 packages/cli/src/tui/hooks/use-input-history.test.ts create mode 100644 packages/cli/src/tui/hooks/use-input-history.ts diff --git a/packages/cli/src/tui/app.tsx b/packages/cli/src/tui/app.tsx index 09a60a8..66e3d73 100644 --- a/packages/cli/src/tui/app.tsx +++ b/packages/cli/src/tui/app.tsx @@ -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()} /> ); diff --git a/packages/cli/src/tui/components/command-autocomplete.tsx b/packages/cli/src/tui/components/command-autocomplete.tsx new file mode 100644 index 0000000..290d18d --- /dev/null +++ b/packages/cli/src/tui/components/command-autocomplete.tsx @@ -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 ( + + {filtered.slice(0, 8).map((cmd, i) => ( + + + {i === clampedIndex ? '▶ ' : ' '}/{cmd.name} + + {cmd.aliases.length > 0 && ( + ({cmd.aliases.map((a) => `/${a}`).join(', ')}) + )} + — {cmd.description} + + ))} + {selected && selected.args && selected.args.length > 0 && ( + + + /{selected.name} {getArgHint(selected.args)} + + — {selected.description} + + )} + + ); +} + +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(' '); +} diff --git a/packages/cli/src/tui/components/input-bar.tsx b/packages/cli/src/tui/components/input-bar.tsx index 6feb9a4..15e72ba 100644 --- a/packages/cli/src/tui/components/input-bar.tsx +++ b/packages/cli/src/tui/components/input-bar.tsx @@ -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 ( - - - {'❯ '} - - + + {showAutocomplete && ( + + )} + + + {'❯ '} + + + ); } diff --git a/packages/cli/src/tui/hooks/use-input-history.test.ts b/packages/cli/src/tui/hooks/use-input-history.test.ts new file mode 100644 index 0000000..c789000 --- /dev/null +++ b/packages/cli/src/tui/hooks/use-input-history.test.ts @@ -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; + + 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'); + }); +}); diff --git a/packages/cli/src/tui/hooks/use-input-history.ts b/packages/cli/src/tui/hooks/use-input-history.ts new file mode 100644 index 0000000..1857ad2 --- /dev/null +++ b/packages/cli/src/tui/hooks/use-input-history.ts @@ -0,0 +1,48 @@ +import { useState, useCallback } from 'react'; + +const MAX_HISTORY = 50; + +export function useInputHistory() { + const [history, setHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(-1); + const [savedInput, setSavedInput] = useState(''); + + 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 }; +}