summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/App.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui/App.tsx')
-rw-r--r--packages/cli/src/ui/App.tsx106
1 files changed, 73 insertions, 33 deletions
diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx
index c7ed9a81..c022fb31 100644
--- a/packages/cli/src/ui/App.tsx
+++ b/packages/cli/src/ui/App.tsx
@@ -11,6 +11,7 @@ import {
measureElement,
Static,
Text,
+ useStdin,
useInput,
type Key as InkKeyType,
} from 'ink';
@@ -54,8 +55,10 @@ import { useLogger } from './hooks/useLogger.js';
import { StreamingContext } from './contexts/StreamingContext.js';
import { SessionStatsProvider } from './contexts/SessionContext.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 {
config: Config;
@@ -98,6 +101,8 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
HistoryItem[] | null
>(null);
const ctrlCTimerRef = useRef<NodeJS.Timeout | null>(null);
+ const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false);
+ const ctrlDTimerRef = useRef<NodeJS.Timeout | null>(null);
const errorCount = useMemo(
() => consoleMessages.filter((msg) => msg.type === 'error').length,
@@ -181,54 +186,84 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
setQuittingMessages,
);
+ const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
+ const { stdin, setRawMode } = useStdin();
+ const isValidPath = useCallback((filePath: string): boolean => {
+ try {
+ return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
+ } catch (_e) {
+ return false;
+ }
+ }, []);
+
+ const widthFraction = 0.9;
+ 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(
+ (cmd) => cmd.name === 'quit' || cmd.altName === 'exit',
+ );
+ if (quitCommand) {
+ quitCommand.action('quit', '', '');
+ } else {
+ process.exit(0);
+ }
+ } else {
+ setPressedOnce(true);
+ timerRef.current = setTimeout(() => {
+ setPressedOnce(false);
+ timerRef.current = null;
+ }, CTRL_EXIT_PROMPT_DURATION_MS);
+ }
+ },
+ [slashCommands],
+ );
+
useInput((input: string, key: InkKeyType) => {
if (key.ctrl && input === 'o') {
setShowErrorDetails((prev) => !prev);
refreshStatic();
} else if (key.ctrl && input === 't') {
- // Toggle showing tool descriptions
const newValue = !showToolDescriptions;
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) {
- clearTimeout(ctrlCTimerRef.current);
- }
- const quitCommand = slashCommands.find(
- (cmd) => cmd.name === 'quit' || cmd.altName === 'exit',
- );
- if (quitCommand) {
- quitCommand.action('quit', '', '');
- } else {
- process.exit(0);
- }
- } else {
- setCtrlCPressedOnce(true);
- ctrlCTimerRef.current = setTimeout(() => {
- setCtrlCPressedOnce(false);
- ctrlCTimerRef.current = null;
- }, CTRL_C_PROMPT_DURATION_MS);
+ 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);
}
});
- useEffect(
- () => () => {
- if (ctrlCTimerRef.current) {
- clearTimeout(ctrlCTimerRef.current);
- }
- },
- [],
- );
-
useConsolePatcher({
onNewMessage: handleNewMessage,
debugMode: config.getDebugMode(),
@@ -324,7 +359,6 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
refreshStatic();
}, [clearItems, clearConsoleMessagesState, refreshStatic]);
- const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize(); // Get terminalWidth
const mainControlsRef = useRef<DOMElement>(null);
const pendingHistoryItemRef = useRef<DOMElement>(null);
@@ -514,6 +548,10 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
<Text color={Colors.AccentYellow}>
Press Ctrl+C again to exit.
</Text>
+ ) : ctrlDPressedOnce ? (
+ <Text color={Colors.AccentYellow}>
+ Press Ctrl+D again to exit.
+ </Text>
) : (
<ContextSummaryDisplay
geminiMdFileCount={geminiMdFileCount}
@@ -540,7 +578,9 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
{isInputActive && (
<InputPrompt
- widthFraction={0.9}
+ buffer={buffer}
+ inputWidth={inputWidth}
+ suggestionsWidth={suggestionsWidth}
onSubmit={handleFinalSubmit}
userMessages={userMessages}
onClearScreen={handleClearScreen}