mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 08:47:44 +00:00
feat(cli): support ctrl+d to exit (#878)
Similar to ctrl+c, ctrl+d can now be used to exit the program. To avoid accidental exit, ctrl+d must be pressed twice in relatively quick succession (same as ctrl+c). Following common UX pattern, ctrl+d will be ignored when the input prompt is non-empty. This behavior is similar to how most shell (bash/zsh) behaves. To support this, I had to refactor so that text buffer is initialized outside of the InputPrompt component and instead do it on the main App component to allow input controller to have access to check the content of the text buffer.
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
|||||||
measureElement,
|
measureElement,
|
||||||
Static,
|
Static,
|
||||||
Text,
|
Text,
|
||||||
|
useStdin,
|
||||||
useInput,
|
useInput,
|
||||||
type Key as InkKeyType,
|
type Key as InkKeyType,
|
||||||
} from 'ink';
|
} from 'ink';
|
||||||
@@ -54,8 +55,10 @@ import { useLogger } from './hooks/useLogger.js';
|
|||||||
import { StreamingContext } from './contexts/StreamingContext.js';
|
import { StreamingContext } from './contexts/StreamingContext.js';
|
||||||
import { SessionStatsProvider } from './contexts/SessionContext.js';
|
import { SessionStatsProvider } from './contexts/SessionContext.js';
|
||||||
import { useGitBranchName } from './hooks/useGitBranchName.js';
|
import { useGitBranchName } from './hooks/useGitBranchName.js';
|
||||||
|
import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
const CTRL_C_PROMPT_DURATION_MS = 1000;
|
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
|
||||||
|
|
||||||
interface AppProps {
|
interface AppProps {
|
||||||
config: Config;
|
config: Config;
|
||||||
@@ -98,6 +101,8 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
|||||||
HistoryItem[] | null
|
HistoryItem[] | null
|
||||||
>(null);
|
>(null);
|
||||||
const ctrlCTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const ctrlCTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false);
|
||||||
|
const ctrlDTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const errorCount = useMemo(
|
const errorCount = useMemo(
|
||||||
() => consoleMessages.filter((msg) => msg.type === 'error').length,
|
() => consoleMessages.filter((msg) => msg.type === 'error').length,
|
||||||
@@ -181,26 +186,40 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
|||||||
setQuittingMessages,
|
setQuittingMessages,
|
||||||
);
|
);
|
||||||
|
|
||||||
useInput((input: string, key: InkKeyType) => {
|
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
|
||||||
if (key.ctrl && input === 'o') {
|
const { stdin, setRawMode } = useStdin();
|
||||||
setShowErrorDetails((prev) => !prev);
|
const isValidPath = useCallback((filePath: string): boolean => {
|
||||||
refreshStatic();
|
try {
|
||||||
} else if (key.ctrl && input === 't') {
|
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
|
||||||
// Toggle showing tool descriptions
|
} catch (_e) {
|
||||||
const newValue = !showToolDescriptions;
|
return false;
|
||||||
setShowToolDescriptions(newValue);
|
|
||||||
refreshStatic();
|
|
||||||
|
|
||||||
// Re-execute the MCP command to show/hide descriptions
|
|
||||||
const mcpServers = config.getMcpServers();
|
|
||||||
if (Object.keys(mcpServers || {}).length > 0) {
|
|
||||||
// Pass description flag based on the new value
|
|
||||||
handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc');
|
|
||||||
}
|
}
|
||||||
} else if (key.ctrl && (input === 'c' || input === 'C')) {
|
}, []);
|
||||||
if (ctrlCPressedOnce) {
|
|
||||||
if (ctrlCTimerRef.current) {
|
const widthFraction = 0.9;
|
||||||
clearTimeout(ctrlCTimerRef.current);
|
const inputWidth = Math.max(
|
||||||
|
20,
|
||||||
|
Math.round(terminalWidth * widthFraction) - 3,
|
||||||
|
);
|
||||||
|
const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8));
|
||||||
|
|
||||||
|
const buffer = useTextBuffer({
|
||||||
|
initialText: '',
|
||||||
|
viewport: { height: 10, width: inputWidth },
|
||||||
|
stdin,
|
||||||
|
setRawMode,
|
||||||
|
isValidPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleExit = useCallback(
|
||||||
|
(
|
||||||
|
pressedOnce: boolean,
|
||||||
|
setPressedOnce: (value: boolean) => void,
|
||||||
|
timerRef: React.MutableRefObject<NodeJS.Timeout | null>,
|
||||||
|
) => {
|
||||||
|
if (pressedOnce) {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
}
|
}
|
||||||
const quitCommand = slashCommands.find(
|
const quitCommand = slashCommands.find(
|
||||||
(cmd) => cmd.name === 'quit' || cmd.altName === 'exit',
|
(cmd) => cmd.name === 'quit' || cmd.altName === 'exit',
|
||||||
@@ -211,24 +230,40 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setCtrlCPressedOnce(true);
|
setPressedOnce(true);
|
||||||
ctrlCTimerRef.current = setTimeout(() => {
|
timerRef.current = setTimeout(() => {
|
||||||
setCtrlCPressedOnce(false);
|
setPressedOnce(false);
|
||||||
ctrlCTimerRef.current = null;
|
timerRef.current = null;
|
||||||
}, CTRL_C_PROMPT_DURATION_MS);
|
}, CTRL_EXIT_PROMPT_DURATION_MS);
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
if (ctrlCTimerRef.current) {
|
|
||||||
clearTimeout(ctrlCTimerRef.current);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[slashCommands],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useInput((input: string, key: InkKeyType) => {
|
||||||
|
if (key.ctrl && input === 'o') {
|
||||||
|
setShowErrorDetails((prev) => !prev);
|
||||||
|
refreshStatic();
|
||||||
|
} else if (key.ctrl && input === 't') {
|
||||||
|
const newValue = !showToolDescriptions;
|
||||||
|
setShowToolDescriptions(newValue);
|
||||||
|
refreshStatic();
|
||||||
|
|
||||||
|
const mcpServers = config.getMcpServers();
|
||||||
|
if (Object.keys(mcpServers || {}).length > 0) {
|
||||||
|
handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc');
|
||||||
|
}
|
||||||
|
} else if (key.ctrl && (input === 'c' || input === 'C')) {
|
||||||
|
handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef);
|
||||||
|
} else if (key.ctrl && (input === 'd' || input === 'D')) {
|
||||||
|
if (buffer.text.length > 0) {
|
||||||
|
// Do nothing if there is text in the input.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
useConsolePatcher({
|
useConsolePatcher({
|
||||||
onNewMessage: handleNewMessage,
|
onNewMessage: handleNewMessage,
|
||||||
debugMode: config.getDebugMode(),
|
debugMode: config.getDebugMode(),
|
||||||
@@ -324,7 +359,6 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
|||||||
refreshStatic();
|
refreshStatic();
|
||||||
}, [clearItems, clearConsoleMessagesState, refreshStatic]);
|
}, [clearItems, clearConsoleMessagesState, refreshStatic]);
|
||||||
|
|
||||||
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize(); // Get terminalWidth
|
|
||||||
const mainControlsRef = useRef<DOMElement>(null);
|
const mainControlsRef = useRef<DOMElement>(null);
|
||||||
const pendingHistoryItemRef = useRef<DOMElement>(null);
|
const pendingHistoryItemRef = useRef<DOMElement>(null);
|
||||||
|
|
||||||
@@ -514,6 +548,10 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
|||||||
<Text color={Colors.AccentYellow}>
|
<Text color={Colors.AccentYellow}>
|
||||||
Press Ctrl+C again to exit.
|
Press Ctrl+C again to exit.
|
||||||
</Text>
|
</Text>
|
||||||
|
) : ctrlDPressedOnce ? (
|
||||||
|
<Text color={Colors.AccentYellow}>
|
||||||
|
Press Ctrl+D again to exit.
|
||||||
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<ContextSummaryDisplay
|
<ContextSummaryDisplay
|
||||||
geminiMdFileCount={geminiMdFileCount}
|
geminiMdFileCount={geminiMdFileCount}
|
||||||
@@ -540,7 +578,9 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
|||||||
|
|
||||||
{isInputActive && (
|
{isInputActive && (
|
||||||
<InputPrompt
|
<InputPrompt
|
||||||
widthFraction={0.9}
|
buffer={buffer}
|
||||||
|
inputWidth={inputWidth}
|
||||||
|
suggestionsWidth={suggestionsWidth}
|
||||||
onSubmit={handleFinalSubmit}
|
onSubmit={handleFinalSubmit}
|
||||||
userMessages={userMessages}
|
userMessages={userMessages}
|
||||||
onClearScreen={handleClearScreen}
|
onClearScreen={handleClearScreen}
|
||||||
|
|||||||
@@ -4,15 +4,13 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { Text, Box, useInput, useStdin } from 'ink';
|
import { Text, Box, useInput } from 'ink';
|
||||||
import { Colors } from '../colors.js';
|
import { Colors } from '../colors.js';
|
||||||
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
|
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
|
||||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||||
import { useTextBuffer, cpSlice, cpLen } from './shared/text-buffer.js';
|
import { cpSlice, cpLen, TextBuffer } from './shared/text-buffer.js';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
|
||||||
import stringWidth from 'string-width';
|
import stringWidth from 'string-width';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { useCompletion } from '../hooks/useCompletion.js';
|
import { useCompletion } from '../hooks/useCompletion.js';
|
||||||
@@ -21,60 +19,36 @@ import { SlashCommand } from '../hooks/slashCommandProcessor.js';
|
|||||||
import { Config } from '@gemini-cli/core';
|
import { Config } from '@gemini-cli/core';
|
||||||
|
|
||||||
export interface InputPromptProps {
|
export interface InputPromptProps {
|
||||||
|
buffer: TextBuffer;
|
||||||
onSubmit: (value: string) => void;
|
onSubmit: (value: string) => void;
|
||||||
userMessages: readonly string[];
|
userMessages: readonly string[];
|
||||||
onClearScreen: () => void;
|
onClearScreen: () => void;
|
||||||
config: Config; // Added config for useCompletion
|
config: Config; // Added config for useCompletion
|
||||||
slashCommands: SlashCommand[]; // Added slashCommands for useCompletion
|
slashCommands: SlashCommand[]; // Added slashCommands for useCompletion
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
height?: number; // Visible height of the editor area
|
|
||||||
focus?: boolean;
|
focus?: boolean;
|
||||||
widthFraction: number;
|
inputWidth: number;
|
||||||
|
suggestionsWidth: number;
|
||||||
shellModeActive: boolean;
|
shellModeActive: boolean;
|
||||||
setShellModeActive: (value: boolean) => void;
|
setShellModeActive: (value: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InputPrompt: React.FC<InputPromptProps> = ({
|
export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
|
buffer,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
userMessages,
|
userMessages,
|
||||||
onClearScreen,
|
onClearScreen,
|
||||||
config,
|
config,
|
||||||
slashCommands,
|
slashCommands,
|
||||||
placeholder = ' Type your message or @path/to/file',
|
placeholder = ' Type your message or @path/to/file',
|
||||||
height = 10,
|
|
||||||
focus = true,
|
focus = true,
|
||||||
widthFraction,
|
inputWidth,
|
||||||
|
suggestionsWidth,
|
||||||
shellModeActive,
|
shellModeActive,
|
||||||
setShellModeActive,
|
setShellModeActive,
|
||||||
}) => {
|
}) => {
|
||||||
const terminalSize = useTerminalSize();
|
|
||||||
const padding = 3;
|
|
||||||
const effectiveWidth = Math.max(
|
|
||||||
20,
|
|
||||||
Math.round(terminalSize.columns * widthFraction) - padding,
|
|
||||||
);
|
|
||||||
const suggestionsWidth = Math.max(60, Math.floor(terminalSize.columns * 0.8));
|
|
||||||
|
|
||||||
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
||||||
|
|
||||||
const { stdin, setRawMode } = useStdin();
|
|
||||||
|
|
||||||
const isValidPath = useCallback((filePath: string): boolean => {
|
|
||||||
try {
|
|
||||||
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
|
|
||||||
} catch (_e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const buffer = useTextBuffer({
|
|
||||||
initialText: '',
|
|
||||||
viewport: { height, width: effectiveWidth },
|
|
||||||
stdin,
|
|
||||||
setRawMode,
|
|
||||||
isValidPath,
|
|
||||||
});
|
|
||||||
|
|
||||||
const completion = useCompletion(
|
const completion = useCompletion(
|
||||||
buffer.text,
|
buffer.text,
|
||||||
config.getTargetDir(),
|
config.getTargetDir(),
|
||||||
@@ -370,11 +344,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
linesToRender.map((lineText, visualIdxInRenderedSet) => {
|
linesToRender.map((lineText, visualIdxInRenderedSet) => {
|
||||||
const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow;
|
const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow;
|
||||||
let display = cpSlice(lineText, 0, effectiveWidth);
|
let display = cpSlice(lineText, 0, inputWidth);
|
||||||
const currentVisualWidth = stringWidth(display);
|
const currentVisualWidth = stringWidth(display);
|
||||||
if (currentVisualWidth < effectiveWidth) {
|
if (currentVisualWidth < inputWidth) {
|
||||||
display =
|
display = display + ' '.repeat(inputWidth - currentVisualWidth);
|
||||||
display + ' '.repeat(effectiveWidth - currentVisualWidth);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (visualIdxInRenderedSet === cursorVisualRow) {
|
if (visualIdxInRenderedSet === cursorVisualRow) {
|
||||||
@@ -394,7 +367,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
cpSlice(display, relativeVisualColForHighlight + 1);
|
cpSlice(display, relativeVisualColForHighlight + 1);
|
||||||
} else if (
|
} else if (
|
||||||
relativeVisualColForHighlight === cpLen(display) &&
|
relativeVisualColForHighlight === cpLen(display) &&
|
||||||
cpLen(display) === effectiveWidth
|
cpLen(display) === inputWidth
|
||||||
) {
|
) {
|
||||||
display = display + chalk.inverse(' ');
|
display = display + chalk.inverse(' ');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user