fix(cli): sidebar 'd' delete, /new command, Ctrl+C double-press exit
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful

- 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:
2026-03-16 08:17:05 -05:00
parent 931ae76df1
commit 70f894f148
3 changed files with 89 additions and 61 deletions

View File

@@ -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 type { ParsedCommand } from '@mosaic/types';
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
const [tuiInput, setTuiInput] = useState('');
// Ctrl+C double-press: first press with empty input shows hint; second exits
const ctrlCPendingExit = useRef(false);
const handleLocalCommand = useCallback(
(parsed: ParsedCommand) => {
@@ -100,6 +102,20 @@ export function TuiApp({
case 'clear':
socket.clearMessages();
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':
// Currently no stop mechanism exposed — show feedback
socket.addSystemMessage('Stop is not available for the current session.');
@@ -156,15 +172,21 @@ export function TuiApp({
);
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 (tuiInput) {
setTuiInput('');
} else {
ctrlCPendingExit.current = false;
} else if (ctrlCPendingExit.current) {
exit();
} else {
ctrlCPendingExit.current = true;
socket.addSystemMessage('Press Ctrl+C again to exit.');
}
return;
}
// Any other key resets the pending-exit flag
ctrlCPendingExit.current = false;
// Ctrl+L: toggle sidebar (refresh on open)
if (key.ctrl && ch === 'l') {
const willOpen = !appMode.sidebarOpen;
@@ -277,6 +299,7 @@ export function TuiApp({
onGatewayCommand={handleGatewayCommand}
isStreaming={socket.isStreaming}
connected={socket.connected}
focused={appMode.mode === 'chat'}
placeholder={inputPlaceholder}
allCommands={commandRegistry.getAll()}
/>

View File

@@ -47,10 +47,18 @@ const LOCAL_COMMANDS: CommandDef[] = [
available: true,
scope: 'core',
},
{
name: 'new',
description: 'Start a new conversation',
aliases: ['n'],
args: undefined,
execution: 'local',
available: true,
scope: 'core',
},
];
const ALIASES: Record<string, string> = {
n: 'new',
m: 'model',
t: 'thinking',
a: 'agent',

View File

@@ -17,6 +17,9 @@ export interface InputBarProps {
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[];
}
@@ -30,6 +33,7 @@ export function InputBar({
onGatewayCommand,
isStreaming,
connected,
focused = true,
placeholder: placeholderOverride,
allCommands,
}: InputBarProps) {
@@ -127,73 +131,65 @@ export function InputBar({
return false;
}, [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 ??
@@ -221,6 +217,7 @@ export function InputBar({
onChange={handleChange}
onSubmit={handleSubmit}
placeholder={placeholder}
focus={focused}
/>
</Box>
</Box>