feat(cli): Introduce toggleable shell mode with enhanced UI

- Implements a toggleable shell mode, removing the need to prefix every command with `!`.
- Users can now enter and exit shell mode by typing `!` as the first character in an empty input prompt.
- The input prompt visually indicates active shell mode with a distinct color and `! ` prefix.
- Shell command history items (`user_shell`) are now visually differentiated from regular user messages.
- This provides a cleaner and more streamlined user experience for frequent shell interactions.

Fixes https://b.corp.google.com/issues/418509745
This commit is contained in:
Taylor Mullen 2025-05-18 01:18:32 -07:00 committed by N. Taylor Mullen
parent 0d4e0fe647
commit e4d978da7c
6 changed files with 68 additions and 5 deletions

View File

@ -61,6 +61,7 @@ export const App = ({
const [themeError, setThemeError] = useState<string | null>(null); const [themeError, setThemeError] = useState<string | null>(null);
const [footerHeight, setFooterHeight] = useState<number>(0); const [footerHeight, setFooterHeight] = useState<number>(0);
const [corgiMode, setCorgiMode] = useState(false); const [corgiMode, setCorgiMode] = useState(false);
const [shellModeActive, setShellModeActive] = useState(false);
const toggleCorgiMode = useCallback(() => { const toggleCorgiMode = useCallback(() => {
setCorgiMode((prev) => !prev); setCorgiMode((prev) => !prev);
@ -152,10 +153,16 @@ export const App = ({
(submittedValue: string) => { (submittedValue: string) => {
const trimmedValue = submittedValue.trim(); const trimmedValue = submittedValue.trim();
if (trimmedValue.length > 0) { if (trimmedValue.length > 0) {
submitQuery(submittedValue); if (shellModeActive && !trimmedValue.startsWith('!')) {
// TODO: Don't prefix (hack) and properly submit pass throughs to a dedicated hook:
// https://b.corp.google.com/issues/418509745
submitQuery(`!${trimmedValue}`);
} else {
submitQuery(trimmedValue);
}
} }
}, },
[submitQuery], [submitQuery, shellModeActive],
); );
const userMessages = useMemo( const userMessages = useMemo(
@ -364,6 +371,8 @@ export const App = ({
resetCompletion={completion.resetCompletionState} resetCompletion={completion.resetCompletionState}
setEditorState={setEditorState} setEditorState={setEditorState}
onClearScreen={handleClearScreen} // Added onClearScreen prop onClearScreen={handleClearScreen} // Added onClearScreen prop
shellModeActive={shellModeActive}
setShellModeActive={setShellModeActive}
/> />
{completion.showSuggestions && ( {completion.showSuggestions && (
<Box> <Box>

View File

@ -7,6 +7,7 @@
import React from 'react'; import React from 'react';
import type { HistoryItem } from '../types.js'; import type { HistoryItem } from '../types.js';
import { UserMessage } from './messages/UserMessage.js'; import { UserMessage } from './messages/UserMessage.js';
import { UserShellMessage } from './messages/UserShellMessage.js';
import { GeminiMessage } from './messages/GeminiMessage.js'; import { GeminiMessage } from './messages/GeminiMessage.js';
import { InfoMessage } from './messages/InfoMessage.js'; import { InfoMessage } from './messages/InfoMessage.js';
import { ErrorMessage } from './messages/ErrorMessage.js'; import { ErrorMessage } from './messages/ErrorMessage.js';
@ -28,6 +29,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
<Box flexDirection="column" key={item.id}> <Box flexDirection="column" key={item.id}>
{/* Render standard message types */} {/* Render standard message types */}
{item.type === 'user' && <UserMessage text={item.text} />} {item.type === 'user' && <UserMessage text={item.text} />}
{item.type === 'user_shell' && <UserShellMessage text={item.text} />}
{item.type === 'gemini' && ( {item.type === 'gemini' && (
<GeminiMessage <GeminiMessage
text={item.text} text={item.text}

View File

@ -26,6 +26,8 @@ interface InputPromptProps {
navigateSuggestionDown: () => void; navigateSuggestionDown: () => void;
setEditorState: (updater: (prevState: EditorState) => EditorState) => void; setEditorState: (updater: (prevState: EditorState) => EditorState) => void;
onClearScreen: () => void; onClearScreen: () => void;
shellModeActive: boolean;
setShellModeActive: (value: boolean) => void;
} }
export interface EditorState { export interface EditorState {
@ -48,6 +50,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
resetCompletion, resetCompletion,
setEditorState, setEditorState,
onClearScreen, onClearScreen,
shellModeActive,
setShellModeActive,
}) => { }) => {
const handleSubmit = useCallback( const handleSubmit = useCallback(
(submittedValue: string) => { (submittedValue: string) => {
@ -116,6 +120,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
_currentText?: string, _currentText?: string,
_cursorOffset?: number, _cursorOffset?: number,
) => { ) => {
if (input === '!' && query === '' && !showSuggestions) {
setShellModeActive(!shellModeActive);
onChangeAndMoveCursor(''); // Clear the '!' from input
return true;
}
if (showSuggestions) { if (showSuggestions) {
if (key.upArrow) { if (key.upArrow) {
navigateSuggestionUp(); navigateSuggestionUp();
@ -186,12 +195,21 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
inputHistory, inputHistory,
setEditorState, setEditorState,
onClearScreen, onClearScreen,
shellModeActive,
setShellModeActive,
onChangeAndMoveCursor,
], ],
); );
return ( return (
<Box borderStyle="round" borderColor={Colors.AccentBlue} paddingX={1}> <Box
<Text color={Colors.AccentPurple}>&gt; </Text> borderStyle="round"
borderColor={shellModeActive ? Colors.AccentYellow : Colors.AccentBlue}
paddingX={1}
>
<Text color={shellModeActive ? Colors.AccentYellow : Colors.AccentPurple}>
{shellModeActive ? '! ' : '> '}
</Text>
<Box flexGrow={1}> <Box flexGrow={1}>
<MultilineTextEditor <MultilineTextEditor
key={editorState.key.toString()} key={editorState.key.toString()}

View File

@ -0,0 +1,25 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
interface UserShellMessageProps {
text: string;
}
export const UserShellMessage: React.FC<UserShellMessageProps> = ({ text }) => {
// Remove leading '!' if present, as App.tsx adds it for the processor.
const commandToDisplay = text.startsWith('!') ? text.substring(1) : text;
return (
<Box>
<Text color={Colors.AccentCyan}>$ </Text>
<Text>{commandToDisplay}</Text>
</Box>
);
};

View File

@ -38,7 +38,10 @@ export const useShellCommandProcessor = (
const commandToExecute = rawQuery.trim().slice(1).trimStart(); const commandToExecute = rawQuery.trim().slice(1).trimStart();
const userMessageTimestamp = Date.now(); const userMessageTimestamp = Date.now();
addItemToHistory({ type: 'user', text: rawQuery }, userMessageTimestamp); addItemToHistory(
{ type: 'user_shell', text: rawQuery },
userMessageTimestamp,
);
if (!commandToExecute) { if (!commandToExecute) {
addItemToHistory( addItemToHistory(

View File

@ -85,12 +85,18 @@ export type HistoryItemToolGroup = HistoryItemBase & {
tools: IndividualToolCallDisplay[]; tools: IndividualToolCallDisplay[];
}; };
export type HistoryItemUserShell = HistoryItemBase & {
type: 'user_shell';
text: string;
};
// Using Omit<HistoryItem, 'id'> seems to have some issues with typescript's // Using Omit<HistoryItem, 'id'> seems to have some issues with typescript's
// type inference e.g. historyItem.type === 'tool_group' isn't auto-inferring that // type inference e.g. historyItem.type === 'tool_group' isn't auto-inferring that
// 'tools' in historyItem. // 'tools' in historyItem.
// Individually exported types extending HistoryItemBase // Individually exported types extending HistoryItemBase
export type HistoryItemWithoutId = export type HistoryItemWithoutId =
| HistoryItemUser | HistoryItemUser
| HistoryItemUserShell
| HistoryItemGemini | HistoryItemGemini
| HistoryItemGeminiContent | HistoryItemGeminiContent
| HistoryItemInfo | HistoryItemInfo