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 [footerHeight, setFooterHeight] = useState<number>(0);
const [corgiMode, setCorgiMode] = useState(false);
const [shellModeActive, setShellModeActive] = useState(false);
const toggleCorgiMode = useCallback(() => {
setCorgiMode((prev) => !prev);
@ -152,10 +153,16 @@ export const App = ({
(submittedValue: string) => {
const trimmedValue = submittedValue.trim();
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(
@ -364,6 +371,8 @@ export const App = ({
resetCompletion={completion.resetCompletionState}
setEditorState={setEditorState}
onClearScreen={handleClearScreen} // Added onClearScreen prop
shellModeActive={shellModeActive}
setShellModeActive={setShellModeActive}
/>
{completion.showSuggestions && (
<Box>

View File

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

View File

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

View File

@ -85,12 +85,18 @@ export type HistoryItemToolGroup = HistoryItemBase & {
tools: IndividualToolCallDisplay[];
};
export type HistoryItemUserShell = HistoryItemBase & {
type: 'user_shell';
text: string;
};
// 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
// 'tools' in historyItem.
// Individually exported types extending HistoryItemBase
export type HistoryItemWithoutId =
| HistoryItemUser
| HistoryItemUserShell
| HistoryItemGemini
| HistoryItemGeminiContent
| HistoryItemInfo