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:
Gaurav Ghosh
2026-03-20 13:39:10 -07:00
parent cbacdc67d0
commit 651ad63ed6
22 changed files with 906 additions and 83 deletions

View File

@@ -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,

View File

@@ -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',

View File

@@ -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);

View 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,
};
}

View File

@@ -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,

View File

@@ -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,