mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-16 00:00:52 -07:00
feat: Introduce an AI-driven interactive shell mode with new
`read-shell` and `write-to-shell` tools and a configurable mode setting.
This commit is contained in:
@@ -1009,6 +1009,7 @@ export async function loadCliConfig(
|
||||
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
|
||||
shellBackgroundCompletionBehavior: settings.tools?.shell
|
||||
?.backgroundCompletionBehavior as string | undefined,
|
||||
interactiveShellMode: settings.tools?.shell?.interactiveShellMode,
|
||||
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
|
||||
enableShellOutputEfficiency:
|
||||
settings.tools?.shell?.enableShellOutputEfficiency ?? true,
|
||||
|
||||
@@ -1512,6 +1512,26 @@ const SETTINGS_SCHEMA = {
|
||||
{ label: 'Notify', value: 'notify' },
|
||||
],
|
||||
},
|
||||
interactiveShellMode: {
|
||||
type: 'enum',
|
||||
label: 'Interactive Shell Mode',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: undefined as 'human' | 'ai' | 'off' | undefined,
|
||||
description: oneLine`
|
||||
Controls who can interact with backgrounded shell processes.
|
||||
"human": user can Tab-focus and type into shells (default).
|
||||
"ai": model gets write_to_shell/read_shell tools for TUI interaction.
|
||||
"off": no interactive shell.
|
||||
When set, overrides enableInteractiveShell.
|
||||
`,
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'human', label: 'Human (Tab to focus)' },
|
||||
{ value: 'ai', label: 'AI (model-driven tools)' },
|
||||
{ value: 'off', label: 'Off' },
|
||||
],
|
||||
},
|
||||
pager: {
|
||||
type: 'string',
|
||||
label: 'Pager',
|
||||
|
||||
@@ -92,7 +92,23 @@ export function shellReducer(
|
||||
nextTasks.delete(action.pid);
|
||||
}
|
||||
nextTasks.set(action.pid, updatedTask);
|
||||
return { ...state, backgroundTasks: nextTasks };
|
||||
|
||||
// Auto-hide panel when all tasks have exited
|
||||
let nextVisible = state.isBackgroundTaskVisible;
|
||||
if (action.update.status === 'exited') {
|
||||
const hasRunning = Array.from(nextTasks.values()).some(
|
||||
(s) => s.status === 'running',
|
||||
);
|
||||
if (!hasRunning) {
|
||||
nextVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
backgroundTasks: nextTasks,
|
||||
isBackgroundTaskVisible: nextVisible,
|
||||
};
|
||||
}
|
||||
case 'APPEND_TASK_OUTPUT': {
|
||||
const task = state.backgroundTasks.get(action.pid);
|
||||
|
||||
101
packages/cli/src/ui/hooks/useBackgroundShellManager.ts
Normal file
101
packages/cli/src/ui/hooks/useBackgroundShellManager.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { type BackgroundTask } from './shellReducer.js';
|
||||
|
||||
export interface BackgroundShellManagerProps {
|
||||
backgroundTasks: Map<number, BackgroundTask>;
|
||||
backgroundTaskCount: number;
|
||||
isBackgroundTaskVisible: boolean;
|
||||
activePtyId: number | null | undefined;
|
||||
embeddedShellFocused: boolean;
|
||||
setEmbeddedShellFocused: (focused: boolean) => void;
|
||||
terminalHeight: number;
|
||||
}
|
||||
|
||||
export function useBackgroundShellManager({
|
||||
backgroundTasks,
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskVisible,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
}: BackgroundShellManagerProps) {
|
||||
const [isBackgroundShellListOpen, setIsBackgroundShellListOpen] =
|
||||
useState(false);
|
||||
const [activeBackgroundShellPid, setActiveBackgroundShellPid] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const prevShellCountRef = useRef(backgroundTaskCount);
|
||||
|
||||
useEffect(() => {
|
||||
if (backgroundTasks.size === 0) {
|
||||
if (activeBackgroundShellPid !== null) {
|
||||
setActiveBackgroundShellPid(null);
|
||||
}
|
||||
if (isBackgroundShellListOpen) {
|
||||
setIsBackgroundShellListOpen(false);
|
||||
}
|
||||
} else if (
|
||||
activeBackgroundShellPid === null ||
|
||||
!backgroundTasks.has(activeBackgroundShellPid)
|
||||
) {
|
||||
// If active shell is closed or none selected, select the first one
|
||||
setActiveBackgroundShellPid(backgroundTasks.keys().next().value ?? null);
|
||||
} else if (backgroundTaskCount > prevShellCountRef.current) {
|
||||
// A new shell was added — auto-switch to the newest one (last in the map)
|
||||
const pids = Array.from(backgroundTasks.keys());
|
||||
const newestPid = pids[pids.length - 1];
|
||||
if (newestPid !== undefined && newestPid !== activeBackgroundShellPid) {
|
||||
setActiveBackgroundShellPid(newestPid);
|
||||
}
|
||||
}
|
||||
prevShellCountRef.current = backgroundTaskCount;
|
||||
}, [
|
||||
backgroundTasks,
|
||||
activeBackgroundShellPid,
|
||||
backgroundTaskCount,
|
||||
isBackgroundShellListOpen,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (embeddedShellFocused) {
|
||||
const hasActiveForegroundShell = !!activePtyId;
|
||||
const hasVisibleBackgroundShell =
|
||||
isBackgroundTaskVisible && backgroundTasks.size > 0;
|
||||
|
||||
if (!hasActiveForegroundShell && !hasVisibleBackgroundShell) {
|
||||
setEmbeddedShellFocused(false);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isBackgroundTaskVisible,
|
||||
backgroundTasks,
|
||||
embeddedShellFocused,
|
||||
backgroundTaskCount,
|
||||
activePtyId,
|
||||
setEmbeddedShellFocused,
|
||||
]);
|
||||
|
||||
const backgroundShellHeight = useMemo(
|
||||
() =>
|
||||
isBackgroundTaskVisible && backgroundTasks.size > 0
|
||||
? Math.max(Math.floor(terminalHeight * 0.3), 5)
|
||||
: 0,
|
||||
[isBackgroundTaskVisible, backgroundTasks.size, terminalHeight],
|
||||
);
|
||||
|
||||
return {
|
||||
isBackgroundShellListOpen,
|
||||
setIsBackgroundShellListOpen,
|
||||
activeBackgroundShellPid,
|
||||
setActiveBackgroundShellPid,
|
||||
backgroundShellHeight,
|
||||
};
|
||||
}
|
||||
@@ -661,6 +661,10 @@ export const useExecutionLifecycle = (
|
||||
(s: BackgroundTask) => s.status === 'running',
|
||||
).length;
|
||||
|
||||
const showBackgroundShell = useCallback(() => {
|
||||
dispatch({ type: 'SET_VISIBILITY', visible: true });
|
||||
}, [dispatch]);
|
||||
|
||||
return {
|
||||
handleShellCommand,
|
||||
activeShellPtyId: state.activeShellPtyId,
|
||||
@@ -668,6 +672,7 @@ export const useExecutionLifecycle = (
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskVisible: state.isBackgroundTaskVisible,
|
||||
toggleBackgroundTasks,
|
||||
showBackgroundShell,
|
||||
backgroundCurrentExecution,
|
||||
registerBackgroundTask,
|
||||
dismissBackgroundTask,
|
||||
|
||||
@@ -390,6 +390,7 @@ export const useGeminiStream = (
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskVisible,
|
||||
toggleBackgroundTasks,
|
||||
showBackgroundShell,
|
||||
backgroundCurrentExecution,
|
||||
registerBackgroundTask,
|
||||
dismissBackgroundTask,
|
||||
@@ -1917,6 +1918,7 @@ export const useGeminiStream = (
|
||||
backgroundedTool.command,
|
||||
backgroundedTool.initialOutput,
|
||||
);
|
||||
showBackgroundShell();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2056,6 +2058,7 @@ export const useGeminiStream = (
|
||||
modelSwitchedFromQuotaError,
|
||||
addItem,
|
||||
registerBackgroundTask,
|
||||
showBackgroundShell,
|
||||
consumeUserHint,
|
||||
isLowErrorVerbosity,
|
||||
maybeAddSuppressedToolErrorNote,
|
||||
|
||||
Reference in New Issue
Block a user