Skip to content

Commit 181898c

Browse files
authored
feat(shell): enable interactive commands with virtual terminal (google-gemini#6694)
1 parent 8969a23 commit 181898c

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+2338
-317
lines changed

packages/a2a-server/src/agent/task.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type {
2525
ToolCallConfirmationDetails,
2626
Config,
2727
UserTierId,
28+
AnsiOutput,
2829
} from '@google/gemini-cli-core';
2930
import type { RequestContext } from '@a2a-js/sdk/server';
3031
import { type ExecutionEventBus } from '@a2a-js/sdk/server';
@@ -284,20 +285,29 @@ export class Task {
284285

285286
private _schedulerOutputUpdate(
286287
toolCallId: string,
287-
outputChunk: string,
288+
outputChunk: string | AnsiOutput,
288289
): void {
290+
let outputAsText: string;
291+
if (typeof outputChunk === 'string') {
292+
outputAsText = outputChunk;
293+
} else {
294+
outputAsText = outputChunk
295+
.map((line) => line.map((token) => token.text).join(''))
296+
.join('\n');
297+
}
298+
289299
logger.info(
290300
'[Task] Scheduler output update for tool call ' +
291301
toolCallId +
292302
': ' +
293-
outputChunk,
303+
outputAsText,
294304
);
295305
const artifact: Artifact = {
296306
artifactId: `tool-${toolCallId}-output`,
297307
parts: [
298308
{
299309
kind: 'text',
300-
text: outputChunk,
310+
text: outputAsText,
301311
} as Part,
302312
],
303313
};

packages/a2a-server/src/http/app.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ vi.mock('../utils/logger.js', () => ({
6464
let config: Config;
6565
const getToolRegistrySpy = vi.fn().mockReturnValue(ApprovalMode.DEFAULT);
6666
const getApprovalModeSpy = vi.fn();
67+
const getShellExecutionConfigSpy = vi.fn();
6768
vi.mock('../config/config.js', async () => {
6869
const actual = await vi.importActual('../config/config.js');
6970
return {
@@ -72,6 +73,7 @@ vi.mock('../config/config.js', async () => {
7273
const mockConfig = createMockConfig({
7374
getToolRegistry: getToolRegistrySpy,
7475
getApprovalMode: getApprovalModeSpy,
76+
getShellExecutionConfig: getShellExecutionConfigSpy,
7577
});
7678
config = mockConfig as Config;
7779
return config;

packages/cli/src/config/keyBindings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export enum Command {
5656
REVERSE_SEARCH = 'reverseSearch',
5757
SUBMIT_REVERSE_SEARCH = 'submitReverseSearch',
5858
ACCEPT_SUGGESTION_REVERSE_SEARCH = 'acceptSuggestionReverseSearch',
59+
TOGGLE_SHELL_INPUT_FOCUS = 'toggleShellInputFocus',
5960
}
6061

6162
/**
@@ -162,4 +163,5 @@ export const defaultKeyBindings: KeyBindingConfig = {
162163
// Note: original logic ONLY checked ctrl=false, ignored meta/shift/paste
163164
[Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }],
164165
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }],
166+
[Command.TOGGLE_SHELL_INPUT_FOCUS]: [{ key: 'f', ctrl: true }],
165167
};

packages/cli/src/config/settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ const MIGRATION_MAP: Record<string, string> = {
106106
sandbox: 'tools.sandbox',
107107
selectedAuthType: 'security.auth.selectedType',
108108
shouldUseNodePtyShell: 'tools.usePty',
109+
shellPager: 'tools.shell.pager',
110+
shellShowColor: 'tools.shell.showColor',
109111
skipNextSpeakerCheck: 'model.skipNextSpeakerCheck',
110112
summarizeToolOutput: 'model.summarizeToolOutput',
111113
telemetry: 'telemetry',

packages/cli/src/config/settingsSchema.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,36 @@ const SETTINGS_SCHEMA = {
649649
'Use node-pty for shell command execution. Fallback to child_process still applies.',
650650
showInDialog: true,
651651
},
652+
shell: {
653+
type: 'object',
654+
label: 'Shell',
655+
category: 'Tools',
656+
requiresRestart: false,
657+
default: {},
658+
description: 'Settings for shell execution.',
659+
showInDialog: false,
660+
properties: {
661+
pager: {
662+
type: 'string',
663+
label: 'Pager',
664+
category: 'Tools',
665+
requiresRestart: false,
666+
default: 'cat' as string | undefined,
667+
description:
668+
'The pager command to use for shell output. Defaults to `cat`.',
669+
showInDialog: false,
670+
},
671+
showColor: {
672+
type: 'boolean',
673+
label: 'Show Color',
674+
category: 'Tools',
675+
requiresRestart: false,
676+
default: false,
677+
description: 'Show color in shell output.',
678+
showInDialog: true,
679+
},
680+
},
681+
},
652682
autoAccept: {
653683
type: 'boolean',
654684
label: 'Auto Accept',

packages/cli/src/services/prompt-processors/shellProcessor.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ describe('ShellProcessor', () => {
7171
getTargetDir: vi.fn().mockReturnValue('/test/dir'),
7272
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
7373
getShouldUseNodePtyShell: vi.fn().mockReturnValue(false),
74+
getShellExecutionConfig: vi.fn().mockReturnValue({}),
7475
};
7576

7677
context = createMockCommandContext({
@@ -147,6 +148,7 @@ describe('ShellProcessor', () => {
147148
expect.any(Function),
148149
expect.any(Object),
149150
false,
151+
expect.any(Object),
150152
);
151153
expect(result).toEqual([{ text: 'The current status is: On branch main' }]);
152154
});
@@ -218,6 +220,7 @@ describe('ShellProcessor', () => {
218220
expect.any(Function),
219221
expect.any(Object),
220222
false,
223+
expect.any(Object),
221224
);
222225
expect(result).toEqual([{ text: 'Do something dangerous: deleted' }]);
223226
});
@@ -410,6 +413,7 @@ describe('ShellProcessor', () => {
410413
expect.any(Function),
411414
expect.any(Object),
412415
false,
416+
expect.any(Object),
413417
);
414418
});
415419

@@ -574,6 +578,7 @@ describe('ShellProcessor', () => {
574578
expect.any(Function),
575579
expect.any(Object),
576580
false,
581+
expect.any(Object),
577582
);
578583

579584
expect(result).toEqual([{ text: 'Command: match found' }]);
@@ -598,6 +603,7 @@ describe('ShellProcessor', () => {
598603
expect.any(Function),
599604
expect.any(Object),
600605
false,
606+
expect.any(Object),
601607
);
602608

603609
expect(result).toEqual([
@@ -668,6 +674,7 @@ describe('ShellProcessor', () => {
668674
expect.any(Function),
669675
expect.any(Object),
670676
false,
677+
expect.any(Object),
671678
);
672679
});
673680

@@ -697,6 +704,7 @@ describe('ShellProcessor', () => {
697704
expect.any(Function),
698705
expect.any(Object),
699706
false,
707+
expect.any(Object),
700708
);
701709
});
702710
});

packages/cli/src/services/prompt-processors/shellProcessor.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
SHORTHAND_ARGS_PLACEHOLDER,
2121
} from './types.js';
2222
import { extractInjections, type Injection } from './injectionParser.js';
23+
import { themeManager } from '../../ui/themes/theme-manager.js';
2324

2425
export class ConfirmationRequiredError extends Error {
2526
constructor(
@@ -159,12 +160,19 @@ export class ShellProcessor implements IPromptProcessor {
159160

160161
// Execute the resolved command (which already has ESCAPED input).
161162
if (injection.resolvedCommand) {
163+
const activeTheme = themeManager.getActiveTheme();
164+
const shellExecutionConfig = {
165+
...config.getShellExecutionConfig(),
166+
defaultFg: activeTheme.colors.Foreground,
167+
defaultBg: activeTheme.colors.Background,
168+
};
162169
const { result } = await ShellExecutionService.execute(
163170
injection.resolvedCommand,
164171
config.getTargetDir(),
165172
() => {},
166173
new AbortController().signal,
167174
config.getShouldUseNodePtyShell(),
175+
shellExecutionConfig,
168176
);
169177

170178
const executionResult = await result;

packages/cli/src/ui/AppContainer.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
getAllGeminiMdFilenames,
3535
AuthType,
3636
clearCachedCredentialFile,
37+
ShellExecutionService,
3738
} from '@google/gemini-cli-core';
3839
import { validateAuthMethod } from '../config/auth.js';
3940
import { loadHierarchicalGeminiMemory } from '../config/config.js';
@@ -97,6 +98,18 @@ interface AppContainerProps {
9798
initializationResult: InitializationResult;
9899
}
99100

101+
/**
102+
* The fraction of the terminal width to allocate to the shell.
103+
* This provides horizontal padding.
104+
*/
105+
const SHELL_WIDTH_FRACTION = 0.89;
106+
107+
/**
108+
* The number of lines to subtract from the available terminal height
109+
* for the shell. This provides vertical padding and space for other UI elements.
110+
*/
111+
const SHELL_HEIGHT_PADDING = 10;
112+
100113
export const AppContainer = (props: AppContainerProps) => {
101114
const { settings, config, initializationResult } = props;
102115
const historyManager = useHistory();
@@ -110,6 +123,8 @@ export const AppContainer = (props: AppContainerProps) => {
110123
initializationResult.themeError,
111124
);
112125
const [isProcessing, setIsProcessing] = useState<boolean>(false);
126+
const [shellFocused, setShellFocused] = useState(false);
127+
113128
const [geminiMdFileCount, setGeminiMdFileCount] = useState<number>(
114129
initializationResult.geminiMdFileCount,
115130
);
@@ -506,6 +521,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
506521
pendingHistoryItems: pendingGeminiHistoryItems,
507522
thought,
508523
cancelOngoingRequest,
524+
activePtyId,
509525
loopDetectionConfirmationRequest,
510526
} = useGeminiStream(
511527
config.getGeminiClient(),
@@ -523,6 +539,10 @@ Logging in with Google... Please restart Gemini CLI to continue.
523539
setModelSwitchedFromQuotaError,
524540
refreshStatic,
525541
() => cancelHandlerRef.current(),
542+
setShellFocused,
543+
terminalWidth,
544+
terminalHeight,
545+
shellFocused,
526546
);
527547

528548
const { messageQueue, addMessage, clearQueue, getQueuedMessagesText } =
@@ -603,6 +623,13 @@ Logging in with Google... Please restart Gemini CLI to continue.
603623
return terminalHeight - staticExtraHeight;
604624
}, [terminalHeight]);
605625

626+
config.setShellExecutionConfig({
627+
terminalWidth: Math.floor(terminalWidth * SHELL_WIDTH_FRACTION),
628+
terminalHeight: Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING),
629+
pager: settings.merged.tools?.shell?.pager,
630+
showColor: settings.merged.tools?.shell?.showColor,
631+
});
632+
606633
const isFocused = useFocus();
607634
useBracketedPaste();
608635

@@ -620,6 +647,22 @@ Logging in with Google... Please restart Gemini CLI to continue.
620647
const initialPromptSubmitted = useRef(false);
621648
const geminiClient = config.getGeminiClient();
622649

650+
useEffect(() => {
651+
if (activePtyId) {
652+
ShellExecutionService.resizePty(
653+
activePtyId,
654+
Math.floor(terminalWidth * SHELL_WIDTH_FRACTION),
655+
Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING),
656+
);
657+
}
658+
}, [
659+
terminalHeight,
660+
terminalWidth,
661+
availableTerminalHeight,
662+
activePtyId,
663+
geminiClient,
664+
]);
665+
623666
useEffect(() => {
624667
if (
625668
initialPrompt &&
@@ -840,6 +883,10 @@ Logging in with Google... Please restart Gemini CLI to continue.
840883
!enteringConstrainHeightMode
841884
) {
842885
setConstrainHeight(false);
886+
} else if (keyMatchers[Command.TOGGLE_SHELL_INPUT_FOCUS](key)) {
887+
if (activePtyId || shellFocused) {
888+
setShellFocused((prev) => !prev);
889+
}
843890
}
844891
},
845892
[
@@ -866,6 +913,8 @@ Logging in with Google... Please restart Gemini CLI to continue.
866913
isSettingsDialogOpen,
867914
isFolderTrustDialogOpen,
868915
showPrivacyNotice,
916+
activePtyId,
917+
shellFocused,
869918
settings.merged.general?.debugKeystrokeLogging,
870919
],
871920
);
@@ -991,6 +1040,8 @@ Logging in with Google... Please restart Gemini CLI to continue.
9911040
updateInfo,
9921041
showIdeRestartPrompt,
9931042
isRestarting,
1043+
activePtyId,
1044+
shellFocused,
9941045
}),
9951046
[
9961047
historyManager.history,
@@ -1064,6 +1115,8 @@ Logging in with Google... Please restart Gemini CLI to continue.
10641115
showIdeRestartPrompt,
10651116
isRestarting,
10661117
currentModel,
1118+
activePtyId,
1119+
shellFocused,
10671120
],
10681121
);
10691122

0 commit comments

Comments
 (0)