Compare commits

..

1 Commits

Author SHA1 Message Date
Coco Sheng 80ff4bd39c feat: implement alternate buffer toggling and unit tests 2026-03-30 11:40:52 -04:00
16 changed files with 139 additions and 170 deletions
-8
View File
@@ -38,14 +38,6 @@ folder, a dialog will automatically appear, prompting you to make a choice:
Your choice is saved in a central file (`~/.gemini/trustedFolders.json`), so you
will only be asked once per folder.
### Headless mode and automation
When running in headless mode (using the `-p` or `--prompt` flag), the trust
dialog is bypassed automatically to ensure that non-interactive workflows and
automation scripts are not interrupted. In this mode, the CLI operates with
restricted permissions as if the folder were untrusted, unless the folder has
been previously trusted through an interactive session.
## Understanding folder contents: The discovery phase
Before you make a choice, the Gemini CLI performs a **discovery phase** to scan
+2 -2
View File
@@ -138,7 +138,7 @@ like `jq`. To get pure JSON data from the model, combine the
fi
# Extract data
gemini -p "Return a raw JSON object with keys 'version' and 'deps' from @package.json" --output-format json | jq -r '.response' > data.json
gemini --output-format json "Return a raw JSON object with keys 'version' and 'deps' from @package.json" | jq -r '.response' > data.json
```
**Windows PowerShell (`generate_json.ps1`)**
@@ -151,7 +151,7 @@ like `jq`. To get pure JSON data from the model, combine the
}
# Extract data (requires jq installed, or you can use ConvertFrom-Json)
$output = gemini -p "Return a raw JSON object with keys 'version' and 'deps' from @package.json" --output-format json | ConvertFrom-Json
$output = gemini --output-format json "Return a raw JSON object with keys 'version' and 'deps' from @package.json" | ConvertFrom-Json
$output.response | Out-File -FilePath data.json -Encoding utf8
```
-93
View File
@@ -1,93 +0,0 @@
# A2A Server
The A2A Server is a core component of the Gemini CLI that implements the
Agent-to-Agent (A2A) protocol. It enables external clients, such as IDEs or
other AI agents, to interact with the Gemini CLI agent through a standardized,
streaming interface.
<!-- prettier-ignore -->
> [!NOTE]
> This is an experimental feature currently under active development.
The A2A Server allows Gemini CLI to function as a backend service, providing
capabilities like code generation, tool execution, and workspace analysis to
client applications like Zed, VS Code, and Gemini Code Assist.
## Overview
The A2A Server implements the `development-tool` extension for the A2A protocol.
This extension defines a communication contract for rich, interactive workflows,
including:
- **Task-based streaming:** Real-time updates on the agent's thoughts and tool
invocations.
- **Permission requests:** Standardized mechanism for the agent to request user
approval before executing potentially sensitive tools.
- **Tool lifecycle management:** A structured state machine for tracking tool
execution from creation to completion.
- **Slash command execution:** Programmatic access to built-in Gemini CLI
commands.
## Usage
The A2A Server can be started independently or as part of an integrated
workflow.
### Starting the server
In a development environment, you can launch the A2A Server from the project
root using npm:
```bash
npm run start:a2a-server
```
This command builds the necessary dependencies and starts the server on its
default port. The server provides an SSE (Server-Sent Events) stream for
real-time updates to connected A2A clients.
### Integration with IDEs
Many IDE integrations use the A2A Server to power their "Agent Mode" features.
For example, the [VS Code companion](../ide-integration/index.md) communicates
with the A2A Server to execute complex coding tasks within your workspace.
## Key features
### Development tool extension
The A2A Server uses a specialized set of schemas to enable rich client
interactions:
- **Agent thoughts:** Streams the agent's internal reasoning as it works through
a task.
- **Tool calls:** Provides a detailed, stateless representation of tool
execution, including `PENDING`, `EXECUTING`, and `SUCCEEDED` statuses.
- **Confirmation requests:** Requests user permission for shell commands, file
edits, or MCP tool usage via the client UI.
### Separation of concerns
The A2A architecture enforces a strict separation of concerns:
1. **A2A Protocol:** Standardizes the communication and task management between
the client and the Gemini CLI agent.
2. **MCP (Model Context Protocol):** Serves as the authoritative interface for
accessing client-side capabilities, such as reading active buffers or
accessing the local file system.
## Troubleshooting
If you encounter issues with the A2A Server:
- **Check logs:** Review the server output for errors during initialization or
client connection.
- **Verify A2A compliance:** Ensure that the client application is correctly
implementing the A2A protocol and the `development-tool` extension.
- **Network permissions:** Ensure that the server has the necessary permissions
to listen on its configured port and communicate with MCP servers.
## Next steps
- Learn about [Remote subagents](./remote-agents.md) and A2A connectivity.
- Explore the [IDE integration guide](../ide-integration/index.md).
+1 -1
View File
@@ -89,7 +89,7 @@ manually run through this checklist.
- [ ] Vertex AI
- **Basic prompting:**
- [ ] Run `gemini -p "Tell me a joke"` and verify a sensible response.
- [ ] Run `gemini "Tell me a joke"` and verify a sensible response.
- [ ] Run in interactive mode: `gemini`. Ask a follow-up question to test
context.
+2 -17
View File
@@ -95,11 +95,6 @@
}
]
},
{
"label": "A2A Server",
"badge": "🔬",
"slug": "docs/core/a2a-server"
},
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
{ "label": "Headless mode", "slug": "docs/cli/headless" },
@@ -163,18 +158,8 @@
"items": [
{ "label": "Custom commands", "slug": "docs/cli/custom-commands" },
{
"label": "Enterprise administration",
"collapsed": true,
"items": [
{
"label": "Enterprise configuration",
"slug": "docs/cli/enterprise"
},
{
"label": "Enterprise Admin Controls",
"slug": "docs/admin/enterprise-controls"
}
]
"label": "Enterprise configuration",
"slug": "docs/cli/enterprise"
},
{
"label": "Ignore files (.geminiignore)",
+3 -21
View File
@@ -15,13 +15,9 @@ Lists the names of files and subdirectories directly within a specified path.
- **Tool name:** `list_directory`
- **Arguments:**
- `dir_path` (string, required): Absolute or relative path to the directory.
- `ignore` (array, optional): Glob patterns to exclude (e.g.,
`["*.tmp", "build/"]`).
- `ignore` (array, optional): Glob patterns to exclude.
- `file_filtering_options` (object, optional): Configuration for `.gitignore`
and `.geminiignore` compliance.
- `respect_git_ignore` (boolean, optional): Whether to respect `.gitignore`.
- `respect_gemini_ignore` (boolean, optional): Whether to respect
`.geminiignore`.
### `read_file` (ReadFile)
@@ -31,22 +27,8 @@ and PDF.
- **Tool name:** `read_file`
- **Arguments:**
- `file_path` (string, required): Path to the file.
- `start_line` (number, optional): The 1-based line number to start reading
from.
- `end_line` (number, optional): The 1-based line number to end reading at
(inclusive).
### `read_many_files` (ReadManyFiles)
Reads and concatenates content from multiple files or entire directories. This
is the tool triggered by the `@` symbol in your prompt.
- **Tool name:** `read_many_files`
- **Arguments:**
- `paths` (array of strings, required): List of paths to files or directories.
- `ignore` (array of strings, optional): Glob patterns to exclude.
- `file_filtering_options` (object, optional): Configuration for `.gitignore`
and `.geminiignore` compliance.
- `offset` (number, optional): Start line for text files (0-based).
- `limit` (number, optional): Maximum lines to read.
### `write_file` (WriteFile)
+1
View File
@@ -534,6 +534,7 @@ export const mockAppState: AppState = {
};
const mockUIActions: UIActions = {
toggleAlternateBuffer: vi.fn(),
handleThemeSelect: vi.fn(),
closeThemeDialog: vi.fn(),
handleThemeHighlight: vi.fn(),
+58 -1
View File
@@ -68,8 +68,10 @@ import {
writeToStdout,
disableMouseEvents,
enterAlternateScreen,
exitAlternateScreen,
enableMouseEvents,
disableLineWrapping,
enableLineWrapping,
shouldEnterAlternateScreen,
startupProfiler,
SessionStartSource,
@@ -213,7 +215,7 @@ export const AppContainer = (props: AppContainerProps) => {
});
useMemoryMonitor(historyManager);
const isAlternateBuffer = config.getUseAlternateBuffer();
const [isAlternateBuffer, setIsAlternateBuffer] = useState(config.getUseAlternateBuffer());
const [corgiMode, setCorgiMode] = useState(false);
const [forceRerenderKey, setForceRerenderKey] = useState(0);
const [debugMessage, setDebugMessage] = useState<string>('');
@@ -1550,6 +1552,23 @@ Logging in with Google... Restarting Gemini CLI to continue.
type: TransientMessageType;
}>(WARNING_PROMPT_DURATION_MS);
const [shownBufferToggleHint, setShownBufferToggleHint] = useState(false);
useEffect(() => {
if (isAlternateBuffer) return;
const isLongHistory = historyManager.history.length > 15;
const isComplexPrompt = buffer.text.length > 200 || buffer.text.includes('\n');
if ((isLongHistory || isComplexPrompt) && !shownBufferToggleHint) {
showTransientMessage({
text: 'Tip: Press Alt+T to toggle full-screen mode for better scrolling/editing',
type: TransientMessageType.Hint
});
setShownBufferToggleHint(true);
}
}, [historyManager.history.length, buffer.text, isAlternateBuffer, shownBufferToggleHint, showTransientMessage]);
const {
isFolderTrustDialogOpen,
discoveryResults: folderDiscoveryResults,
@@ -1700,6 +1719,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
return true;
}
if (keyMatchers[Command.TOGGLE_BUFFER_MODE](key)) {
toggleAlternateBuffer();
return true;
}
if (keyMatchers[Command.QUIT](key)) {
// If the user presses Ctrl+C, we want to cancel any ongoing requests.
// This should happen regardless of the count.
@@ -2204,8 +2228,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
};
}, [config, refreshStatic]);
const showIsAlternateBufferHint = (historyManager.history.length > 15 || buffer.text.length > 200 || buffer.text.includes('\n')) && !isAlternateBuffer;
const uiState: UIState = useMemo(
() => ({
isAlternateBuffer,
history: historyManager.history,
historyManager,
isThemeDialogOpen,
@@ -2331,6 +2358,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
adminSettingsChanged,
newAgents,
showIsExpandableHint,
showIsAlternateBufferHint,
hintMode:
config.isModelSteeringEnabled() && isToolExecuting(pendingHistoryItems),
hintBuffer: '',
@@ -2457,6 +2485,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
adminSettingsChanged,
newAgents,
showIsExpandableHint,
showIsAlternateBufferHint,
isAlternateBuffer,
],
);
@@ -2465,6 +2495,31 @@ Logging in with Google... Restarting Gemini CLI to continue.
[setShowPrivacyNotice],
);
const toggleAlternateBuffer = useCallback(() => {
setIsAlternateBuffer(prev => {
const next = !prev;
if (next) {
enterAlternateScreen();
enableMouseEvents();
disableLineWrapping();
} else {
exitAlternateScreen();
disableMouseEvents();
enableLineWrapping();
writeToStdout('\x1b[2J\x1b[H');
}
process.stdout.emit('resize');
// Give a tick for resize to process, then trigger remount to force full redraw
setImmediate(() => {
refreshStatic();
setForceRerenderKey((prev) => prev + 1);
});
return next;
});
}, [setIsAlternateBuffer, refreshStatic, setForceRerenderKey]);
const uiActions: UIActions = useMemo(
() => ({
handleThemeSelect,
@@ -2476,6 +2531,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleEditorSelect,
exitEditorDialog,
exitPrivacyNotice,
toggleAlternateBuffer,
closeSettingsDialog,
closeModelDialog,
openAgentConfigDialog,
@@ -2614,6 +2670,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
config,
historyManager,
getPreferredEditor,
toggleAlternateBuffer,
],
);
@@ -142,4 +142,38 @@ describe('<StatusRow />', () => {
await waitUntilReady();
expect(lastFrame()).toContain('Tip: Test Tip');
});
it('renders buffer toggle hint when showIsAlternateBufferHint is true', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: false,
showLoadingIndicator: false,
showTips: false,
showWit: false,
modeContentObj: null,
showMinimalContext: false,
});
const uiState: Partial<UIState> = {
...defaultUiState,
showIsAlternateBufferHint: true,
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={false}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('[Alt+T] Switch to Full Screen');
});
});
+6 -1
View File
@@ -206,7 +206,12 @@ export const StatusRow: React.FC<StatusRowProps> = ({
return uiState.currentTip;
}
// 2. Shortcut Hint (Fallback)
// 2. Buffer Toggle Hint
if (uiState.showIsAlternateBufferHint) {
return '[Alt+T] Switch to Full Screen';
}
// 3. Shortcut Hint (Fallback)
if (
settings.merged.ui.showShortcutsHint &&
!hideUiDetailsForSuggestions &&
@@ -205,6 +205,8 @@ const MAC_ALT_KEY_CHARACTER_MAP: Record<string, string> = {
'\u03A9': 'z', // "Ω" Option+z
'\u00B8': 'Z', // "¸" Option+Shift+z
'\u2202': 'd', // "∂" delete word forward
'\u2020': 't', // "†" toggle full screen buffer
'\u00E5': 'a', // "å" Option+a for alternate buffer
};
function nonKeyboardEventFilter(
@@ -78,6 +78,7 @@ export interface UIActions {
setShortcutsHelpVisible: (visible: boolean) => void;
setCleanUiDetailsVisible: (visible: boolean) => void;
toggleCleanUiDetailsVisible: () => void;
toggleAlternateBuffer: () => void;
revealCleanUiDetailsTemporarily: (durationMs?: number) => void;
handleWarning: (message: string) => void;
setEmbeddedShellFocused: (value: boolean) => void;
@@ -118,6 +118,8 @@ export interface UIState {
isEditorDialogOpen: boolean;
showPrivacyNotice: boolean;
corgiMode: boolean;
isAlternateBuffer: boolean;
showIsAlternateBufferHint: boolean;
debugMessage: string;
quittingMessages: HistoryItem[] | null;
isSettingsDialogOpen: boolean;
@@ -11,49 +11,47 @@ import {
isAlternateBufferEnabled,
} from './useAlternateBuffer.js';
import type { Config } from '@google/gemini-cli-core';
import { useUIState } from '../contexts/UIStateContext.js';
vi.mock('../contexts/ConfigContext.js', () => ({
useConfig: vi.fn(),
}));
vi.mock('../contexts/UIStateContext.js');
const mockUseConfig = vi.mocked(
await import('../contexts/ConfigContext.js').then((m) => m.useConfig),
);
const mockUseUIState = vi.mocked(useUIState);
describe('useAlternateBuffer', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return false when config.getUseAlternateBuffer returns false', async () => {
mockUseConfig.mockReturnValue({
getUseAlternateBuffer: () => false,
} as unknown as ReturnType<typeof mockUseConfig>);
it('should return false when uiState.isAlternateBuffer is false', async () => {
mockUseUIState.mockReturnValue({
isAlternateBuffer: false,
} as unknown as ReturnType<typeof mockUseUIState>);
const { result } = await renderHook(() => useAlternateBuffer());
expect(result.current).toBe(false);
});
it('should return true when config.getUseAlternateBuffer returns true', async () => {
mockUseConfig.mockReturnValue({
getUseAlternateBuffer: () => true,
} as unknown as ReturnType<typeof mockUseConfig>);
it('should return true when uiState.isAlternateBuffer is true', async () => {
mockUseUIState.mockReturnValue({
isAlternateBuffer: true,
} as unknown as ReturnType<typeof mockUseUIState>);
const { result } = await renderHook(() => useAlternateBuffer());
expect(result.current).toBe(true);
});
it('should return the immutable config value, not react to settings changes', async () => {
const mockConfig = {
getUseAlternateBuffer: () => true,
} as unknown as ReturnType<typeof mockUseConfig>;
mockUseConfig.mockReturnValue(mockConfig);
it('should react to state changes', async () => {
mockUseUIState.mockReturnValue({
isAlternateBuffer: false,
} as unknown as ReturnType<typeof mockUseUIState>);
const { result, rerender } = await renderHook(() => useAlternateBuffer());
// Value should remain true even after rerender
expect(result.current).toBe(true);
expect(result.current).toBe(false);
mockUseUIState.mockReturnValue({
isAlternateBuffer: true,
} as unknown as ReturnType<typeof mockUseUIState>);
rerender();
@@ -4,14 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useConfig } from '../contexts/ConfigContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import type { Config } from '@google/gemini-cli-core';
export const isAlternateBufferEnabled = (config: Config): boolean =>
config.getUseAlternateBuffer();
// This is read from Config so that the UI reads the same value per application session
// This is read from UIState so that the UI can toggle dynamically
export const useAlternateBuffer = (): boolean => {
const config = useConfig();
return isAlternateBufferEnabled(config);
const uiState = useUIState();
return uiState.isAlternateBuffer;
};
+3
View File
@@ -95,6 +95,7 @@ export enum Command {
RESTART_APP = 'app.restart',
SUSPEND_APP = 'app.suspend',
SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'app.showShellUnfocusWarning',
TOGGLE_BUFFER_MODE = 'app.toggleBufferMode',
// Background Shell Controls
BACKGROUND_SHELL_ESCAPE = 'background.escape',
@@ -392,6 +393,7 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
[Command.RESTART_APP, [new KeyBinding('r'), new KeyBinding('shift+r')]],
[Command.SUSPEND_APP, [new KeyBinding('ctrl+z')]],
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, [new KeyBinding('tab')]],
[Command.TOGGLE_BUFFER_MODE, [new KeyBinding('alt+a')]],
// Background Shell Controls
[Command.BACKGROUND_SHELL_ESCAPE, [new KeyBinding('escape')]],
@@ -609,6 +611,7 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.SHOW_IDE_CONTEXT_DETAIL]: 'Show IDE context details.',
[Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.',
[Command.TOGGLE_COPY_MODE]: 'Toggle copy mode when in alternate buffer mode.',
[Command.TOGGLE_BUFFER_MODE]: 'Toggle between regular and full screen (alternate buffer) mode.',
[Command.TOGGLE_YOLO]: 'Toggle YOLO (auto-approval) mode for tool calls.',
[Command.CYCLE_APPROVAL_MODE]:
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy.',