fix(cli): sidebar 'd' delete, /new command, Ctrl+C double-press exit
- InputBar is now focus-gated: when mode != 'chat', TextInput focus=false and useInput isActive=false — sidebar 'd' key no longer leaks into input - /new added as a local command (execution:'local') so it creates a new conversation and switches to it, same as Ctrl+N; alias 'n' moved from ALIASES map into the CommandDef - Ctrl+C behaviour: clear input → show hint → second empty press exits (resets if any other key is pressed between the two Ctrl+Cs) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import { Box, useApp, useInput } from 'ink';
|
import { Box, useApp, useInput } from 'ink';
|
||||||
import type { ParsedCommand } from '@mosaic/types';
|
import type { ParsedCommand } from '@mosaic/types';
|
||||||
import { TopBar } from './components/top-bar.js';
|
import { TopBar } from './components/top-bar.js';
|
||||||
@@ -75,6 +75,8 @@ export function TuiApp({
|
|||||||
|
|
||||||
// Controlled input state — held here so Ctrl+C can clear it
|
// Controlled input state — held here so Ctrl+C can clear it
|
||||||
const [tuiInput, setTuiInput] = useState('');
|
const [tuiInput, setTuiInput] = useState('');
|
||||||
|
// Ctrl+C double-press: first press with empty input shows hint; second exits
|
||||||
|
const ctrlCPendingExit = useRef(false);
|
||||||
|
|
||||||
const handleLocalCommand = useCallback(
|
const handleLocalCommand = useCallback(
|
||||||
(parsed: ParsedCommand) => {
|
(parsed: ParsedCommand) => {
|
||||||
@@ -100,6 +102,20 @@ export function TuiApp({
|
|||||||
case 'clear':
|
case 'clear':
|
||||||
socket.clearMessages();
|
socket.clearMessages();
|
||||||
break;
|
break;
|
||||||
|
case 'new':
|
||||||
|
case 'n':
|
||||||
|
void conversations
|
||||||
|
.createConversation()
|
||||||
|
.then((conv) => {
|
||||||
|
if (conv) {
|
||||||
|
socket.switchConversation(conv.id);
|
||||||
|
appMode.setMode('chat');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
socket.addSystemMessage('Failed to create new conversation.');
|
||||||
|
});
|
||||||
|
break;
|
||||||
case 'stop':
|
case 'stop':
|
||||||
// Currently no stop mechanism exposed — show feedback
|
// Currently no stop mechanism exposed — show feedback
|
||||||
socket.addSystemMessage('Stop is not available for the current session.');
|
socket.addSystemMessage('Stop is not available for the current session.');
|
||||||
@@ -156,15 +172,21 @@ export function TuiApp({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useInput((ch, key) => {
|
useInput((ch, key) => {
|
||||||
// Ctrl+C: clear input if non-empty; exit if already empty (second press)
|
// Ctrl+C: clear input → show hint → second empty press exits
|
||||||
if (key.ctrl && ch === 'c') {
|
if (key.ctrl && ch === 'c') {
|
||||||
if (tuiInput) {
|
if (tuiInput) {
|
||||||
setTuiInput('');
|
setTuiInput('');
|
||||||
} else {
|
ctrlCPendingExit.current = false;
|
||||||
|
} else if (ctrlCPendingExit.current) {
|
||||||
exit();
|
exit();
|
||||||
|
} else {
|
||||||
|
ctrlCPendingExit.current = true;
|
||||||
|
socket.addSystemMessage('Press Ctrl+C again to exit.');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Any other key resets the pending-exit flag
|
||||||
|
ctrlCPendingExit.current = false;
|
||||||
// Ctrl+L: toggle sidebar (refresh on open)
|
// Ctrl+L: toggle sidebar (refresh on open)
|
||||||
if (key.ctrl && ch === 'l') {
|
if (key.ctrl && ch === 'l') {
|
||||||
const willOpen = !appMode.sidebarOpen;
|
const willOpen = !appMode.sidebarOpen;
|
||||||
@@ -277,6 +299,7 @@ export function TuiApp({
|
|||||||
onGatewayCommand={handleGatewayCommand}
|
onGatewayCommand={handleGatewayCommand}
|
||||||
isStreaming={socket.isStreaming}
|
isStreaming={socket.isStreaming}
|
||||||
connected={socket.connected}
|
connected={socket.connected}
|
||||||
|
focused={appMode.mode === 'chat'}
|
||||||
placeholder={inputPlaceholder}
|
placeholder={inputPlaceholder}
|
||||||
allCommands={commandRegistry.getAll()}
|
allCommands={commandRegistry.getAll()}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -47,10 +47,18 @@ const LOCAL_COMMANDS: CommandDef[] = [
|
|||||||
available: true,
|
available: true,
|
||||||
scope: 'core',
|
scope: 'core',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'new',
|
||||||
|
description: 'Start a new conversation',
|
||||||
|
aliases: ['n'],
|
||||||
|
args: undefined,
|
||||||
|
execution: 'local',
|
||||||
|
available: true,
|
||||||
|
scope: 'core',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const ALIASES: Record<string, string> = {
|
const ALIASES: Record<string, string> = {
|
||||||
n: 'new',
|
|
||||||
m: 'model',
|
m: 'model',
|
||||||
t: 'thinking',
|
t: 'thinking',
|
||||||
a: 'agent',
|
a: 'agent',
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export interface InputBarProps {
|
|||||||
onGatewayCommand?: (parsed: ParsedCommand) => void;
|
onGatewayCommand?: (parsed: ParsedCommand) => void;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
connected: 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;
|
placeholder?: string;
|
||||||
allCommands?: CommandDef[];
|
allCommands?: CommandDef[];
|
||||||
}
|
}
|
||||||
@@ -30,6 +33,7 @@ export function InputBar({
|
|||||||
onGatewayCommand,
|
onGatewayCommand,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
connected,
|
connected,
|
||||||
|
focused = true,
|
||||||
placeholder: placeholderOverride,
|
placeholder: placeholderOverride,
|
||||||
allCommands,
|
allCommands,
|
||||||
}: InputBarProps) {
|
}: InputBarProps) {
|
||||||
@@ -127,73 +131,65 @@ export function InputBar({
|
|||||||
return false;
|
return false;
|
||||||
}, [showAutocomplete, input, availableCommands, autocompleteIndex, setInput]);
|
}, [showAutocomplete, input, availableCommands, autocompleteIndex, setInput]);
|
||||||
|
|
||||||
useInput((ch, key) => {
|
useInput(
|
||||||
// Escape: hide autocomplete
|
(_ch, key) => {
|
||||||
if (key.escape && showAutocomplete) {
|
if (key.escape && showAutocomplete) {
|
||||||
setShowAutocomplete(false);
|
setShowAutocomplete(false);
|
||||||
setAutocompleteIndex(0);
|
setAutocompleteIndex(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tab: fill autocomplete selection
|
// Tab: fill autocomplete selection
|
||||||
if (key.tab) {
|
if (key.tab) {
|
||||||
fillAutocompleteSelection();
|
fillAutocompleteSelection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Up arrow
|
// Up arrow
|
||||||
if (key.upArrow) {
|
if (key.upArrow) {
|
||||||
if (showAutocomplete) {
|
if (showAutocomplete) {
|
||||||
// Navigate autocomplete list up
|
setAutocompleteIndex((prev) => Math.max(0, prev - 1));
|
||||||
setAutocompleteIndex((prev) => Math.max(0, prev - 1));
|
} else {
|
||||||
} else {
|
const prev = navigateUp(input);
|
||||||
// Navigate input history
|
if (prev !== null) {
|
||||||
const prev = navigateUp(input);
|
setInput(prev);
|
||||||
if (prev !== null) {
|
if (prev.startsWith('/')) setShowAutocomplete(true);
|
||||||
setInput(prev);
|
|
||||||
if (prev.startsWith('/')) {
|
|
||||||
setShowAutocomplete(true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Down arrow
|
// Down arrow
|
||||||
if (key.downArrow) {
|
if (key.downArrow) {
|
||||||
if (showAutocomplete) {
|
if (showAutocomplete) {
|
||||||
// Navigate autocomplete list down — compute filtered length
|
const query = input.startsWith('/') ? input.slice(1) : input;
|
||||||
const query = input.startsWith('/') ? input.slice(1) : input;
|
const filteredLen = availableCommands.filter(
|
||||||
const filteredLen = availableCommands.filter(
|
(c) =>
|
||||||
(c) =>
|
!query ||
|
||||||
!query ||
|
c.name.includes(query.toLowerCase()) ||
|
||||||
c.name.includes(query.toLowerCase()) ||
|
c.aliases.some((a) => a.includes(query.toLowerCase())) ||
|
||||||
c.aliases.some((a) => a.includes(query.toLowerCase())) ||
|
c.description.toLowerCase().includes(query.toLowerCase()),
|
||||||
c.description.toLowerCase().includes(query.toLowerCase()),
|
).length;
|
||||||
).length;
|
const maxVisible = Math.min(filteredLen, 8);
|
||||||
const maxVisible = Math.min(filteredLen, 8);
|
setAutocompleteIndex((prev) => Math.min(prev + 1, maxVisible - 1));
|
||||||
setAutocompleteIndex((prev) => Math.min(prev + 1, maxVisible - 1));
|
} else {
|
||||||
} else {
|
const next = navigateDown();
|
||||||
// Navigate input history downward
|
if (next !== null) {
|
||||||
const next = navigateDown();
|
setInput(next);
|
||||||
if (next !== null) {
|
setShowAutocomplete(next.startsWith('/'));
|
||||||
setInput(next);
|
|
||||||
if (next.startsWith('/')) {
|
|
||||||
setShowAutocomplete(true);
|
|
||||||
} else {
|
|
||||||
setShowAutocomplete(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return/Enter on autocomplete: fill selected command
|
// Return/Enter on autocomplete: fill selected command
|
||||||
if (key.return && showAutocomplete) {
|
if (key.return && showAutocomplete) {
|
||||||
fillAutocompleteSelection();
|
fillAutocompleteSelection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
{ isActive: focused },
|
||||||
|
);
|
||||||
|
|
||||||
const placeholder =
|
const placeholder =
|
||||||
placeholderOverride ??
|
placeholderOverride ??
|
||||||
@@ -221,6 +217,7 @@ export function InputBar({
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
focus={focused}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
Reference in New Issue
Block a user