fix(cli): Ctrl+C clears input, second Ctrl+C exits TUI
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful

InputBar is now a controlled component — input state lives in app.tsx.
Ctrl+C when input has text clears it; Ctrl+C with empty input exits,
giving the standard two-press-to-quit behaviour expected in TUIs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 08:12:51 -05:00
parent 1214a4cce7
commit 931ae76df1
2 changed files with 33 additions and 13 deletions

View File

@@ -73,6 +73,9 @@ export function TuiApp({
const [sidebarSelectedIndex, setSidebarSelectedIndex] = useState(0); const [sidebarSelectedIndex, setSidebarSelectedIndex] = useState(0);
// Controlled input state — held here so Ctrl+C can clear it
const [tuiInput, setTuiInput] = useState('');
const handleLocalCommand = useCallback( const handleLocalCommand = useCallback(
(parsed: ParsedCommand) => { (parsed: ParsedCommand) => {
switch (parsed.command) { switch (parsed.command) {
@@ -153,8 +156,14 @@ export function TuiApp({
); );
useInput((ch, key) => { useInput((ch, key) => {
// Ctrl+C: clear input if non-empty; exit if already empty (second press)
if (key.ctrl && ch === 'c') { if (key.ctrl && ch === 'c') {
exit(); if (tuiInput) {
setTuiInput('');
} else {
exit();
}
return;
} }
// Ctrl+L: toggle sidebar (refresh on open) // Ctrl+L: toggle sidebar (refresh on open)
if (key.ctrl && ch === 'l') { if (key.ctrl && ch === 'l') {
@@ -260,6 +269,8 @@ export function TuiApp({
)} )}
<InputBar <InputBar
value={tuiInput}
onChange={setTuiInput}
onSubmit={socket.sendMessage} onSubmit={socket.sendMessage}
onSystemMessage={socket.addSystemMessage} onSystemMessage={socket.addSystemMessage}
onLocalCommand={handleLocalCommand} onLocalCommand={handleLocalCommand}

View File

@@ -1,12 +1,16 @@
import React, { useState, useCallback } from 'react'; import React, { useCallback } from 'react';
import { Box, Text, useInput } from 'ink'; import { Box, Text, useInput } from 'ink';
import TextInput from 'ink-text-input'; import TextInput from 'ink-text-input';
import type { ParsedCommand, CommandDef } from '@mosaic/types'; import type { ParsedCommand, CommandDef } from '@mosaic/types';
import { parseSlashCommand, commandRegistry } from '../commands/index.js'; import { parseSlashCommand, commandRegistry } from '../commands/index.js';
import { CommandAutocomplete } from './command-autocomplete.js'; import { CommandAutocomplete } from './command-autocomplete.js';
import { useInputHistory } from '../hooks/use-input-history.js'; import { useInputHistory } from '../hooks/use-input-history.js';
import { useState } from 'react';
export interface InputBarProps { export interface InputBarProps {
/** Controlled input value — caller owns the state */
value: string;
onChange: (val: string) => void;
onSubmit: (value: string) => void; onSubmit: (value: string) => void;
onSystemMessage?: (message: string) => void; onSystemMessage?: (message: string) => void;
onLocalCommand?: (parsed: ParsedCommand) => void; onLocalCommand?: (parsed: ParsedCommand) => void;
@@ -18,6 +22,8 @@ export interface InputBarProps {
} }
export function InputBar({ export function InputBar({
value: input,
onChange: setInput,
onSubmit, onSubmit,
onSystemMessage, onSystemMessage,
onLocalCommand, onLocalCommand,
@@ -27,7 +33,6 @@ export function InputBar({
placeholder: placeholderOverride, placeholder: placeholderOverride,
allCommands, allCommands,
}: InputBarProps) { }: InputBarProps) {
const [input, setInput] = useState('');
const [showAutocomplete, setShowAutocomplete] = useState(false); const [showAutocomplete, setShowAutocomplete] = useState(false);
const [autocompleteIndex, setAutocompleteIndex] = useState(0); const [autocompleteIndex, setAutocompleteIndex] = useState(0);
@@ -36,15 +41,18 @@ export function InputBar({
// Determine which commands to show in autocomplete // Determine which commands to show in autocomplete
const availableCommands = allCommands ?? commandRegistry.getAll(); const availableCommands = allCommands ?? commandRegistry.getAll();
const handleChange = useCallback((value: string) => { const handleChange = useCallback(
setInput(value); (value: string) => {
if (value.startsWith('/')) { setInput(value);
setShowAutocomplete(true); if (value.startsWith('/')) {
setAutocompleteIndex(0); setShowAutocomplete(true);
} else { setAutocompleteIndex(0);
setShowAutocomplete(false); } else {
} setShowAutocomplete(false);
}, []); }
},
[setInput],
);
const handleSubmit = useCallback( const handleSubmit = useCallback(
(value: string) => { (value: string) => {
@@ -92,6 +100,7 @@ export function InputBar({
isStreaming, isStreaming,
connected, connected,
addToHistory, addToHistory,
setInput,
], ],
); );
@@ -116,7 +125,7 @@ export function InputBar({
return true; return true;
} }
return false; return false;
}, [showAutocomplete, input, availableCommands, autocompleteIndex]); }, [showAutocomplete, input, availableCommands, autocompleteIndex, setInput]);
useInput((ch, key) => { useInput((ch, key) => {
// Escape: hide autocomplete // Escape: hide autocomplete