fix(cli): wire command:result + system:reload socket events in TUI (#187)
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 #187.
This commit is contained in:
2026-03-16 13:21:11 +00:00
committed by jason.woltje
parent b6effdcd6b
commit f0476cae92
6 changed files with 189 additions and 77 deletions

View File

@@ -1,33 +1,42 @@
import React, { useState, useCallback } from 'react';
import React, { useCallback } from 'react';
import { Box, Text, useInput } from 'ink';
import TextInput from 'ink-text-input';
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';
import { useState } from 'react';
export interface InputBarProps {
/** Controlled input value — caller owns the state */
value: string;
onChange: (val: string) => void;
onSubmit: (value: string) => void;
onSystemMessage?: (message: string) => void;
onLocalCommand?: (parsed: ParsedCommand) => void;
onGatewayCommand?: (parsed: ParsedCommand) => void;
isStreaming: boolean;
connected: boolean;
/** Whether this input bar is focused/active (default true). When false,
* keyboard input is not captured — e.g. when the sidebar has focus. */
focused?: boolean;
placeholder?: string;
allCommands?: CommandDef[];
}
export function InputBar({
value: input,
onChange: setInput,
onSubmit,
onSystemMessage,
onLocalCommand,
onGatewayCommand,
isStreaming,
connected,
focused = true,
placeholder: placeholderOverride,
allCommands,
}: InputBarProps) {
const [input, setInput] = useState('');
const [showAutocomplete, setShowAutocomplete] = useState(false);
const [autocompleteIndex, setAutocompleteIndex] = useState(0);
@@ -36,15 +45,18 @@ export function InputBar({
// 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 handleChange = useCallback(
(value: string) => {
setInput(value);
if (value.startsWith('/')) {
setShowAutocomplete(true);
setAutocompleteIndex(0);
} else {
setShowAutocomplete(false);
}
},
[setInput],
);
const handleSubmit = useCallback(
(value: string) => {
@@ -59,8 +71,7 @@ export function InputBar({
if (trimmed.startsWith('/')) {
const parsed = parseSlashCommand(trimmed);
if (!parsed) {
onSystemMessage?.(`Unknown command format: ${trimmed}`);
setInput('');
// Bare "/" or malformed — ignore silently (autocomplete handles discovery)
return;
}
const def = commandRegistry.find(parsed.command);
@@ -93,6 +104,7 @@ export function InputBar({
isStreaming,
connected,
addToHistory,
setInput,
],
);
@@ -117,75 +129,67 @@ export function InputBar({
return true;
}
return false;
}, [showAutocomplete, input, availableCommands, autocompleteIndex]);
}, [showAutocomplete, input, availableCommands, autocompleteIndex, setInput]);
useInput((ch, key) => {
// Escape: hide autocomplete
if (key.escape && showAutocomplete) {
setShowAutocomplete(false);
setAutocompleteIndex(0);
return;
}
useInput(
(_ch, key) => {
if (key.escape && showAutocomplete) {
setShowAutocomplete(false);
setAutocompleteIndex(0);
return;
}
// Tab: fill autocomplete selection
if (key.tab) {
fillAutocompleteSelection();
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);
// Up arrow
if (key.upArrow) {
if (showAutocomplete) {
setAutocompleteIndex((prev) => Math.max(0, prev - 1));
} else {
const prev = navigateUp(input);
if (prev !== null) {
setInput(prev);
if (prev.startsWith('/')) setShowAutocomplete(true);
}
}
return;
}
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);
// Down arrow
if (key.downArrow) {
if (showAutocomplete) {
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 {
const next = navigateDown();
if (next !== null) {
setInput(next);
setShowAutocomplete(next.startsWith('/'));
}
}
return;
}
return;
}
// Return/Enter on autocomplete: fill selected command
if (key.return && showAutocomplete) {
fillAutocompleteSelection();
return;
}
});
// Return/Enter on autocomplete: fill selected command
if (key.return && showAutocomplete) {
fillAutocompleteSelection();
return;
}
},
{ isActive: focused },
);
const placeholder =
placeholderOverride ??
@@ -213,6 +217,7 @@ export function InputBar({
onChange={handleChange}
onSubmit={handleSubmit}
placeholder={placeholder}
focus={focused}
/>
</Box>
</Box>