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 };
+}