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:
parent
0d4e0fe647
commit
e4d978da7c
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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}>> </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()}
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue