Compare commits

...

40 Commits

Author SHA1 Message Date
jacob314 e2981da430 refactor(cli): simplify command completion orchestration and fix shell cache race
- Simplified `useCommandCompletion.tsx` by moving shell-specific tokenization into `useShellCompletion.ts`.
- Reverted most changes to the `useCommandCompletion` `useMemo` block to keep it clean and consistent with other modes.
- Fixed a potential race condition in `useShellCompletion.ts` where `scanPathExecutables` could be called multiple times if it was slow. It now caches the Promise itself.
- Updated tests to match the refined hook interfaces.
2026-02-25 10:35:14 -08:00
jacob314 1dbb9af41c fix(cli): prioritize shorter command names in shell autocomplete results 2026-02-25 10:00:14 -08:00
jacob314 b4724a1eed test(cli): update snapshots for shell autocompletion refinements 2026-02-25 09:37:28 -08:00
jacob314 4eea909f28 fix(cli): add Windows shell built-in commands to autocomplete list
On Windows, many common commands like `dir`, `copy`, `del`, etc., are internal to `cmd.exe` and do not exist as separate executables on the disk. This updates `scanPathExecutables` to explicitly include these built-ins when running on Windows, ensuring a consistent autocomplete experience for Windows users.
2026-02-25 09:26:38 -08:00
jacob314 af8462f76d fix(cli): disable shell autocompletion on completely empty prompts
To reduce visual noise and distraction, autocompletion is now suppressed when the shell prompt is entirely empty (or only contains whitespace). It remains active as soon as the user starts typing or moves the cursor to a position that would suggest paths/commands.
2026-02-25 09:09:33 -08:00
jacob314 2f4242fb60 fix(cli): prevent autocomplete UI flicker by decoupling disabled state reset from completion refresh
When typing outside of shell mode (e.g., using `@` completion), the `useShellCompletion` hook was continuously re-rendering and executing its `useEffect` because `performCompletion` was recreated on every keystroke (since its `query` dependency changed).

Because `enabled` was false, the effect unconditionally called `setSuggestions([])`, rapidly overriding the suggestions populated by `useAtCompletion` and causing severe UI flickering.

This splits the effect to ensure `setSuggestions([])` is only called once when `enabled` becomes false, stabilizing the suggestions list.
2026-02-25 08:58:31 -08:00
jacob314 aeecf5a9e7 fix(cli): prevent auto-submit on Enter when navigating to first shell autocomplete suggestion
When using shell autocomplete, pressing Down then Up returns the activeSuggestionIndex to 0. If the first suggestion happened to be an exact match, the InputPrompt would bypass the normal autocomplete logic and eagerly submit the prompt on Enter because it ignored whether the user had actively navigated.

This fix updates the isPerfectMatch check to require that the user has not navigated the suggestions list, ensuring that explicitly selected items always autocomplete instead of submitting.
2026-02-25 08:23:53 -08:00
MD. MOHIBUR RAHMAN dfa5c79a47 refactor(cli): centralize token parsing in useCommandCompletion to optimize re-renders 2026-02-24 05:22:51 +06:00
MD. MOHIBUR RAHMAN d6770d2ae9 fix(cli): escape executables in shell autocompletion to prevent command injection 2026-02-24 05:06:10 +06:00
MD. MOHIBUR RAHMAN c1d7bc4c8c fix(cli): escape npm script names in shell autocompletion to prevent command injection 2026-02-24 05:04:27 +06:00
MD. MOHIBUR RAHMAN 39b393abfb fix(cli): prevent command injection by escaping git branch suggestions 2026-02-24 04:25:16 +06:00
MD. MOHIBUR RAHMAN 5b3819073f fix(cli): add missing shell metacharacters (\n, \r, \t, \\) to escape regex 2026-02-24 04:14:54 +06:00
MD. MOHIBUR RAHMAN 926afab788 fix(cli): improve shell completion for dotfiles, spaces, and quotes 2026-02-24 03:41:33 +06:00
MD. MOHIBUR RAHMAN 555b95eb09 feat(cli): add context-sensitive shell completions for git and npm (#2492) 2026-02-24 02:52:20 +06:00
MD. MOHIBUR RAHMAN 0c6ebbd1f5 feat(cli): implement interactive shell autocompletion (#2492) 2026-02-24 01:49:30 +06:00
Sandy Tao 25803e05fd fix(bundling): copy devtools package to bundle for runtime resolution (#19766) 2026-02-23 18:40:41 +00:00
Himanshu Soni 774ae220be fix(core): prevent state corruption in McpClientManager during collis (#19782) 2026-02-23 18:35:31 +00:00
Tommaso Sciortino 813e0c18ac Allow ask headers longer than 16 chars (#20041) 2026-02-23 18:26:59 +00:00
Gal Zahavi 3f6cec22e6 chore: restrict gemini-automted-issue-triage to only allow echo (#20047) 2026-02-23 18:24:34 +00:00
Sri Pasumarthi 3966f3c053 feat: Map tool kinds to explicit ACP.ToolKind values and update test … (#19547) 2026-02-23 18:22:05 +00:00
sinisterchill 2e3cbd6363 fix(core): prevent OAuth server crash on unexpected requests (#19668) 2026-02-23 18:03:31 +00:00
Adib234 8b1dc15182 fix(plan): allow plan mode writes on Windows and fix prompt paths (#19658) 2026-02-23 17:48:50 +00:00
owenofbrien fa9aee2bf0 Fix for silent failures in non-interactive mode (#19905) 2026-02-23 17:35:13 +00:00
Sam Roberts 6628cbb39d Updates command reference and /stats command. (#19794) 2026-02-23 17:13:24 +00:00
Sehoon Shon aa9163da60 feat(core): add policy chain support for Gemini 3.1 (#19991) 2026-02-23 15:13:48 +00:00
Sehoon Shon ec0f23ae03 fix(core): increase default retry attempts and add quota error backoff (#19949) 2026-02-23 15:13:34 +00:00
nityam ac04c388e0 Fix: Persist manual model selection on restart #19864 (#19891) 2026-02-23 03:44:00 +00:00
Abhi 621ddbe744 refactor(core): move session conversion logic to core (#19972) 2026-02-23 01:18:07 +00:00
Sehoon Shon c537fd5aec refactor(config): remove enablePromptCompletion from settings (#19974) 2026-02-22 19:10:20 -05:00
Shivangi Sharma a91bc60e18 fix(core): add uniqueness guard to edit tool (#19890)
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
2026-02-22 20:24:58 +00:00
Nick Salerni faa1ec3044 fix(core): prevent omission placeholder deletions in replace/write_file (#19870)
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
2026-02-22 19:58:31 +00:00
Bryan Morgan d96bd05d36 fix(core): allow any preview model in quota access check (#19867) 2026-02-22 12:53:24 +00:00
Adib234 84666e1bbc fix(plan): time share by approval mode dashboard reporting negative time shares (#19847) 2026-02-22 00:32:57 +00:00
N. Taylor Mullen a7d851146a feat(core): remove unnecessary login verbiage from Code Assist auth (#19861) 2026-02-21 21:55:11 +00:00
Abhi acb7f577de chore(lint): fix lint errors seen when running npm run lint (#19844) 2026-02-21 18:33:25 +00:00
Abhi d2d345f41a fix(cli): filter subagent sessions from resume history (#19698) 2026-02-21 17:41:27 +00:00
Christian Gunderman dfd7721e69 Disallow unsafe returns. (#19767) 2026-02-21 01:12:56 +00:00
matt korwel 09218572d0 refactor(core): remove unsafe type assertions in error utils (Phase 1.1) (#19750) 2026-02-21 01:00:57 +00:00
Christian Gunderman 5d98ed5820 Utilize pipelining of grep_search -> read_file to eliminate turns (#19574) 2026-02-21 00:36:10 +00:00
Jarrod Whelan 727f9b67b1 feat(cli): improve CTRL+O experience for both standard and alternate screen buffer (ASB) modes (#19010)
Co-authored-by: jacob314 <jacob314@gmail.com>
2026-02-21 00:26:11 +00:00
190 changed files with 5707 additions and 1191 deletions
@@ -155,7 +155,10 @@ jobs:
"telemetry": {
"enabled": true,
"target": "gcp"
}
},
"coreTools": [
"run_shell_command(echo)"
],
}
prompt: |-
## Role
+1 -1
View File
@@ -225,7 +225,7 @@ priority = 100
modes = ["plan"]
# Adjust the pattern to match your custom directory.
# This example matches any .md file in a .gemini/plans directory within the project.
argsPattern = "\"file_path\":\"[^\"]*/\\.gemini/plans/[a-zA-Z0-9_-]+\\.md\""
argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\""
```
[`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder
+10 -11
View File
@@ -22,17 +22,16 @@ they appear in the UI.
### General
| UI Label | Setting | Description | Default |
| ------------------------ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` |
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` |
| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` |
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `undefined` |
| UI Label | Setting | Description | Default |
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` |
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` |
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `undefined` |
### Output
+10
View File
@@ -64,6 +64,16 @@ and more.
To explore the power of Gemini CLI, see [Gemini CLI examples](./examples.md).
## Check usage and quota
You can check your current token usage and quota information using the
`/stats model` command. This command provides a snapshot of your current
session's token usage, as well as your overall quota and usage for the supported
models.
For more information on the `/stats` command and its subcommands, see the
[Command Reference](../../reference/commands.md#stats).
## Next steps
- Follow the [File management](../cli/tutorials/file-management.md) guide to
+54 -7
View File
@@ -32,6 +32,8 @@ Slash commands provide meta-level control over the CLI itself.
conversation state interactively, or resuming a previous state from a later
session.
- **Sub-commands:**
- **`debug`**
- **Description:** Export the most recent API request as a JSON payload.
- **`delete <tag>`**
- **Description:** Deletes a saved conversation checkpoint.
- **`list`**
@@ -128,8 +130,29 @@ Slash commands provide meta-level control over the CLI itself.
### `/extensions`
- **Description:** Lists all active extensions in the current Gemini CLI
session. See [Gemini CLI Extensions](../extensions/index.md).
- **Description:** Manage extensions. See
[Gemini CLI Extensions](../extensions/index.md).
- **Sub-commands:**
- **`config`**:
- **Description:** Configure extension settings.
- **`disable`**:
- **Description:** Disable an extension.
- **`enable`**:
- **Description:** Enable an extension.
- **`explore`**:
- **Description:** Open extensions page in your browser.
- **`install`**:
- **Description:** Install an extension from a git repo or local path.
- **`link`**:
- **Description:** Link an extension from a local path.
- **`list`**:
- **Description:** List active extensions.
- **`restart`**:
- **Description:** Restart all extensions.
- **`uninstall`**:
- **Description:** Uninstall an extension.
- **`update`**:
- **Description:** Update extensions. Usage: update <extension-names>|--all
### `/help` (or `/?`)
@@ -184,6 +207,10 @@ Slash commands provide meta-level control over the CLI itself.
servers that support OAuth authentication.
- **`desc`**
- **Description:** List configured MCP servers and tools with descriptions.
- **`disable`**
- **Description:** Disable an MCP server.
- **`enable`**
- **Description:** Enable a disabled MCP server.
- **`list`** or **`ls`**:
- **Description:** List configured MCP servers and tools. This is the
default action if no subcommand is specified.
@@ -221,7 +248,21 @@ Slash commands provide meta-level control over the CLI itself.
### `/model`
- **Description:** Opens a dialog to choose your Gemini model.
- **Description:** Manage model configuration.
- **Sub-commands:**
- **`manage`**:
- **Description:** Opens a dialog to configure the model.
- **`set`**:
- **Description:** Set the model to use.
- **Usage:** `/model set <model-name> [--persist]`
### `/permissions`
- **Description:** Manage folder trust settings and other permissions.
- **Sub-commands:**
- **`trust`**:
- **Description:** Manage folder trust settings.
- **Usage:** `/permissions trust [<directory-path>]`
### `/plan`
@@ -331,10 +372,16 @@ Slash commands provide meta-level control over the CLI itself.
### `/stats`
- **Description:** Display detailed statistics for the current Gemini CLI
session, including token usage, cached token savings (when available), and
session duration. Note: Cached token information is only displayed when cached
tokens are being used, which occurs with API key authentication but not with
OAuth authentication at this time.
session.
- **Sub-commands:**
- **`session`**:
- **Description:** Show session-specific usage statistics, including
duration, tool calls, and performance metrics. This is the default view.
- **`model`**:
- **Description:** Show model-specific usage statistics, including token
counts and quota information.
- **`tools`**:
- **Description:** Show tool-specific usage statistics.
### `/terminal-setup`
-6
View File
@@ -137,12 +137,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`general.enablePromptCompletion`** (boolean):
- **Description:** Enable AI-powered prompt completion suggestions while
typing.
- **Default:** `false`
- **Requires restart:** Yes
- **`general.retryFetchErrors`** (boolean):
- **Description:** Retry on "exception TypeError: fetch failed sending
request" errors.
+12 -5
View File
@@ -135,6 +135,18 @@ Flow video editor). These plans do not apply to the API usage which powers the
Gemini CLI. Supporting these plans is under active consideration for future
support.
## Check usage and quota
You can check your current token usage and quota information using the
`/stats model` command. This command provides a snapshot of your current
session's token usage, as well as your overall quota and usage for the supported
models.
For more information on the `/stats` command and its subcommands, see the
[Command Reference](../../reference/commands.md#stats).
A summary of model usage is also presented on exit at the end of a session.
## Tips to avoid high costs
When using a Pay as you Go API key, be mindful of your usage to avoid unexpected
@@ -151,8 +163,3 @@ costs.
models directly.
- Vertex AI: This is the enterprise-grade platform for building, deploying, and
managing Gemini models with specific security and control requirements.
## Understanding your usage
A summary of model usage is available through the `/stats` command and presented
on exit at the end of a session.
+1 -15
View File
@@ -72,14 +72,9 @@
"slug": "docs/extensions/index"
},
{ "label": "Headless mode", "slug": "docs/cli/headless" },
{ "label": "Help", "link": "/docs/reference/commands/#help-or" },
{ "label": "Hooks", "slug": "docs/hooks" },
{ "label": "IDE integration", "slug": "docs/ide-integration" },
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
{
"label": "Memory management",
"link": "/docs/reference/commands/#memory"
},
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
{ "label": "Model selection", "slug": "docs/cli/model" },
{ "label": "Plan mode", "badge": "🧪", "slug": "docs/cli/plan-mode" },
@@ -96,17 +91,8 @@
{ "label": "Rewind", "slug": "docs/cli/rewind" },
{ "label": "Sandboxing", "slug": "docs/cli/sandbox" },
{ "label": "Settings", "slug": "docs/cli/settings" },
{
"label": "Shell",
"link": "/docs/reference/commands/#shells-or-bashes"
},
{
"label": "Stats",
"link": "/docs/reference/commands/#stats"
},
{ "label": "Telemetry", "slug": "docs/cli/telemetry" },
{ "label": "Token caching", "slug": "docs/cli/token-caching" },
{ "label": "Tools", "link": "/docs/reference/commands/#tools" }
{ "label": "Token caching", "slug": "docs/cli/token-caching" }
]
},
{
+1
View File
@@ -201,6 +201,7 @@ export default tseslint.config(
rules: {
'@typescript-eslint/no-unsafe-type-assertion': 'error',
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-return': 'error',
},
},
{
+1
View File
@@ -17419,6 +17419,7 @@
"shell-quote": "^1.8.3",
"simple-git": "^3.28.0",
"strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1",
"systeminformation": "^5.25.11",
"tree-sitter-bash": "^0.25.0",
"undici": "^7.10.0",
+7 -5
View File
@@ -14,19 +14,21 @@ import {
type Mock,
} from 'vitest';
import { Task } from './task.js';
import type {
ToolCall,
Config,
ToolCallRequestInfo,
GitService,
CompletedToolCall,
} from '@google/gemini-cli-core';
import {
GeminiEventType,
type Config,
type ToolCallRequestInfo,
type GitService,
type CompletedToolCall,
ApprovalMode,
ToolConfirmationOutcome,
} from '@google/gemini-cli-core';
import { createMockConfig } from '../utils/testing_utils.js';
import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server';
import { CoderAgentEvent } from '../types.js';
import type { ToolCall } from '@google/gemini-cli-core';
const mockProcessRestorableToolCalls = vi.hoisted(() => vi.fn());
+7 -6
View File
@@ -13,6 +13,7 @@ import {
getAllMCPServerStatuses,
MCPServerStatus,
isNodeError,
getErrorMessage,
parseAndFormatApiError,
safeLiteralReplace,
DEFAULT_GUI_EDITOR,
@@ -30,8 +31,7 @@ import {
EDIT_TOOL_NAMES,
processRestorableToolCalls,
} from '@google/gemini-cli-core';
import type { RequestContext } from '@a2a-js/sdk/server';
import { type ExecutionEventBus } from '@a2a-js/sdk/server';
import type { RequestContext, ExecutionEventBus } from '@a2a-js/sdk/server';
import type {
TaskStatusUpdateEvent,
TaskArtifactUpdateEvent,
@@ -728,16 +728,17 @@ export class Task {
// Block scope for lexical declaration
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const errorEvent = event as ServerGeminiErrorEvent; // Type assertion
const errorMessage =
errorEvent.value?.error.message ?? 'Unknown error from LLM stream';
const errorMessage = errorEvent.value?.error
? getErrorMessage(errorEvent.value.error)
: 'Unknown error from LLM stream';
logger.error(
'[Task] Received error event from LLM stream:',
errorMessage,
);
let errMessage = `Unknown error from LLM stream: ${JSON.stringify(event)}`;
if (errorEvent.value) {
errMessage = parseAndFormatApiError(errorEvent.value);
if (errorEvent.value?.error) {
errMessage = parseAndFormatApiError(errorEvent.value.error);
}
this.cancelPendingTools(`LLM stream error: ${errorMessage}`);
this.setTaskStateAndPublishUpdate(
+5 -3
View File
@@ -8,17 +8,19 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as dotenv from 'dotenv';
import type { TelemetryTarget } from '@google/gemini-cli-core';
import type {
TelemetryTarget,
ConfigParameters,
ExtensionLoader,
} from '@google/gemini-cli-core';
import {
AuthType,
Config,
type ConfigParameters,
FileDiscoveryService,
ApprovalMode,
loadServerHierarchicalMemory,
GEMINI_DIR,
DEFAULT_GEMINI_EMBEDDING_MODEL,
type ExtensionLoader,
startupProfiler,
PREVIEW_GEMINI_MODEL,
homedir,
+1 -1
View File
@@ -147,7 +147,7 @@ function resolveEnvVarsInObject<T>(obj: T): T {
}
if (Array.isArray(obj)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return
return obj.map((item) => resolveEnvVarsInObject(item)) as unknown as T;
}
+4 -5
View File
@@ -4,12 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '@google/gemini-cli-core';
import {
GeminiEventType,
ApprovalMode,
type ToolCallConfirmationDetails,
import type {
Config,
ToolCallConfirmationDetails,
} from '@google/gemini-cli-core';
import { GeminiEventType, ApprovalMode } from '@google/gemini-cli-core';
import type {
TaskStatusUpdateEvent,
SendStreamingMessageSuccessResponse,
+16 -1
View File
@@ -36,7 +36,21 @@ process.on('uncaughtException', (error) => {
});
main().catch(async (error) => {
await runExitCleanup();
// Set a timeout to force exit if cleanup hangs
const cleanupTimeout = setTimeout(() => {
writeToStderr('Cleanup timed out, forcing exit...\n');
process.exit(1);
}, 5000);
try {
await runExitCleanup();
} catch (cleanupError) {
writeToStderr(
`Error during final cleanup: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}\n`,
);
} finally {
clearTimeout(cleanupTimeout);
}
if (error instanceof FatalError) {
let errorMessage = error.message;
@@ -46,6 +60,7 @@ main().catch(async (error) => {
writeToStderr(errorMessage + '\n');
process.exit(error.exitCode);
}
writeToStderr('An unexpected critical error occurred:');
if (error instanceof Error) {
writeToStderr(error.stack + '\n');
@@ -47,6 +47,7 @@ const defaultRequestConfirmation: RequestConfirmationCallback = async (
message,
initial: false,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return response.confirm;
};
-1
View File
@@ -848,7 +848,6 @@ export async function loadCliConfig(
enableShellOutputEfficiency:
settings.tools?.shell?.enableShellOutputEfficiency ?? true,
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
enablePromptCompletion: settings.general?.enablePromptCompletion,
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
eventEmitter: coreEvents,
useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos,
@@ -83,6 +83,7 @@ export class ExtensionRegistryClient {
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const results = await fzf.find(query);
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return results.map((r: { item: RegistryExtension }) => r.item);
}
@@ -179,6 +179,7 @@ export class ExtensionEnablementManager {
readConfig(): AllExtensionsEnablementConfig {
try {
const content = fs.readFileSync(this.configFilePath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return JSON.parse(content);
} catch (error) {
if (
@@ -156,6 +156,7 @@ export async function promptForSetting(
name: 'value',
message: `${setting.name}\n${setting.description}`,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return response.value;
}
@@ -58,6 +58,7 @@ export function recursivelyHydrateStrings<T>(
if (Array.isArray(obj)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return obj.map((item) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
recursivelyHydrateStrings(item, values),
) as unknown as T;
}
@@ -339,6 +339,8 @@ describe('Policy Engine Integration Tests', () => {
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/session-1/plans/my-plan.md',
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/session-1/plans/feature_auth.md',
'/home/user/.gemini/tmp/new-temp_dir_123/session-1/plans/plan.md', // new style of temp directory
'C:\\Users\\user\\.gemini\\tmp\\project-id\\session-id\\plans\\plan.md',
'D:\\gemini-cli\\.gemini\\tmp\\project-id\\session-1\\plans\\plan.md', // no session ID
];
for (const file_path of validPaths) {
@@ -364,7 +366,8 @@ describe('Policy Engine Integration Tests', () => {
const invalidPaths = [
'/project/src/file.ts', // Workspace
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/script.js', // Wrong extension
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md', // Path traversal
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md', // Path traversal (Unix)
'C:\\Users\\user\\.gemini\\tmp\\id\\session\\plans\\..\\..\\..\\Windows\\System32\\config\\SAM', // Path traversal (Windows)
'/home/user/.gemini/non-tmp/new-temp_dir_123/plans/plan.md', // outside of temp dir
];
-10
View File
@@ -287,16 +287,6 @@ const SETTINGS_SCHEMA = {
},
},
},
enablePromptCompletion: {
type: 'boolean',
label: 'Enable Prompt Completion',
category: 'General',
requiresRestart: true,
default: false,
description:
'Enable AI-powered prompt completion suggestions while typing.',
showInDialog: true,
},
retryFetchErrors: {
type: 'boolean',
label: 'Retry Fetch Errors',
@@ -131,7 +131,6 @@ describe('Settings Repro', () => {
},
general: {
debugKeystrokeLogging: false,
enablePromptCompletion: false,
preferredEditor: 'vim',
vimMode: false,
},
+14 -11
View File
@@ -102,6 +102,7 @@ import { createPolicyUpdater } from './config/policy.js';
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
import { TerminalProvider } from './ui/contexts/TerminalContext.js';
import { OverflowProvider } from './ui/contexts/OverflowContext.js';
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
import { profiler } from './ui/components/DebugProfiler.js';
@@ -238,17 +239,19 @@ export async function startInteractiveUI(
>
<TerminalProvider>
<ScrollProvider>
<SessionStatsProvider>
<VimModeProvider settings={settings}>
<AppContainer
config={config}
startupWarnings={startupWarnings}
version={version}
resumedSessionData={resumedSessionData}
initializationResult={initializationResult}
/>
</VimModeProvider>
</SessionStatsProvider>
<OverflowProvider>
<SessionStatsProvider>
<VimModeProvider settings={settings}>
<AppContainer
config={config}
startupWarnings={startupWarnings}
version={version}
resumedSessionData={resumedSessionData}
initializationResult={initializationResult}
/>
</VimModeProvider>
</SessionStatsProvider>
</OverflowProvider>
</ScrollProvider>
</TerminalProvider>
</MouseProvider>
+3 -3
View File
@@ -13,6 +13,7 @@ import type {
import { isSlashCommand } from './ui/utils/commandUtils.js';
import type { LoadedSettings } from './config/settings.js';
import {
convertSessionToClientHistory,
GeminiEventType,
FatalInputError,
promptIdContext,
@@ -35,7 +36,6 @@ import type { Content, Part } from '@google/genai';
import readline from 'node:readline';
import stripAnsi from 'strip-ansi';
import { convertSessionToHistoryFormats } from './ui/hooks/useSessionBrowser.js';
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
@@ -220,9 +220,9 @@ export async function runNonInteractive({
// Initialize chat. Resume if resume data is passed.
if (resumedSessionData) {
await geminiClient.resumeChat(
convertSessionToHistoryFormats(
convertSessionToClientHistory(
resumedSessionData.conversation.messages,
).clientHistory,
),
resumedSessionData,
);
}
@@ -121,5 +121,6 @@ export const createMockCommandContext = (
return output;
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return merge(defaultMocks, overrides);
};
@@ -128,7 +128,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000),
getShellExecutionConfig: vi.fn().mockReturnValue({}),
setShellExecutionConfig: vi.fn(),
getEnablePromptCompletion: vi.fn().mockReturnValue(false),
getEnableToolOutputTruncation: vi.fn().mockReturnValue(true),
getTruncateToolOutputThreshold: vi.fn().mockReturnValue(1000),
getTruncateToolOutputLines: vi.fn().mockReturnValue(100),
+60 -29
View File
@@ -35,6 +35,13 @@ import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js';
import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js';
import { AskUserActionsProvider } from '../ui/contexts/AskUserActionsContext.js';
import { TerminalProvider } from '../ui/contexts/TerminalContext.js';
import {
OverflowProvider,
useOverflowActions,
useOverflowState,
type OverflowActions,
type OverflowState,
} from '../ui/contexts/OverflowContext.js';
import { makeFakeConfig, type Config } from '@google/gemini-cli-core';
import { FakePersistentState } from './persistentStateFake.js';
@@ -335,6 +342,8 @@ export type RenderInstance = {
lastFrame: (options?: { allowEmpty?: boolean }) => string;
terminal: Terminal;
waitUntilReady: () => Promise<void>;
capturedOverflowState: OverflowState | undefined;
capturedOverflowActions: OverflowActions | undefined;
};
const instances: InkInstance[] = [];
@@ -343,7 +352,10 @@ const instances: InkInstance[] = [];
export const render = (
tree: React.ReactElement,
terminalWidth?: number,
): RenderInstance => {
): Omit<
RenderInstance,
'capturedOverflowState' | 'capturedOverflowActions'
> => {
const cols = terminalWidth ?? 100;
// We use 1000 rows to avoid windows with incorrect snapshots if a correct
// value was used (e.g. 40 rows). The alternatives to make things worse are
@@ -562,6 +574,16 @@ const mockUIActions: UIActions = {
handleNewAgentsSelect: vi.fn(),
};
let capturedOverflowState: OverflowState | undefined;
let capturedOverflowActions: OverflowActions | undefined;
const ContextCapture: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
capturedOverflowState = useOverflowState();
capturedOverflowActions = useOverflowActions();
return <>{children}</>;
};
export const renderWithProviders = (
component: React.ReactElement,
{
@@ -663,6 +685,9 @@ export const renderWithProviders = (
.filter((item): item is HistoryItemToolGroup => item.type === 'tool_group')
.flatMap((item) => item.tools);
capturedOverflowState = undefined;
capturedOverflowActions = undefined;
const renderResult = render(
<AppContext.Provider value={appState}>
<ConfigContext.Provider value={config}>
@@ -675,35 +700,39 @@ export const renderWithProviders = (
value={finalUiState.streamingState}
>
<UIActionsContext.Provider value={finalUIActions}>
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
>
<AskUserActionsProvider
request={null}
onSubmit={vi.fn()}
onCancel={vi.fn()}
<OverflowProvider>
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
>
<KeypressProvider>
<MouseProvider
mouseEventsEnabled={mouseEventsEnabled}
>
<TerminalProvider>
<ScrollProvider>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{component}
</Box>
</ScrollProvider>
</TerminalProvider>
</MouseProvider>
</KeypressProvider>
</AskUserActionsProvider>
</ToolActionsProvider>
<AskUserActionsProvider
request={null}
onSubmit={vi.fn()}
onCancel={vi.fn()}
>
<KeypressProvider>
<MouseProvider
mouseEventsEnabled={mouseEventsEnabled}
>
<TerminalProvider>
<ScrollProvider>
<ContextCapture>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{component}
</Box>
</ContextCapture>
</ScrollProvider>
</TerminalProvider>
</MouseProvider>
</KeypressProvider>
</AskUserActionsProvider>
</ToolActionsProvider>
</OverflowProvider>
</UIActionsContext.Provider>
</StreamingContext.Provider>
</SessionStatsProvider>
@@ -718,6 +747,8 @@ export const renderWithProviders = (
return {
...renderResult,
capturedOverflowState,
capturedOverflowActions,
simulateClick: (col: number, row: number, button?: 0 | 1 | 2) =>
simulateClick(renderResult.stdin, col, row, button),
};
+328 -14
View File
@@ -105,6 +105,11 @@ import {
type UIActions,
} from './contexts/UIActionsContext.js';
import { KeypressProvider } from './contexts/KeypressContext.js';
import { OverflowProvider } from './contexts/OverflowContext.js';
import {
useOverflowActions,
type OverflowActions,
} from './contexts/OverflowContext.js';
// Mock useStdout to capture terminal title writes
vi.mock('ink', async (importOriginal) => {
@@ -120,9 +125,11 @@ vi.mock('ink', async (importOriginal) => {
// so we can assert against them in our tests.
let capturedUIState: UIState;
let capturedUIActions: UIActions;
let capturedOverflowActions: OverflowActions;
function TestContextConsumer() {
capturedUIState = useContext(UIStateContext)!;
capturedUIActions = useContext(UIActionsContext)!;
capturedOverflowActions = useOverflowActions()!;
return null;
}
@@ -229,7 +236,10 @@ import {
disableMouseEvents,
} from '@google/gemini-cli-core';
import { type ExtensionManager } from '../config/extension-manager.js';
import { WARNING_PROMPT_DURATION_MS } from './constants.js';
import {
WARNING_PROMPT_DURATION_MS,
EXPAND_HINT_DURATION_MS,
} from './constants.js';
describe('AppContainer State Management', () => {
let mockConfig: Config;
@@ -255,13 +265,15 @@ describe('AppContainer State Management', () => {
} = {}) => (
<SettingsContext.Provider value={settings}>
<KeypressProvider config={config}>
<AppContainer
config={config}
version={version}
initializationResult={initResult}
startupWarnings={startupWarnings}
resumedSessionData={resumedSessionData}
/>
<OverflowProvider>
<AppContainer
config={config}
version={version}
initializationResult={initResult}
startupWarnings={startupWarnings}
resumedSessionData={resumedSessionData}
/>
</OverflowProvider>
</KeypressProvider>
</SettingsContext.Provider>
);
@@ -2687,12 +2699,14 @@ describe('AppContainer State Management', () => {
const getTree = (settings: LoadedSettings) => (
<SettingsContext.Provider value={settings}>
<KeypressProvider config={mockConfig}>
<AppContainer
config={mockConfig}
version="1.0.0"
initializationResult={mockInitResult}
/>
<TestChild />
<OverflowProvider>
<AppContainer
config={mockConfig}
version="1.0.0"
initializationResult={mockInitResult}
/>
<TestChild />
</OverflowProvider>
</KeypressProvider>
</SettingsContext.Provider>
);
@@ -3303,6 +3317,306 @@ describe('AppContainer State Management', () => {
});
});
describe('Submission Handling', () => {
it('resets expansion state on submission when not in alternate buffer', async () => {
const { checkPermissions } = await import(
'./hooks/atCommandProcessor.js'
);
vi.mocked(checkPermissions).mockResolvedValue([]);
let unmount: () => void;
await act(async () => {
unmount = renderAppContainer({
settings: {
...mockSettings,
merged: {
...mockSettings.merged,
ui: { ...mockSettings.merged.ui, useAlternateBuffer: false },
},
} as LoadedSettings,
}).unmount;
});
await waitFor(() => expect(capturedUIActions).toBeTruthy());
// Expand first
act(() => capturedUIActions.setConstrainHeight(false));
expect(capturedUIState.constrainHeight).toBe(false);
// Reset mock stdout to clear any initial writes
mocks.mockStdout.write.mockClear();
// Submit
await act(async () => capturedUIActions.handleFinalSubmit('test prompt'));
// Should be reset
expect(capturedUIState.constrainHeight).toBe(true);
// Should refresh static (which clears terminal in non-alternate buffer)
expect(mocks.mockStdout.write).toHaveBeenCalledWith(
ansiEscapes.clearTerminal,
);
unmount!();
});
it('resets expansion state on submission when in alternate buffer without clearing terminal', async () => {
const { checkPermissions } = await import(
'./hooks/atCommandProcessor.js'
);
vi.mocked(checkPermissions).mockResolvedValue([]);
let unmount: () => void;
await act(async () => {
unmount = renderAppContainer({
settings: {
...mockSettings,
merged: {
...mockSettings.merged,
ui: { ...mockSettings.merged.ui, useAlternateBuffer: true },
},
} as LoadedSettings,
}).unmount;
});
await waitFor(() => expect(capturedUIActions).toBeTruthy());
// Expand first
act(() => capturedUIActions.setConstrainHeight(false));
expect(capturedUIState.constrainHeight).toBe(false);
// Reset mock stdout
mocks.mockStdout.write.mockClear();
// Submit
await act(async () => capturedUIActions.handleFinalSubmit('test prompt'));
// Should be reset
expect(capturedUIState.constrainHeight).toBe(true);
// Should NOT refresh static's clearTerminal in alternate buffer
expect(mocks.mockStdout.write).not.toHaveBeenCalledWith(
ansiEscapes.clearTerminal,
);
unmount!();
});
});
describe('Overflow Hint Handling', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('sets showIsExpandableHint when overflow occurs in Standard Mode and hides after 10s', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
// Trigger overflow
act(() => {
capturedOverflowActions.addOverflowingId('test-id');
});
await waitFor(() => {
// Should show hint because we are in Standard Mode (default settings) and have overflow
expect(capturedUIState.showIsExpandableHint).toBe(true);
});
// Advance just before the timeout
act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100);
});
expect(capturedUIState.showIsExpandableHint).toBe(true);
// Advance to hit the timeout mark
act(() => {
vi.advanceTimersByTime(100);
});
await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(false);
});
unmount!();
});
it('toggles expansion state and resets the hint timer when Ctrl+O is pressed in Standard Mode', async () => {
let unmount: () => void;
let stdin: ReturnType<typeof renderAppContainer>['stdin'];
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
stdin = result.stdin;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
// Initial state is constrainHeight = true
expect(capturedUIState.constrainHeight).toBe(true);
// Trigger overflow so the hint starts showing
act(() => {
capturedOverflowActions.addOverflowingId('test-id');
});
await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(true);
});
// Advance half the duration
act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2);
});
expect(capturedUIState.showIsExpandableHint).toBe(true);
// Simulate Ctrl+O
act(() => {
stdin.write('\x0f'); // \x0f is Ctrl+O
});
await waitFor(() => {
// constrainHeight should toggle
expect(capturedUIState.constrainHeight).toBe(false);
});
// Advance enough that the original timer would have expired if it hadn't reset
act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 + 1000);
});
// We expect it to still be true because Ctrl+O should have reset the timer
expect(capturedUIState.showIsExpandableHint).toBe(true);
// Advance remaining time to reach the new timeout
act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 - 1000);
});
await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(false);
});
unmount!();
});
it('toggles Ctrl+O multiple times and verifies the hint disappears exactly after the last toggle', async () => {
let unmount: () => void;
let stdin: ReturnType<typeof renderAppContainer>['stdin'];
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
stdin = result.stdin;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
// Initial state is constrainHeight = true
expect(capturedUIState.constrainHeight).toBe(true);
// Trigger overflow so the hint starts showing
act(() => {
capturedOverflowActions.addOverflowingId('test-id');
});
await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(true);
});
// Advance half the duration
act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2);
});
expect(capturedUIState.showIsExpandableHint).toBe(true);
// First toggle 'on' (expanded)
act(() => {
stdin.write('\x0f'); // Ctrl+O
});
await waitFor(() => {
expect(capturedUIState.constrainHeight).toBe(false);
});
// Wait 1 second
act(() => {
vi.advanceTimersByTime(1000);
});
expect(capturedUIState.showIsExpandableHint).toBe(true);
// Second toggle 'off' (collapsed)
act(() => {
stdin.write('\x0f'); // Ctrl+O
});
await waitFor(() => {
expect(capturedUIState.constrainHeight).toBe(true);
});
// Wait 1 second
act(() => {
vi.advanceTimersByTime(1000);
});
expect(capturedUIState.showIsExpandableHint).toBe(true);
// Third toggle 'on' (expanded)
act(() => {
stdin.write('\x0f'); // Ctrl+O
});
await waitFor(() => {
expect(capturedUIState.constrainHeight).toBe(false);
});
// Now we wait just before the timeout from the LAST toggle.
// It should still be true.
act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100);
});
expect(capturedUIState.showIsExpandableHint).toBe(true);
// Wait 0.1s more to hit exactly the timeout since the last toggle.
// It should hide now.
act(() => {
vi.advanceTimersByTime(100);
});
await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(false);
});
unmount!();
});
it('does NOT set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => {
const alternateSettings = mergeSettings({}, {}, {}, {}, true);
const settingsWithAlternateBuffer = {
merged: {
...alternateSettings,
ui: {
...alternateSettings.ui,
useAlternateBuffer: true,
},
},
} as unknown as LoadedSettings;
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({
settings: settingsWithAlternateBuffer,
});
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
// Trigger overflow
act(() => {
capturedOverflowActions.addOverflowingId('test-id');
});
// Should NOT show hint because we are in Alternate Buffer Mode
expect(capturedUIState.showIsExpandableHint).toBe(false);
unmount!();
});
});
describe('Permission Handling', () => {
it('shows permission dialog when checkPermissions returns paths', async () => {
const { checkPermissions } = await import(
+112 -2
View File
@@ -95,6 +95,10 @@ import { useSettingsCommand } from './hooks/useSettingsCommand.js';
import { useModelCommand } from './hooks/useModelCommand.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
import { useVimMode } from './contexts/VimModeContext.js';
import {
useOverflowActions,
useOverflowState,
} from './contexts/OverflowContext.js';
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
import { useTerminalSize } from './hooks/useTerminalSize.js';
import { calculatePromptWidths } from './components/InputPrompt.js';
@@ -151,6 +155,7 @@ import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js'
import {
WARNING_PROMPT_DURATION_MS,
QUEUE_ERROR_DISPLAY_DURATION_MS,
EXPAND_HINT_DURATION_MS,
} from './constants.js';
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
@@ -214,6 +219,7 @@ const SHELL_HEIGHT_PADDING = 10;
export const AppContainer = (props: AppContainerProps) => {
const { config, initializationResult, resumedSessionData } = props;
const settings = useSettings();
const { reset } = useOverflowActions()!;
const notificationsEnabled = isNotificationsEnabled(settings);
const historyManager = useHistory({
@@ -262,6 +268,54 @@ export const AppContainer = (props: AppContainerProps) => {
);
const [newAgents, setNewAgents] = useState<AgentDefinition[] | null>(null);
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
const [showIsExpandableHint, setShowIsExpandableHint] = useState(false);
const expandHintTimerRef = useRef<NodeJS.Timeout | null>(null);
const overflowState = useOverflowState();
const overflowingIdsSize = overflowState?.overflowingIds.size ?? 0;
const hasOverflowState = overflowingIdsSize > 0 || !constrainHeight;
/**
* Manages the visibility and x-second timer for the expansion hint.
*
* This effect triggers the timer countdown whenever an overflow is detected
* or the user manually toggles the expansion state with Ctrl+O. We use a stable
* boolean dependency (hasOverflowState) to ensure the timer only resets on
* genuine state transitions, preventing it from infinitely resetting during
* active text streaming.
*/
useEffect(() => {
if (isAlternateBuffer) {
setShowIsExpandableHint(false);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
return;
}
if (hasOverflowState) {
setShowIsExpandableHint(true);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
expandHintTimerRef.current = setTimeout(() => {
setShowIsExpandableHint(false);
}, EXPAND_HINT_DURATION_MS);
}
}, [hasOverflowState, isAlternateBuffer, constrainHeight]);
/**
* Safe cleanup to ensure the expansion hint timer is cancelled when the
* component unmounts, preventing memory leaks.
*/
useEffect(
() => () => {
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
},
[],
);
const [defaultBannerText, setDefaultBannerText] = useState('');
const [warningBannerText, setWarningBannerText] = useState('');
@@ -1189,6 +1243,19 @@ Logging in with Google... Restarting Gemini CLI to continue.
const handleFinalSubmit = useCallback(
async (submittedValue: string) => {
reset();
// Explicitly hide the expansion hint and clear its x-second timer when a new turn begins.
setShowIsExpandableHint(false);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
if (!constrainHeight) {
setConstrainHeight(true);
if (!isAlternateBuffer) {
refreshStatic();
}
}
const isSlash = isSlashCommand(submittedValue.trim());
const isIdle = streamingState === StreamingState.Idle;
const isAgentRunning =
@@ -1247,15 +1314,32 @@ Logging in with Google... Restarting Gemini CLI to continue.
pendingSlashCommandHistoryItems,
pendingGeminiHistoryItems,
config,
constrainHeight,
setConstrainHeight,
isAlternateBuffer,
refreshStatic,
reset,
handleHintSubmit,
],
);
const handleClearScreen = useCallback(() => {
reset();
// Explicitly hide the expansion hint and clear its x-second timer when clearing the screen.
setShowIsExpandableHint(false);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
historyManager.clearItems();
clearConsoleMessagesState();
refreshStatic();
}, [historyManager, clearConsoleMessagesState, refreshStatic]);
}, [
historyManager,
clearConsoleMessagesState,
refreshStatic,
reset,
setShowIsExpandableHint,
]);
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
@@ -1425,7 +1509,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
windowMs: WARNING_PROMPT_DURATION_MS,
onRepeat: handleExitRepeat,
});
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
const [ideContextState, setIdeContextState] = useState<
IdeContext | undefined
>();
@@ -1655,6 +1739,19 @@ Logging in with Google... Restarting Gemini CLI to continue.
if (!constrainHeight) {
enteringConstrainHeightMode = true;
setConstrainHeight(true);
if (keyMatchers[Command.SHOW_MORE_LINES](key)) {
// If the user manually collapses the view, show the hint and reset the x-second timer.
setShowIsExpandableHint(true);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
expandHintTimerRef.current = setTimeout(() => {
setShowIsExpandableHint(false);
}, EXPAND_HINT_DURATION_MS);
}
if (!isAlternateBuffer) {
refreshStatic();
}
}
if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) {
@@ -1698,6 +1795,17 @@ Logging in with Google... Restarting Gemini CLI to continue.
!enteringConstrainHeightMode
) {
setConstrainHeight(false);
// If the user manually expands the view, show the hint and reset the x-second timer.
setShowIsExpandableHint(true);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
expandHintTimerRef.current = setTimeout(() => {
setShowIsExpandableHint(false);
}, EXPAND_HINT_DURATION_MS);
if (!isAlternateBuffer) {
refreshStatic();
}
return true;
} else if (
(keyMatchers[Command.FOCUS_SHELL_INPUT](key) ||
@@ -2218,6 +2326,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isBackgroundShellListOpen,
adminSettingsChanged,
newAgents,
showIsExpandableHint,
hintMode:
config.isModelSteeringEnabled() &&
isToolExecuting([
@@ -2344,6 +2453,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
backgroundShells,
adminSettingsChanged,
newAgents,
showIsExpandableHint,
],
);
@@ -23,6 +23,7 @@ import {
RewindEvent,
type ChatRecordingService,
type GeminiClient,
convertSessionToClientHistory,
} from '@google/gemini-cli-core';
/**
@@ -54,9 +55,8 @@ async function rewindConversation(
}
// Convert to UI and Client formats
const { uiHistory, clientHistory } = convertSessionToHistoryFormats(
conversation.messages,
);
const { uiHistory } = convertSessionToHistoryFormats(conversation.messages);
const clientHistory = convertSessionToClientHistory(conversation.messages);
client.setHistory(clientHistory as Content[]);
@@ -173,7 +173,9 @@ describe('FolderTrustDialog', () => {
// Initial state: truncated
await waitFor(() => {
expect(lastFrame()).toContain('Do you trust the files in this folder?');
expect(lastFrame()).toContain('Press ctrl-o to show more lines');
// In standard terminal mode, the expansion hint is handled globally by ToastDisplay
// via AppContainer, so it should not be present in the dialog's local frame.
expect(lastFrame()).not.toContain('Press Ctrl+O');
expect(lastFrame()).toContain('hidden');
});
@@ -285,33 +285,37 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
);
};
return (
<OverflowProvider>
<Box flexDirection="column" width="100%">
<Box flexDirection="column" marginLeft={1} marginRight={1}>
{renderContent()}
</Box>
<Box paddingX={2} marginBottom={1}>
<ShowMoreLines constrainHeight={constrainHeight} />
</Box>
{isRestarting && (
<Box marginLeft={1} marginTop={1}>
<Text color={theme.status.warning}>
Gemini CLI is restarting to apply the trust changes...
</Text>
</Box>
)}
{exiting && (
<Box marginLeft={1} marginTop={1}>
<Text color={theme.status.warning}>
A folder trust level must be selected to continue. Exiting since
escape was pressed.
</Text>
</Box>
)}
const content = (
<Box flexDirection="column" width="100%">
<Box flexDirection="column" marginLeft={1} marginRight={1}>
{renderContent()}
</Box>
</OverflowProvider>
<Box paddingX={2} marginBottom={1}>
<ShowMoreLines constrainHeight={constrainHeight} />
</Box>
{isRestarting && (
<Box marginLeft={1} marginTop={1}>
<Text color={theme.status.warning}>
Gemini CLI is restarting to apply the trust changes...
</Text>
</Box>
)}
{exiting && (
<Box marginLeft={1} marginTop={1}>
<Text color={theme.status.warning}>
A folder trust level must be selected to continue. Exiting since
escape was pressed.
</Text>
</Box>
)}
</Box>
);
return isAlternateBuffer ? (
<OverflowProvider>{content}</OverflowProvider>
) : (
content
);
};
@@ -46,6 +46,7 @@ interface HistoryItemDisplayProps {
isPending: boolean;
commands?: readonly SlashCommand[];
availableTerminalHeightGemini?: number;
isExpandable?: boolean;
}
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
@@ -55,6 +56,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
isPending,
commands,
availableTerminalHeightGemini,
isExpandable,
}) => {
const settings = useSettings();
const inlineThinkingMode = getInlineThinkingMode(settings);
@@ -180,6 +182,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
terminalWidth={terminalWidth}
borderTop={itemForDisplay.borderTop}
borderBottom={itemForDisplay.borderBottom}
isExpandable={isExpandable}
/>
)}
{itemForDisplay.type === 'compression' && (
@@ -411,6 +411,73 @@ describe('InputPrompt', () => {
unmount();
});
it('should submit command in shell mode when Enter pressed with suggestions visible but no arrow navigation', async () => {
props.shellModeActive = true;
props.buffer.setText('ls ');
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [
{ label: 'dir1', value: 'dir1' },
{ label: 'dir2', value: 'dir2' },
],
activeSuggestionIndex: 0,
});
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
// Press Enter without navigating — should dismiss suggestions and fall
// through to the main submit handler.
await act(async () => {
stdin.write('\r');
});
await waitFor(() => {
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
expect(props.onSubmit).toHaveBeenCalledWith('ls'); // Assert fall-through (text is trimmed)
});
expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled();
unmount();
});
it('should accept suggestion in shell mode when Enter pressed after arrow navigation', async () => {
props.shellModeActive = true;
props.buffer.setText('ls ');
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [
{ label: 'dir1', value: 'dir1' },
{ label: 'dir2', value: 'dir2' },
],
activeSuggestionIndex: 1,
});
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
// Press ArrowDown to navigate, then Enter to accept
await act(async () => {
stdin.write('\u001B[B'); // ArrowDown — sets hasUserNavigatedSuggestions
});
await waitFor(() =>
expect(mockCommandCompletion.navigateDown).toHaveBeenCalled(),
);
await act(async () => {
stdin.write('\r'); // Enter — should accept navigated suggestion
});
await waitFor(() => {
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(1);
});
expect(props.onSubmit).not.toHaveBeenCalled();
unmount();
});
it('should NOT call shell history methods when not in shell mode', async () => {
props.buffer.setText('some text');
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
+34 -3
View File
@@ -259,6 +259,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
>(null);
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const innerBoxRef = useRef<DOMElement>(null);
const hasUserNavigatedSuggestions = useRef(false);
const [reverseSearchActive, setReverseSearchActive] = useState(false);
const [commandSearchActive, setCommandSearchActive] = useState(false);
@@ -615,6 +616,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setSuppressCompletion(
isHistoryNav || isCursorMovement || keyMatchers[Command.ESCAPE](key),
);
hasUserNavigatedSuggestions.current = false;
}
// TODO(jacobr): this special case is likely not needed anymore.
@@ -648,7 +650,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
Boolean(completion.promptCompletion.text) ||
reverseSearchActive ||
commandSearchActive;
if (isPlainTab) {
if (isPlainTab && shellModeActive) {
resetPlainTabPress();
if (!completion.showSuggestions) {
setSuppressCompletion(false);
}
} else if (isPlainTab) {
if (!hasTabCompletionInteraction) {
if (registerPlainTabPress() === 2) {
toggleCleanUiDetailsVisible();
@@ -892,7 +900,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
completion.isPerfectMatch &&
keyMatchers[Command.SUBMIT](key) &&
recentUnsafePasteTime === null &&
(!completion.showSuggestions || completion.activeSuggestionIndex <= 0)
(!completion.showSuggestions ||
(completion.activeSuggestionIndex <= 0 &&
!hasUserNavigatedSuggestions.current))
) {
handleSubmit(buffer.text);
return true;
@@ -908,11 +918,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (completion.suggestions.length > 1) {
if (keyMatchers[Command.COMPLETION_UP](key)) {
completion.navigateUp();
hasUserNavigatedSuggestions.current = true;
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
return true;
}
if (keyMatchers[Command.COMPLETION_DOWN](key)) {
completion.navigateDown();
hasUserNavigatedSuggestions.current = true;
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
return true;
}
@@ -930,6 +942,24 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const isEnterKey = key.name === 'return' && !key.ctrl;
if (isEnterKey && shellModeActive) {
if (hasUserNavigatedSuggestions.current) {
completion.handleAutocomplete(
completion.activeSuggestionIndex,
);
setExpandedSuggestionIndex(-1);
hasUserNavigatedSuggestions.current = false;
return true;
}
completion.resetCompletionState();
setExpandedSuggestionIndex(-1);
hasUserNavigatedSuggestions.current = false;
if (buffer.text.trim()) {
handleSubmit(buffer.text);
}
return true;
}
if (isEnterKey && buffer.text.startsWith('/')) {
const { isArgumentCompletion, leafCommand } =
completion.slashCompletionRange;
@@ -1386,7 +1416,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
scrollOffset={activeCompletion.visibleStartIndex}
userInput={buffer.text}
mode={
completion.completionMode === CompletionMode.AT
completion.completionMode === CompletionMode.AT ||
completion.completionMode === CompletionMode.SHELL
? 'reverse'
: buffer.text.startsWith('/') &&
!reverseSearchActive &&
@@ -493,7 +493,8 @@ describe('MainContent', () => {
isAlternateBuffer: true,
embeddedShellFocused: true,
constrainHeight: true,
shouldShowLine1: true,
shouldShowLine1: false,
staticAreaMaxItemHeight: 15,
},
{
name: 'ASB mode - Unfocused shell',
@@ -501,6 +502,7 @@ describe('MainContent', () => {
embeddedShellFocused: false,
constrainHeight: true,
shouldShowLine1: false,
staticAreaMaxItemHeight: 15,
},
{
name: 'Normal mode - Constrained height',
@@ -508,13 +510,15 @@ describe('MainContent', () => {
embeddedShellFocused: false,
constrainHeight: true,
shouldShowLine1: false,
staticAreaMaxItemHeight: 15,
},
{
name: 'Normal mode - Unconstrained height',
isAlternateBuffer: false,
embeddedShellFocused: false,
constrainHeight: false,
shouldShowLine1: false,
shouldShowLine1: true,
staticAreaMaxItemHeight: 15,
},
];
@@ -525,6 +529,7 @@ describe('MainContent', () => {
embeddedShellFocused,
constrainHeight,
shouldShowLine1,
staticAreaMaxItemHeight,
}) => {
vi.mocked(useAlternateBuffer).mockReturnValue(isAlternateBuffer);
const ptyId = 123;
@@ -554,6 +559,7 @@ describe('MainContent', () => {
},
],
availableTerminalHeight: 30, // In ASB mode, focused shell should get ~28 lines
staticAreaMaxItemHeight,
terminalHeight: 50,
terminalWidth: 100,
mainAreaWidth: 100,
+60 -22
View File
@@ -47,32 +47,61 @@ export const MainContent = () => {
pendingHistoryItems,
mainAreaWidth,
staticAreaMaxItemHeight,
availableTerminalHeight,
cleanUiDetailsVisible,
} = uiState;
const showHeaderDetails = cleanUiDetailsVisible;
const lastUserPromptIndex = useMemo(() => {
for (let i = uiState.history.length - 1; i >= 0; i--) {
const type = uiState.history[i].type;
if (type === 'user' || type === 'user_shell') {
return i;
}
}
return -1;
}, [uiState.history]);
const historyItems = useMemo(
() =>
uiState.history.map((h) => (
<MemoizedHistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={staticAreaMaxItemHeight}
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={h.id}
item={h}
isPending={false}
commands={uiState.slashCommands}
/>
)),
uiState.history.map((h, index) => {
const isExpandable = index > lastUserPromptIndex;
return (
<MemoizedHistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={
uiState.constrainHeight || !isExpandable
? staticAreaMaxItemHeight
: undefined
}
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={h.id}
item={h}
isPending={false}
commands={uiState.slashCommands}
isExpandable={isExpandable}
/>
);
}),
[
uiState.history,
mainAreaWidth,
staticAreaMaxItemHeight,
uiState.slashCommands,
uiState.constrainHeight,
lastUserPromptIndex,
],
);
const staticHistoryItems = useMemo(
() => historyItems.slice(0, lastUserPromptIndex + 1),
[historyItems, lastUserPromptIndex],
);
const lastResponseHistoryItems = useMemo(
() => historyItems.slice(lastUserPromptIndex + 1),
[historyItems, lastUserPromptIndex],
);
const pendingItems = useMemo(
() => (
<Box flexDirection="column">
@@ -80,14 +109,12 @@ export const MainContent = () => {
<HistoryItemDisplay
key={i}
availableTerminalHeight={
(uiState.constrainHeight && !isAlternateBuffer) ||
isAlternateBuffer
? availableTerminalHeight
: undefined
uiState.constrainHeight ? staticAreaMaxItemHeight : undefined
}
terminalWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
isExpandable={true}
/>
))}
{showConfirmationQueue && confirmingTool && (
@@ -98,8 +125,7 @@ export const MainContent = () => {
[
pendingHistoryItems,
uiState.constrainHeight,
isAlternateBuffer,
availableTerminalHeight,
staticAreaMaxItemHeight,
mainAreaWidth,
showConfirmationQueue,
confirmingTool,
@@ -109,10 +135,14 @@ export const MainContent = () => {
const virtualizedData = useMemo(
() => [
{ type: 'header' as const },
...uiState.history.map((item) => ({ type: 'history' as const, item })),
...uiState.history.map((item, index) => ({
type: 'history' as const,
item,
isExpandable: index > lastUserPromptIndex,
})),
{ type: 'pending' as const },
],
[uiState.history],
[uiState.history, lastUserPromptIndex],
);
const renderItem = useCallback(
@@ -129,12 +159,17 @@ export const MainContent = () => {
return (
<MemoizedHistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={undefined}
availableTerminalHeight={
uiState.constrainHeight || !item.isExpandable
? staticAreaMaxItemHeight
: undefined
}
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={item.item.id}
item={item.item}
isPending={false}
commands={uiState.slashCommands}
isExpandable={item.isExpandable}
/>
);
} else {
@@ -147,6 +182,8 @@ export const MainContent = () => {
mainAreaWidth,
uiState.slashCommands,
pendingItems,
uiState.constrainHeight,
staticAreaMaxItemHeight,
],
);
@@ -176,7 +213,8 @@ export const MainContent = () => {
key={uiState.historyRemountKey}
items={[
<AppHeader key="app-header" version={version} />,
...historyItems,
...staticHistoryItems,
...lastResponseHistoryItems,
]}
>
{(item) => item}
@@ -1620,7 +1620,6 @@ describe('SettingsDialog', () => {
vimMode: true,
enableAutoUpdate: false,
debugKeystrokeLogging: true,
enablePromptCompletion: true,
},
ui: {
hideWindowTitle: true,
@@ -1766,7 +1765,6 @@ describe('SettingsDialog', () => {
vimMode: false,
enableAutoUpdate: true,
debugKeystrokeLogging: false,
enablePromptCompletion: false,
},
ui: {
hideWindowTitle: false,
@@ -29,7 +29,6 @@ describe('ShowMoreLines', () => {
it.each([
[new Set(), StreamingState.Idle, true], // No overflow
[new Set(['1']), StreamingState.Idle, false], // Not constraining height
[new Set(['1']), StreamingState.Responding, true], // Streaming
])(
'renders nothing when: overflow=%s, streaming=%s, constrain=%s',
async (overflowingIds, streamingState, constrainHeight) => {
@@ -46,9 +45,28 @@ describe('ShowMoreLines', () => {
},
);
it.each([[StreamingState.Idle], [StreamingState.WaitingForConfirmation]])(
'renders message when overflowing and state is %s',
it('renders nothing in STANDARD mode even if overflowing', async () => {
mockUseAlternateBuffer.mockReturnValue(false);
mockUseOverflowState.mockReturnValue({
overflowingIds: new Set(['1']),
} as NonNullable<ReturnType<typeof useOverflowState>>);
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
const { lastFrame, waitUntilReady, unmount } = render(
<ShowMoreLines constrainHeight={true} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it.each([
[StreamingState.Idle],
[StreamingState.WaitingForConfirmation],
[StreamingState.Responding],
])(
'renders message in ASB mode when overflowing and state is %s',
async (streamingState) => {
mockUseAlternateBuffer.mockReturnValue(true);
mockUseOverflowState.mockReturnValue({
overflowingIds: new Set(['1']),
} as NonNullable<ReturnType<typeof useOverflowState>>);
@@ -57,8 +75,39 @@ describe('ShowMoreLines', () => {
<ShowMoreLines constrainHeight={true} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Press ctrl-o to show more lines');
expect(lastFrame().toLowerCase()).toContain(
'press ctrl+o to show more lines',
);
unmount();
},
);
it('renders message in ASB mode when isOverflowing prop is true even if internal overflow state is empty', async () => {
mockUseAlternateBuffer.mockReturnValue(true);
mockUseOverflowState.mockReturnValue({
overflowingIds: new Set(),
} as NonNullable<ReturnType<typeof useOverflowState>>);
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
const { lastFrame, waitUntilReady, unmount } = render(
<ShowMoreLines constrainHeight={true} isOverflowing={true} />,
);
await waitUntilReady();
expect(lastFrame().toLowerCase()).toContain(
'press ctrl+o to show more lines',
);
unmount();
});
it('renders nothing when isOverflowing prop is false even if internal overflow state has IDs', async () => {
mockUseOverflowState.mockReturnValue({
overflowingIds: new Set(['1']),
} as NonNullable<ReturnType<typeof useOverflowState>>);
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
const { lastFrame, waitUntilReady, unmount } = render(
<ShowMoreLines constrainHeight={true} isOverflowing={false} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
});
@@ -9,31 +9,42 @@ import { useOverflowState } from '../contexts/OverflowContext.js';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { theme } from '../semantic-colors.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
interface ShowMoreLinesProps {
constrainHeight: boolean;
isOverflowing?: boolean;
}
export const ShowMoreLines = ({ constrainHeight }: ShowMoreLinesProps) => {
export const ShowMoreLines = ({
constrainHeight,
isOverflowing: isOverflowingProp,
}: ShowMoreLinesProps) => {
const isAlternateBuffer = useAlternateBuffer();
const overflowState = useOverflowState();
const streamingState = useStreamingContext();
const isOverflowing =
isOverflowingProp ??
(overflowState !== undefined && overflowState.overflowingIds.size > 0);
if (
overflowState === undefined ||
overflowState.overflowingIds.size === 0 ||
!isAlternateBuffer ||
!isOverflowing ||
!constrainHeight ||
!(
streamingState === StreamingState.Idle ||
streamingState === StreamingState.WaitingForConfirmation
streamingState === StreamingState.WaitingForConfirmation ||
streamingState === StreamingState.Responding
)
) {
return null;
}
return (
<Box paddingX={1}>
<Text color={theme.text.secondary} wrap="truncate">
Press ctrl-o to show more lines
<Box paddingX={1} marginBottom={1}>
<Text color={theme.text.accent} wrap="truncate">
Press Ctrl+O to show more lines
</Text>
</Box>
);
@@ -35,12 +35,22 @@ describe('ToastDisplay', () => {
buffer: { text: '' } as TextBuffer,
history: [] as HistoryItem[],
queueErrorMessage: null,
showIsExpandableHint: false,
};
it('returns false for default state', () => {
expect(shouldShowToast(baseState as UIState)).toBe(false);
});
it('returns true when showIsExpandableHint is true', () => {
expect(
shouldShowToast({
...baseState,
showIsExpandableHint: true,
} as UIState),
).toBe(true);
});
it('returns true when ctrlCPressedOnce is true', () => {
expect(
shouldShowToast({ ...baseState, ctrlCPressedOnce: true } as UIState),
@@ -170,4 +180,22 @@ describe('ToastDisplay', () => {
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders expansion hint when showIsExpandableHint is true', async () => {
const { lastFrame, waitUntilReady } = renderToastDisplay({
showIsExpandableHint: true,
constrainHeight: true,
});
await waitUntilReady();
expect(lastFrame()).toContain('Press Ctrl+O to show more lines');
});
it('renders collapse hint when showIsExpandableHint is true and constrainHeight is false', async () => {
const { lastFrame, waitUntilReady } = renderToastDisplay({
showIsExpandableHint: true,
constrainHeight: false,
});
await waitUntilReady();
expect(lastFrame()).toContain('Press Ctrl+O to collapse lines');
});
});
@@ -17,7 +17,8 @@ export function shouldShowToast(uiState: UIState): boolean {
uiState.ctrlDPressedOnce ||
(uiState.showEscapePrompt &&
(uiState.buffer.text.length > 0 || uiState.history.length > 0)) ||
Boolean(uiState.queueErrorMessage)
Boolean(uiState.queueErrorMessage) ||
uiState.showIsExpandableHint
);
}
@@ -73,5 +74,14 @@ export const ToastDisplay: React.FC = () => {
return <Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>;
}
if (uiState.showIsExpandableHint) {
const action = uiState.constrainHeight ? 'show more' : 'collapse';
return (
<Text color={theme.text.accent}>
Press Ctrl+O to {action} lines for the most recent response
</Text>
);
}
return null;
};
@@ -161,7 +161,7 @@ describe('ToolConfirmationQueue', () => {
</Box>,
{
config: mockConfig,
useAlternateBuffer: false,
useAlternateBuffer: true,
uiState: {
terminalWidth: 80,
terminalHeight: 20,
@@ -173,10 +173,11 @@ describe('ToolConfirmationQueue', () => {
await waitUntilReady();
await waitFor(() =>
expect(lastFrame()).toContain('Press ctrl-o to show more lines'),
expect(lastFrame()?.toLowerCase()).toContain(
'press ctrl+o to show more lines',
),
);
expect(lastFrame()).toMatchSnapshot();
expect(lastFrame()).toContain('Press ctrl-o to show more lines');
unmount();
});
@@ -324,7 +325,7 @@ describe('ToolConfirmationQueue', () => {
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('Press ctrl-o to show more lines');
expect(output).not.toContain('Press CTRL-O to show more lines');
expect(output).toMatchSnapshot();
unmount();
});
@@ -71,13 +71,12 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
// - 2 lines for the rounded border
// - 2 lines for the Header (text + margin)
// - 2 lines for Tool Identity (text + margin)
const availableContentHeight =
constrainHeight && !isAlternateBuffer
? Math.max(maxHeight - (hideToolIdentity ? 4 : 6), 4)
: undefined;
const availableContentHeight = constrainHeight
? Math.max(maxHeight - (hideToolIdentity ? 4 : 6), 4)
: undefined;
return (
<OverflowProvider>
const content = (
<>
<Box flexDirection="column" width={mainAreaWidth} flexShrink={0}>
<StickyHeader
width={mainAreaWidth}
@@ -152,6 +151,13 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
/>
</Box>
<ShowMoreLines constrainHeight={constrainHeight} />
</OverflowProvider>
</>
);
return isAlternateBuffer ? (
/* Shadow the global provider to maintain isolation in ASB mode. */
<OverflowProvider>{content}</OverflowProvider>
) : (
content
);
};
@@ -43,7 +43,6 @@ Tips for getting started:
│ ✓ tool1 Description for tool 1 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool2 Description for tool 2 │
│ │
@@ -90,7 +89,6 @@ Tips for getting started:
│ ✓ tool1 Description for tool 1 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool2 Description for tool 2 │
│ │
@@ -18,20 +18,8 @@ Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
"
`;
exports[`ConfigInitDisplay > truncates list of waiting servers if too many 2`] = `
"
Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
"
`;
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = `
"
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
"
`;
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 2`] = `
"
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
"
`;
@@ -27,33 +27,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: false > bubbles up Ctrl+C when feedback is empty while editing 2`] = `
"Overview
Add user authentication to the CLI application.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add tests in src/auth/__tests__/
Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
● 3. Type your feedback...
Enter to submit · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 1`] = `
"Overview
@@ -81,33 +54,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 2`] = `
"Overview
Add user authentication to the CLI application.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add tests in src/auth/__tests__/
Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
● 3. Add tests
Enter to submit · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: false > displays error state when file read fails 1`] = `
" Error reading plan: File not found
"
@@ -194,33 +140,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: true > bubbles up Ctrl+C when feedback is empty while editing 2`] = `
"Overview
Add user authentication to the CLI application.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add tests in src/auth/__tests__/
Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
● 3. Type your feedback...
Enter to submit · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 1`] = `
"Overview
@@ -248,33 +167,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 2`] = `
"Overview
Add user authentication to the CLI application.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add tests in src/auth/__tests__/
Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
● 3. Add tests
Enter to submit · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: true > displays error state when file read fails 1`] = `
" Error reading plan: File not found
"
@@ -6,27 +6,17 @@ AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command Running a long command... │
│ │
│ Line 1 │
│ Line 2 │
│ Line 3 │
│ Line 4 │
│ Line 5 │
│ Line 6 │
│ Line 7 │
│ Line 8 │
│ Line 9 │
│ Line 10 │
│ Line 11 │
│ Line 12 │
│ Line 13 │
│ Line 14 │
│ Line 15
│ Line 16
│ Line 17
│ Line 18
│ Line 19
│ Line 20
│ │
│ Line 15
│ Line 16
│ Line 17
│ Line 18
│ Line 19
│ Line 20
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
ShowMoreLines
"
@@ -38,15 +28,11 @@ AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command Running a long command... │
│ │
│ Line 6
│ Line 7
│ Line 8
│ Line 9
│ Line 10
│ Line 11 █ │
│ Line 12 █ │
│ Line 13 █ │
│ Line 14 █ │
│ Line 10
│ Line 11
│ Line 12
│ Line 13
│ Line 14
│ Line 15 █ │
│ Line 16 █ │
│ Line 17 █ │
@@ -63,12 +49,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command Running a long command... │
│ │
Line 6
│ Line 7 │
│ Line 8 │
│ Line 9 │
│ Line 10 │
│ Line 11 │
... first 11 lines hidden ...
│ Line 12 │
│ Line 13 │
│ Line 14 │
@@ -88,6 +69,11 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unc
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command Running a long command... │
│ │
│ Line 1 │
│ Line 2 │
│ Line 3 │
│ Line 4 │
│ Line 5 │
│ Line 6 │
│ Line 7 │
│ Line 8 │
@@ -25,15 +25,15 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -72,15 +72,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -119,15 +119,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Enable Prompt Completion false* │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false* │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -166,15 +166,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -213,15 +213,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -260,15 +260,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ > Apply To │
@@ -307,15 +307,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -354,15 +354,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -401,15 +401,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Enable Prompt Completion true* │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging true* │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -16,7 +16,6 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on avai
│ 4. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
Press ctrl-o to show more lines
"
`;
@@ -107,7 +106,7 @@ exports[`ToolConfirmationQueue > renders expansion hint when content is long and
│ 4. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
Press ctrl-o to show more lines
Press Ctrl+O to show more lines
"
`;
@@ -12,6 +12,7 @@ import { theme } from '../../semantic-colors.js';
import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
import { OverflowProvider } from '../../contexts/OverflowContext.js';
interface GeminiMessageProps {
text: string;
@@ -31,7 +32,7 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
const prefixWidth = prefix.length;
const isAlternateBuffer = useAlternateBuffer();
return (
const content = (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={theme.text.accent} aria-label={SCREEN_READER_MODEL_PREFIX}>
@@ -61,4 +62,11 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
</Box>
</Box>
);
return isAlternateBuffer ? (
/* Shadow the global provider to maintain isolation in ASB mode. */
<OverflowProvider>{content}</OverflowProvider>
) : (
content
);
};
@@ -191,7 +191,7 @@ describe('<ShellToolMessage />', () => {
true,
],
[
'defaults to ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is undefined',
'defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined',
undefined,
ACTIVE_SHELL_MAX_LINES,
false,
@@ -219,5 +219,75 @@ describe('<ShellToolMessage />', () => {
expect(frame.match(/Line \d+/g)?.length).toBe(expectedMaxLines);
expect(frame).toMatchSnapshot();
});
it('fully expands in standard mode when availableTerminalHeight is undefined', async () => {
const { lastFrame } = renderShell(
{
resultDisplay: LONG_OUTPUT,
renderOutputAsMarkdown: false,
availableTerminalHeight: undefined,
status: CoreToolCallStatus.Executing,
},
{ useAlternateBuffer: false },
);
await waitFor(() => {
const frame = lastFrame();
// Should show all 100 lines
expect(frame.match(/Line \d+/g)?.length).toBe(100);
});
});
it('fully expands in alternate buffer mode when constrainHeight is false and isExpandable is true', async () => {
const { lastFrame, waitUntilReady } = renderShell(
{
resultDisplay: LONG_OUTPUT,
renderOutputAsMarkdown: false,
availableTerminalHeight: undefined,
status: CoreToolCallStatus.Success,
isExpandable: true,
},
{
useAlternateBuffer: true,
uiState: {
constrainHeight: false,
},
},
);
await waitUntilReady();
await waitFor(() => {
const frame = lastFrame();
// Should show all 100 lines because constrainHeight is false and isExpandable is true
expect(frame.match(/Line \d+/g)?.length).toBe(100);
});
expect(lastFrame()).toMatchSnapshot();
});
it('stays constrained in alternate buffer mode when isExpandable is false even if constrainHeight is false', async () => {
const { lastFrame, waitUntilReady } = renderShell(
{
resultDisplay: LONG_OUTPUT,
renderOutputAsMarkdown: false,
availableTerminalHeight: undefined,
status: CoreToolCallStatus.Success,
isExpandable: false,
},
{
useAlternateBuffer: true,
uiState: {
constrainHeight: false,
},
},
);
await waitUntilReady();
await waitFor(() => {
const frame = lastFrame();
// Should still be constrained to ACTIVE_SHELL_MAX_LINES (15) because isExpandable is false
expect(frame.match(/Line \d+/g)?.length).toBe(15);
});
expect(lastFrame()).toMatchSnapshot();
});
});
});
@@ -15,24 +15,21 @@ import {
ToolStatusIndicator,
ToolInfo,
TrailingIndicator,
STATUS_INDICATOR_WIDTH,
isThisShellFocusable as checkIsShellFocusable,
isThisShellFocused as checkIsShellFocused,
useFocusHint,
FocusHint,
} from './ToolShared.js';
import type { ToolMessageProps } from './ToolMessage.js';
import {
ACTIVE_SHELL_MAX_LINES,
COMPLETED_SHELL_MAX_LINES,
} from '../../constants.js';
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
import { type Config } from '@google/gemini-cli-core';
import { calculateShellMaxLines } from '../../utils/toolLayoutUtils.js';
export interface ShellToolMessageProps extends ToolMessageProps {
config?: Config;
isExpandable?: boolean;
}
export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
@@ -61,9 +58,15 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
borderColor,
borderDimColor,
isExpandable,
}) => {
const { activePtyId: activeShellPtyId, embeddedShellFocused } = useUIState();
const {
activePtyId: activeShellPtyId,
embeddedShellFocused,
constrainHeight,
} = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const isThisShellFocused = checkIsShellFocused(
name,
status,
@@ -155,59 +158,23 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
terminalWidth={terminalWidth}
renderOutputAsMarkdown={renderOutputAsMarkdown}
hasFocus={isThisShellFocused}
maxLines={getShellMaxLines(
maxLines={calculateShellMaxLines({
status,
isAlternateBuffer,
isThisShellFocused,
availableTerminalHeight,
)}
constrainHeight,
isExpandable,
})}
/>
{isThisShellFocused && config && (
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>
<ShellInputPrompt
activeShellPtyId={activeShellPtyId ?? null}
focus={embeddedShellFocused}
scrollPageSize={availableTerminalHeight ?? ACTIVE_SHELL_MAX_LINES}
/>
</Box>
<ShellInputPrompt
activeShellPtyId={activeShellPtyId ?? null}
focus={embeddedShellFocused}
scrollPageSize={availableTerminalHeight ?? ACTIVE_SHELL_MAX_LINES}
/>
)}
</Box>
</>
);
};
/**
* Calculates the maximum number of lines to display for shell output.
*
* For completed processes (Success, Error, Canceled), it returns COMPLETED_SHELL_MAX_LINES.
* For active processes, it returns the available terminal height if in alternate buffer mode
* and focused. Otherwise, it returns ACTIVE_SHELL_MAX_LINES.
*
* This function ensures a finite number of lines is always returned to prevent performance issues.
*/
function getShellMaxLines(
status: CoreToolCallStatus,
isAlternateBuffer: boolean,
isThisShellFocused: boolean,
availableTerminalHeight: number | undefined,
): number {
if (
status === CoreToolCallStatus.Success ||
status === CoreToolCallStatus.Error ||
status === CoreToolCallStatus.Cancelled
) {
return COMPLETED_SHELL_MAX_LINES;
}
if (availableTerminalHeight === undefined) {
return ACTIVE_SHELL_MAX_LINES;
}
const maxLinesBasedOnHeight = Math.max(1, availableTerminalHeight - 2);
if (isAlternateBuffer && isThisShellFocused) {
return maxLinesBasedOnHeight;
}
return Math.min(maxLinesBasedOnHeight, ACTIVE_SHELL_MAX_LINES);
}
@@ -6,6 +6,7 @@
import { renderWithProviders } from '../../../test-utils/render.js';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { act } from 'react';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import type {
HistoryItem,
@@ -678,4 +679,194 @@ describe('<ToolGroupMessage />', () => {
},
);
});
describe('Manual Overflow Detection', () => {
it('detects overflow for string results exceeding available height', async () => {
const toolCalls = [
createToolCall({
resultDisplay: 'line 1\nline 2\nline 3\nline 4\nline 5',
}),
];
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={6} // Very small height
isExpandable={true}
/>,
{
config: baseMockConfig,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
},
},
);
await waitUntilReady();
expect(lastFrame()?.toLowerCase()).toContain(
'press ctrl+o to show more lines',
);
unmount();
});
it('detects overflow for array results exceeding available height', async () => {
// resultDisplay when array is expected to be AnsiLine[]
// AnsiLine is AnsiToken[]
const toolCalls = [
createToolCall({
resultDisplay: Array(5).fill([{ text: 'line', fg: 'default' }]),
}),
];
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={6}
isExpandable={true}
/>,
{
config: baseMockConfig,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
},
},
);
await waitUntilReady();
expect(lastFrame()?.toLowerCase()).toContain(
'press ctrl+o to show more lines',
);
unmount();
});
it('respects ACTIVE_SHELL_MAX_LINES for focused shell tools', async () => {
const toolCalls = [
createToolCall({
name: 'run_shell_command',
status: CoreToolCallStatus.Executing,
ptyId: 1,
resultDisplay: Array(20).fill('line').join('\n'), // 20 lines > 15 (limit)
}),
];
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={100} // Plenty of terminal height
isExpandable={true}
/>,
{
config: baseMockConfig,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
activePtyId: 1,
embeddedShellFocused: true,
},
},
);
await waitUntilReady();
expect(lastFrame()?.toLowerCase()).toContain(
'press ctrl+o to show more lines',
);
unmount();
});
it('does not show expansion hint when content is within limits', async () => {
const toolCalls = [
createToolCall({
resultDisplay: 'small result',
}),
];
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={20}
isExpandable={true}
/>,
{
config: baseMockConfig,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
},
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines');
unmount();
});
it('hides expansion hint when constrainHeight is false', async () => {
const toolCalls = [
createToolCall({
resultDisplay: 'line 1\nline 2\nline 3\nline 4\nline 5',
}),
];
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={6}
isExpandable={true}
/>,
{
config: baseMockConfig,
useAlternateBuffer: true,
uiState: {
constrainHeight: false,
},
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines');
unmount();
});
it('isolates overflow hint in ASB mode (ignores global overflow state)', async () => {
// In this test, the tool output is SHORT (no local overflow).
// We will inject a dummy ID into the global overflow state.
// ToolGroupMessage should still NOT show the hint because it calculates
// overflow locally and passes it as a prop.
const toolCalls = [
createToolCall({
resultDisplay: 'short result',
}),
];
const { lastFrame, unmount, waitUntilReady, capturedOverflowActions } =
renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={100}
isExpandable={true}
/>,
{
config: baseMockConfig,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
},
},
);
await waitUntilReady();
// Manually trigger a global overflow
act(() => {
expect(capturedOverflowActions).toBeDefined();
capturedOverflowActions!.addOverflowingId('unrelated-global-id');
});
// The hint should NOT appear because ToolGroupMessage is isolated by its prop logic
expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines');
unmount();
});
});
});
@@ -17,10 +17,15 @@ import { ToolMessage } from './ToolMessage.js';
import { ShellToolMessage } from './ShellToolMessage.js';
import { theme } from '../../semantic-colors.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { isShellTool } from './ToolShared.js';
import { isShellTool, isThisShellFocused } from './ToolShared.js';
import { shouldHideToolCall } from '@google/gemini-cli-core';
import { ShowMoreLines } from '../ShowMoreLines.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
import {
calculateShellMaxLines,
calculateToolContentMaxLines,
} from '../../utils/toolLayoutUtils.js';
import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js';
interface ToolGroupMessageProps {
@@ -31,6 +36,7 @@ interface ToolGroupMessageProps {
onShellInputSubmit?: (input: string) => void;
borderTop?: boolean;
borderBottom?: boolean;
isExpandable?: boolean;
}
// Main component renders the border and maps the tools using ToolMessage
@@ -43,6 +49,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
terminalWidth,
borderTop: borderTopOverride,
borderBottom: borderBottomOverride,
isExpandable,
}) => {
// Filter out tool calls that should be hidden (e.g. in-progress Ask User, or Plan Mode operations).
const toolCalls = useMemo(
@@ -67,6 +74,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
backgroundShells,
pendingHistoryItems,
} = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const { borderColor, borderDimColor } = useMemo(
() =>
@@ -106,14 +114,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const staticHeight = /* border */ 2;
// If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools),
// only render if we need to close a border from previous
// tool groups. borderBottomOverride=true means we must render the closing border;
// undefined or false means there's nothing to display.
if (visibleToolCalls.length === 0 && borderBottomOverride !== true) {
return null;
}
let countToolCallsWithResults = 0;
for (const tool of visibleToolCalls) {
if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') {
@@ -134,21 +134,91 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const contentWidth = terminalWidth - TOOL_MESSAGE_HORIZONTAL_MARGIN;
return (
// This box doesn't have a border even though it conceptually does because
// we need to allow the sticky headers to render the borders themselves so
// that the top border can be sticky.
/*
* ToolGroupMessage calculates its own overflow state locally and passes
* it as a prop to ShowMoreLines. This isolates it from global overflow
* reports in ASB mode, while allowing it to contribute to the global
* 'Toast' hint in Standard mode.
*
* Because of this prop-based isolation and the explicit mode-checks in
* AppContainer, we do not need to shadow the OverflowProvider here.
*/
const hasOverflow = useMemo(() => {
if (!availableTerminalHeightPerToolMessage) return false;
return visibleToolCalls.some((tool) => {
const isShellToolCall = isShellTool(tool.name);
const isFocused = isThisShellFocused(
tool.name,
tool.status,
tool.ptyId,
activePtyId,
embeddedShellFocused,
);
let maxLines: number | undefined;
if (isShellToolCall) {
maxLines = calculateShellMaxLines({
status: tool.status,
isAlternateBuffer,
isThisShellFocused: isFocused,
availableTerminalHeight: availableTerminalHeightPerToolMessage,
constrainHeight,
isExpandable,
});
}
// Standard tools and Shell tools both eventually use ToolResultDisplay's logic.
// ToolResultDisplay uses calculateToolContentMaxLines to find the final line budget.
const contentMaxLines = calculateToolContentMaxLines({
availableTerminalHeight: availableTerminalHeightPerToolMessage,
isAlternateBuffer,
maxLinesLimit: maxLines,
});
if (!contentMaxLines) return false;
if (typeof tool.resultDisplay === 'string') {
const text = tool.resultDisplay;
const hasTrailingNewline = text.endsWith('\n');
const contentText = hasTrailingNewline ? text.slice(0, -1) : text;
const lineCount = contentText.split('\n').length;
return lineCount > contentMaxLines;
}
if (Array.isArray(tool.resultDisplay)) {
return tool.resultDisplay.length > contentMaxLines;
}
return false;
});
}, [
visibleToolCalls,
availableTerminalHeightPerToolMessage,
activePtyId,
embeddedShellFocused,
isAlternateBuffer,
constrainHeight,
isExpandable,
]);
// If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools),
// only render if we need to close a border from previous
// tool groups. borderBottomOverride=true means we must render the closing border;
// undefined or false means there's nothing to display.
if (visibleToolCalls.length === 0 && borderBottomOverride !== true) {
return null;
}
const content = (
<Box
flexDirection="column"
/*
This width constraint is highly important and protects us from an Ink rendering bug.
Since the ToolGroup can typically change rendering states frequently, it can cause
Ink to render the border of the box incorrectly and span multiple lines and even
cause tearing.
*/
This width constraint is highly important and protects us from an Ink rendering bug.
Since the ToolGroup can typically change rendering states frequently, it can cause
Ink to render the border of the box incorrectly and span multiple lines and even
cause tearing.
*/
width={terminalWidth}
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
marginBottom={borderBottomOverride === false ? 0 : 1}
>
{visibleToolCalls.map((tool, index) => {
const isFirst = index === 0;
@@ -165,6 +235,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
: isFirst,
borderColor,
borderDimColor,
isExpandable,
};
return (
@@ -179,34 +250,34 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
) : (
<ToolMessage {...commonProps} />
)}
<Box
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={false}
borderColor={borderColor}
borderDimColor={borderDimColor}
flexDirection="column"
borderStyle="round"
paddingLeft={1}
paddingRight={1}
>
{tool.outputFile && (
{tool.outputFile && (
<Box
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={false}
borderColor={borderColor}
borderDimColor={borderDimColor}
flexDirection="column"
borderStyle="round"
paddingLeft={1}
paddingRight={1}
>
<Box>
<Text color={theme.text.primary}>
Output too long and was saved to: {tool.outputFile}
</Text>
</Box>
)}
</Box>
</Box>
)}
</Box>
);
})}
{
/*
We have to keep the bottom border separate so it doesn't get
drawn over by the sticky header directly inside it.
*/
We have to keep the bottom border separate so it doesn't get
drawn over by the sticky header directly inside it.
*/
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && (
<Box
height={0}
@@ -222,8 +293,13 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
)
}
{(borderBottomOverride ?? true) && visibleToolCalls.length > 0 && (
<ShowMoreLines constrainHeight={constrainHeight} />
<ShowMoreLines
constrainHeight={constrainHeight && !!isExpandable}
isOverflowing={hasOverflow}
/>
)}
</Box>
);
return content;
};
@@ -0,0 +1,115 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { StreamingState, type IndividualToolCallDisplay } from '../../types.js';
import { OverflowProvider } from '../../contexts/OverflowContext.js';
import { waitFor } from '../../../test-utils/async.js';
import { CoreToolCallStatus } from '@google/gemini-cli-core';
describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay synchronization', () => {
it('should ensure explicit hasOverflow calculation is consistent with ToolResultDisplay truncation in Alternate Buffer (ASB) mode', async () => {
/**
* Logic:
* 1. availableTerminalHeight(13) - staticHeight(3) = 10 lines per tool.
* 2. ASB mode reserves 1 + 6 = 7 lines.
* 3. Line budget = 10 - 7 = 3 lines.
* 4. 5 lines of output > 3 lines budget => hasOverflow should be TRUE.
*/
const lines = Array.from({ length: 5 }, (_, i) => `line ${i + 1}`);
const resultDisplay = lines.join('\n');
const toolCalls: IndividualToolCallDisplay[] = [
{
callId: 'call-1',
name: 'test-tool',
description: 'a test tool',
status: CoreToolCallStatus.Success,
resultDisplay,
confirmationDetails: undefined,
},
];
const { lastFrame } = renderWithProviders(
<OverflowProvider>
<ToolGroupMessage
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={13}
terminalWidth={80}
isExpandable={true}
/>
</OverflowProvider>,
{
uiState: {
streamingState: StreamingState.Idle,
constrainHeight: true,
},
useAlternateBuffer: true,
},
);
// In ASB mode, the hint should appear because hasOverflow is now correctly calculated.
await waitFor(() =>
expect(lastFrame()?.toLowerCase()).toContain(
'press ctrl+o to show more lines',
),
);
});
it('should ensure explicit hasOverflow calculation is consistent with ToolResultDisplay truncation in Standard mode', async () => {
/**
* Logic:
* 1. availableTerminalHeight(13) - staticHeight(3) = 10 lines per tool.
* 2. Standard mode reserves 1 + 2 = 3 lines.
* 3. Line budget = 10 - 3 = 7 lines.
* 4. 9 lines of output > 7 lines budget => hasOverflow should be TRUE.
*/
const lines = Array.from({ length: 9 }, (_, i) => `line ${i + 1}`);
const resultDisplay = lines.join('\n');
const toolCalls: IndividualToolCallDisplay[] = [
{
callId: 'call-1',
name: 'test-tool',
description: 'a test tool',
status: CoreToolCallStatus.Success,
resultDisplay,
confirmationDetails: undefined,
},
];
const { lastFrame } = renderWithProviders(
<OverflowProvider>
<ToolGroupMessage
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={13}
terminalWidth={80}
isExpandable={true}
/>
</OverflowProvider>,
{
uiState: {
streamingState: StreamingState.Idle,
constrainHeight: true,
},
useAlternateBuffer: false,
},
);
// Verify truncation is occurring (standard mode uses MaxSizedBox)
await waitFor(() => expect(lastFrame()).toContain('hidden ...'));
// In Standard mode, ToolGroupMessage calculates hasOverflow correctly now.
// While Standard mode doesn't render the inline hint (ShowMoreLines returns null),
// the logic inside ToolGroupMessage is now synchronized.
});
});
@@ -277,21 +277,47 @@ describe('ToolResultDisplay', () => {
inverse: false,
},
],
[
{
text: 'Line 4',
fg: '',
bg: '',
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
},
],
[
{
text: 'Line 5',
fg: '',
bg: '',
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
},
],
];
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolResultDisplay
resultDisplay={ansiResult}
terminalWidth={80}
availableTerminalHeight={20}
maxLines={2}
maxLines={3}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('Line 1');
expect(output).toContain('Line 2');
expect(output).toContain('Line 3');
expect(output).not.toContain('Line 2');
expect(output).not.toContain('Line 3');
expect(output).toContain('Line 4');
expect(output).toContain('Line 5');
unmount();
});
@@ -19,10 +19,7 @@ import { Scrollable } from '../shared/Scrollable.js';
import { ScrollableList } from '../shared/ScrollableList.js';
import { SCROLL_TO_ITEM_END } from '../shared/VirtualizedList.js';
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
const STATIC_HEIGHT = 1;
const RESERVED_LINE_COUNT = 6; // for tool name, status, padding, and 'ShowMoreLines' hint
const MIN_LINES_SHOWN = 2; // show at least this many lines
import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.js';
// Large threshold to ensure we don't cause performance issues for very large
// outputs that will get truncated further MaxSizedBox anyway.
@@ -53,16 +50,11 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
const { renderMarkdown } = useUIState();
const isAlternateBuffer = useAlternateBuffer();
let availableHeight = availableTerminalHeight
? Math.max(
availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT,
MIN_LINES_SHOWN + 1, // enforce minimum lines shown
)
: undefined;
if (maxLines && availableHeight) {
availableHeight = Math.min(availableHeight, maxLines);
}
const availableHeight = calculateToolContentMaxLines({
availableTerminalHeight,
isAlternateBuffer,
maxLinesLimit: maxLines,
});
const combinedPaddingAndBorderWidth = 4;
const childWidth = terminalWidth - combinedPaddingAndBorderWidth;
@@ -81,7 +73,8 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
[],
);
const truncatedResultDisplay = React.useMemo(() => {
const { truncatedResultDisplay, hiddenLinesCount } = React.useMemo(() => {
let hiddenLines = 0;
// Only truncate string output if not in alternate buffer mode to ensure
// we can scroll through the full output.
if (typeof resultDisplay === 'string' && !isAlternateBuffer) {
@@ -94,14 +87,29 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
const contentText = hasTrailingNewline ? text.slice(0, -1) : text;
const lines = contentText.split('\n');
if (lines.length > maxLines) {
// We will have a label from MaxSizedBox. Reserve space for it.
const targetLines = Math.max(1, maxLines - 1);
hiddenLines = lines.length - targetLines;
text =
lines.slice(-maxLines).join('\n') +
lines.slice(-targetLines).join('\n') +
(hasTrailingNewline ? '\n' : '');
}
}
return text;
return { truncatedResultDisplay: text, hiddenLinesCount: hiddenLines };
}
return resultDisplay;
if (Array.isArray(resultDisplay) && !isAlternateBuffer && maxLines) {
if (resultDisplay.length > maxLines) {
// We will have a label from MaxSizedBox. Reserve space for it.
const targetLines = Math.max(1, maxLines - 1);
return {
truncatedResultDisplay: resultDisplay.slice(-targetLines),
hiddenLinesCount: resultDisplay.length - targetLines,
};
}
}
return { truncatedResultDisplay: resultDisplay, hiddenLinesCount: 0 };
}, [resultDisplay, isAlternateBuffer, maxLines]);
if (!truncatedResultDisplay) return null;
@@ -229,7 +237,11 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
return (
<Box width={childWidth} flexDirection="column">
<MaxSizedBox maxHeight={availableHeight} maxWidth={childWidth}>
<MaxSizedBox
maxHeight={availableHeight}
maxWidth={childWidth}
additionalHiddenLinesCount={hiddenLinesCount}
>
{content}
</MaxSizedBox>
</Box>
@@ -39,6 +39,7 @@ describe('ToolResultDisplay Overflow', () => {
toolCalls={toolCalls}
availableTerminalHeight={15} // Small height to force overflow
terminalWidth={80}
isExpandable={true}
/>
</OverflowProvider>,
{
@@ -46,26 +47,28 @@ describe('ToolResultDisplay Overflow', () => {
streamingState: StreamingState.Idle,
constrainHeight: true,
},
useAlternateBuffer: false,
useAlternateBuffer: true,
},
);
// ResizeObserver might take a tick
await waitFor(() =>
expect(lastFrame()).toContain('Press ctrl-o to show more lines'),
expect(lastFrame()?.toLowerCase()).toContain(
'press ctrl+o to show more lines',
),
);
const frame = lastFrame();
expect(frame).toBeDefined();
if (frame) {
expect(frame).toContain('Press ctrl-o to show more lines');
expect(frame.toLowerCase()).toContain('press ctrl+o to show more lines');
// Ensure it's AFTER the bottom border
const linesOfOutput = frame.split('\n');
const bottomBorderIndex = linesOfOutput.findLastIndex((l) =>
l.includes('╰─'),
);
const hintIndex = linesOfOutput.findIndex((l) =>
l.includes('Press ctrl-o to show more lines'),
l.toLowerCase().includes('press ctrl+o to show more lines'),
);
expect(hintIndex).toBeGreaterThan(bottomBorderIndex);
expect(frame).toMatchSnapshot();
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ShellToolMessage /> > Height Constraints > defaults to ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is undefined 1`] = `
exports[`<ShellToolMessage /> > Height Constraints > defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command A shell command │
│ │
@@ -22,6 +22,113 @@ exports[`<ShellToolMessage /> > Height Constraints > defaults to ACTIVE_SHELL_MA
"
`;
exports[`<ShellToolMessage /> > Height Constraints > fully expands in alternate buffer mode when constrainHeight is false and isExpandable is true 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ Shell Command A shell command │
│ │
│ Line 1 │
│ Line 2 │
│ Line 3 │
│ Line 4 │
│ Line 5 │
│ Line 6 │
│ Line 7 │
│ Line 8 │
│ Line 9 │
│ Line 10 │
│ Line 11 │
│ Line 12 │
│ Line 13 │
│ Line 14 │
│ Line 15 │
│ Line 16 │
│ Line 17 │
│ Line 18 │
│ Line 19 │
│ Line 20 │
│ Line 21 │
│ Line 22 │
│ Line 23 │
│ Line 24 │
│ Line 25 │
│ Line 26 │
│ Line 27 │
│ Line 28 │
│ Line 29 │
│ Line 30 │
│ Line 31 │
│ Line 32 │
│ Line 33 │
│ Line 34 │
│ Line 35 │
│ Line 36 │
│ Line 37 │
│ Line 38 │
│ Line 39 │
│ Line 40 │
│ Line 41 │
│ Line 42 │
│ Line 43 │
│ Line 44 │
│ Line 45 │
│ Line 46 │
│ Line 47 │
│ Line 48 │
│ Line 49 │
│ Line 50 │
│ Line 51 │
│ Line 52 │
│ Line 53 │
│ Line 54 │
│ Line 55 │
│ Line 56 │
│ Line 57 │
│ Line 58 │
│ Line 59 │
│ Line 60 │
│ Line 61 │
│ Line 62 │
│ Line 63 │
│ Line 64 │
│ Line 65 │
│ Line 66 │
│ Line 67 │
│ Line 68 │
│ Line 69 │
│ Line 70 │
│ Line 71 │
│ Line 72 │
│ Line 73 │
│ Line 74 │
│ Line 75 │
│ Line 76 │
│ Line 77 │
│ Line 78 │
│ Line 79 │
│ Line 80 │
│ Line 81 │
│ Line 82 │
│ Line 83 │
│ Line 84 │
│ Line 85 │
│ Line 86 │
│ Line 87 │
│ Line 88 │
│ Line 89 │
│ Line 90 │
│ Line 91 │
│ Line 92 │
│ Line 93 │
│ Line 94 │
│ Line 95 │
│ Line 96 │
│ Line 97 │
│ Line 98 │
│ Line 99 │
│ Line 100 │
"
`;
exports[`<ShellToolMessage /> > Height Constraints > respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command A shell command │
@@ -37,6 +144,28 @@ exports[`<ShellToolMessage /> > Height Constraints > respects availableTerminalH
"
`;
exports[`<ShellToolMessage /> > Height Constraints > stays constrained in alternate buffer mode when isExpandable is false even if constrainHeight is false 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ Shell Command A shell command │
│ │
│ Line 86 │
│ Line 87 │
│ Line 88 │
│ Line 89 │
│ Line 90 │
│ Line 91 │
│ Line 92 │
│ Line 93 │
│ Line 94 │
│ Line 95 │
│ Line 96 │
│ Line 97 │
│ Line 98 ▄ │
│ Line 99 █ │
│ Line 100 █ │
"
`;
exports[`<ShellToolMessage /> > Height Constraints > uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command A shell command │
@@ -161,7 +290,6 @@ exports[`<ShellToolMessage /> > Height Constraints > uses full availableTerminal
│ Line 98 █ │
│ Line 99 █ │
│ Line 100 █ │
│ │
"
`;
@@ -170,7 +298,6 @@ exports[`<ShellToolMessage /> > Snapshots > renders in Alternate Buffer mode whi
│ ⊷ Shell Command A shell command (Shift+Tab to unfocus) │
│ │
│ Test result │
│ │
"
`;
@@ -55,13 +55,13 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-1 Description 1. This is a long description that will need to b… │
│──────────────────────────────────────────────────────────────────────────│
line5
│ │ █
│ ✓ tool-2 Description 2 │ █
│ │ █
│ line1 │ █
│ line2 │ █
╰──────────────────────────────────────────────────────────────────────────╯ █
"
`;
@@ -111,12 +111,12 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call with output
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders two tool groups where only the last line of the previous group is visible 1`] = `
"──────────────────────────────────────────────────────────────────────────
"──────────────────────────────────────────────────────────────────────────
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-2 Description 2 │
│ │
│ line1 │
│ │
│ line1 │
╰──────────────────────────────────────────────────────────────────────────╯ █
"
`;
@@ -37,7 +37,11 @@ exports[`ToolResultDisplay > renders string result as plain text when renderOutp
`;
exports[`ToolResultDisplay > truncates very long string results 1`] = `
"... first 252 lines hidden ...
"... first 248 lines hidden ...
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
@@ -4,13 +4,13 @@ exports[`ToolResultDisplay Overflow > should display "press ctrl-o" hint when co
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool a test tool │
│ │
... first 45 lines hidden ...
line 45
│ line 46 │
│ line 47 │
│ line 48 │
│ line 49 │
│ line 50
│ line 50
╰──────────────────────────────────────────────────────────────────────────╯
Press ctrl-o to show more lines
Press Ctrl+O to show more lines
"
`;
@@ -2,7 +2,7 @@
exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 1`] = `
"╭────────────────────────────────────────────────────────────────────────╮ █
│ ✓ Shell Command Description for Shell Command │
│ ✓ Shell Command Description for Shell Command │
│ │
│ shell-01 │
│ shell-02 │
@@ -11,7 +11,7 @@ exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage i
exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 2`] = `
"╭────────────────────────────────────────────────────────────────────────╮
│ ✓ Shell Command Description for Shell Command │
│ ✓ Shell Command Description for Shell Command │
│────────────────────────────────────────────────────────────────────────│ █
│ shell-06 │ ▀
│ shell-07 │
@@ -173,6 +173,28 @@ describe('TabHeader', () => {
unmount();
});
it('truncates long headers when not selected', async () => {
const longTabs: Tab[] = [
{ key: '0', header: 'ThisIsAVeryLongHeaderThatShouldBeTruncated' },
{ key: '1', header: 'AnotherVeryLongHeader' },
];
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<TabHeader tabs={longTabs} currentIndex={0} />,
);
await waitUntilReady();
const frame = lastFrame();
// Current tab (index 0) should NOT be truncated
expect(frame).toContain('ThisIsAVeryLongHeaderThatShouldBeTruncated');
// Inactive tab (index 1) SHOULD be truncated to 16 chars (15 chars + …)
const expectedTruncated = 'AnotherVeryLong…';
expect(frame).toContain(expectedTruncated);
expect(frame).not.toContain('AnotherVeryLongHeader');
unmount();
});
it('falls back to default when renderStatusIcon returns undefined', async () => {
const renderStatusIcon = () => undefined;
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
@@ -94,16 +94,19 @@ export function TabHeader({
{showStatusIcons && (
<Text color={theme.text.secondary}>{getStatusIcon(tab, i)} </Text>
)}
<Text
color={
i === currentIndex ? theme.status.success : theme.text.secondary
}
bold={i === currentIndex}
underline={i === currentIndex}
aria-current={i === currentIndex ? 'step' : undefined}
>
{tab.header}
</Text>
<Box maxWidth={i !== currentIndex ? 16 : 100}>
<Text
color={
i === currentIndex ? theme.status.success : theme.text.secondary
}
bold={i === currentIndex}
underline={i === currentIndex}
aria-current={i === currentIndex ? 'step' : undefined}
wrap="truncate"
>
{tab.header}
</Text>
</Box>
</React.Fragment>
))}
{showArrows && <Text color={theme.text.secondary}>{' →'}</Text>}
+1
View File
@@ -33,6 +33,7 @@ export const WARNING_PROMPT_DURATION_MS = 3000;
export const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
export const SHELL_ACTION_REQUIRED_TITLE_DELAY_MS = 30000;
export const SHELL_SILENT_WORKING_TITLE_DELAY_MS = 120000;
export const EXPAND_HINT_DURATION_MS = 5000;
export const DEFAULT_BACKGROUND_OPACITY = 0.16;
export const DEFAULT_INPUT_BACKGROUND_OPACITY = 0.24;
@@ -13,13 +13,14 @@ import {
useMemo,
} from 'react';
interface OverflowState {
export interface OverflowState {
overflowingIds: ReadonlySet<string>;
}
interface OverflowActions {
export interface OverflowActions {
addOverflowingId: (id: string) => void;
removeOverflowingId: (id: string) => void;
reset: () => void;
}
const OverflowStateContext = createContext<OverflowState | undefined>(
@@ -63,6 +64,10 @@ export const OverflowProvider: React.FC<{ children: React.ReactNode }> = ({
});
}, []);
const reset = useCallback(() => {
setOverflowingIds(new Set());
}, []);
const stateValue = useMemo(
() => ({
overflowingIds,
@@ -74,8 +79,9 @@ export const OverflowProvider: React.FC<{ children: React.ReactNode }> = ({
() => ({
addOverflowingId,
removeOverflowingId,
reset,
}),
[addOverflowingId, removeOverflowingId],
[addOverflowingId, removeOverflowingId, reset],
);
return (
@@ -182,6 +182,7 @@ export interface UIState {
isBackgroundShellListOpen: boolean;
adminSettingsChanged: boolean;
newAgents: AgentDefinition[] | null;
showIsExpandableHint: boolean;
hintMode: boolean;
hintBuffer: string;
transientMessage: {
@@ -0,0 +1,131 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { gitProvider } from './gitProvider.js';
import * as childProcess from 'node:child_process';
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
return {
...actual,
execFile: vi.fn(),
};
});
describe('gitProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('suggests git subcommands for cursorIndex 1', async () => {
const result = await gitProvider.getCompletions(['git', 'ch'], 1, '/tmp');
expect(result.exclusive).toBe(true);
expect(result.suggestions).toEqual(
expect.arrayContaining([expect.objectContaining({ value: 'checkout' })]),
);
expect(
result.suggestions.find((s) => s.value === 'commit'),
).toBeUndefined();
});
it('suggests branch names for checkout at cursorIndex 2', async () => {
vi.mocked(childProcess.execFile).mockImplementation(
(_cmd, _args, _opts, cb: unknown) => {
const callback = (typeof _opts === 'function' ? _opts : cb) as (
error: Error | null,
result: { stdout: string },
) => void;
callback(null, {
stdout: 'main\nfeature-branch\nfix/bug\nbranch(with)special\n',
});
return {} as ReturnType<typeof childProcess.execFile>;
},
);
const result = await gitProvider.getCompletions(
['git', 'checkout', 'feat'],
2,
'/tmp',
);
expect(result.exclusive).toBe(true);
expect(result.suggestions).toHaveLength(1);
expect(result.suggestions[0].label).toBe('feature-branch');
expect(result.suggestions[0].value).toBe('feature-branch');
expect(childProcess.execFile).toHaveBeenCalledWith(
'git',
['branch', '--format=%(refname:short)'],
expect.any(Object),
expect.any(Function),
);
});
it('escapes branch names with shell metacharacters', async () => {
vi.mocked(childProcess.execFile).mockImplementation(
(_cmd, _args, _opts, cb: unknown) => {
const callback = (typeof _opts === 'function' ? _opts : cb) as (
error: Error | null,
result: { stdout: string },
) => void;
callback(null, { stdout: 'main\nbranch(with)special\n' });
return {} as ReturnType<typeof childProcess.execFile>;
},
);
const result = await gitProvider.getCompletions(
['git', 'checkout', 'branch('],
2,
'/tmp',
);
expect(result.exclusive).toBe(true);
expect(result.suggestions).toHaveLength(1);
expect(result.suggestions[0].label).toBe('branch(with)special');
// On Windows, space escape is not done. But since UNIX_SHELL_SPECIAL_CHARS is mostly tested,
// we can use a matcher that checks if escaping was applied (it differs per platform but that's handled by escapeShellPath).
// Let's match the value against either unescaped (win) or escaped (unix).
const isWin = process.platform === 'win32';
expect(result.suggestions[0].value).toBe(
isWin ? 'branch(with)special' : 'branch\\(with\\)special',
);
});
it('returns empty results if git branch fails', async () => {
vi.mocked(childProcess.execFile).mockImplementation(
(_cmd, _args, _opts, cb: unknown) => {
const callback = (typeof _opts === 'function' ? _opts : cb) as (
error: Error,
stdout?: string,
) => void;
callback(new Error('Not a git repository'));
return {} as ReturnType<typeof childProcess.execFile>;
},
);
const result = await gitProvider.getCompletions(
['git', 'checkout', ''],
2,
'/tmp',
);
expect(result.exclusive).toBe(true);
expect(result.suggestions).toHaveLength(0);
});
it('returns non-exclusive for unrecognized position', async () => {
const result = await gitProvider.getCompletions(
['git', 'commit', '-m', 'some message'],
3,
'/tmp',
);
expect(result.exclusive).toBe(false);
expect(result.suggestions).toHaveLength(0);
});
});
@@ -0,0 +1,93 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import type { ShellCompletionProvider, CompletionResult } from './types.js';
import { escapeShellPath } from '../useShellCompletion.js';
const execFileAsync = promisify(execFile);
const GIT_SUBCOMMANDS = [
'add',
'branch',
'checkout',
'commit',
'diff',
'merge',
'pull',
'push',
'rebase',
'status',
'switch',
];
export const gitProvider: ShellCompletionProvider = {
command: 'git',
async getCompletions(
tokens: string[],
cursorIndex: number,
cwd: string,
signal?: AbortSignal,
): Promise<CompletionResult> {
// We are completing the first argument (subcommand)
if (cursorIndex === 1) {
const partial = tokens[1] || '';
return {
suggestions: GIT_SUBCOMMANDS.filter((cmd) =>
cmd.startsWith(partial),
).map((cmd) => ({
label: cmd,
value: cmd,
description: 'git command',
})),
exclusive: true,
};
}
// We are completing the second argument (e.g. branch name)
if (cursorIndex === 2) {
const subcommand = tokens[1];
if (
subcommand === 'checkout' ||
subcommand === 'switch' ||
subcommand === 'merge' ||
subcommand === 'branch'
) {
const partial = tokens[2] || '';
try {
const { stdout } = await execFileAsync(
'git',
['branch', '--format=%(refname:short)'],
{ cwd, signal },
);
const branches = stdout
.split('\n')
.map((b) => b.trim())
.filter(Boolean);
return {
suggestions: branches
.filter((b) => b.startsWith(partial))
.map((b) => ({
label: b,
value: escapeShellPath(b),
description: 'branch',
})),
exclusive: true,
};
} catch {
// If git fails (e.g. not a git repo), return nothing
return { suggestions: [], exclusive: true };
}
}
}
// Unhandled git argument, fallback to default file completions
return { suggestions: [], exclusive: false };
},
};
@@ -0,0 +1,25 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ShellCompletionProvider, CompletionResult } from './types.js';
import { gitProvider } from './gitProvider.js';
import { npmProvider } from './npmProvider.js';
const providers: ShellCompletionProvider[] = [gitProvider, npmProvider];
export async function getArgumentCompletions(
commandToken: string,
tokens: string[],
cursorIndex: number,
cwd: string,
signal?: AbortSignal,
): Promise<CompletionResult | null> {
const provider = providers.find((p) => p.command === commandToken);
if (!provider) {
return null;
}
return provider.getCompletions(tokens, cursorIndex, cwd, signal);
}
@@ -0,0 +1,106 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { npmProvider } from './npmProvider.js';
import * as fs from 'node:fs/promises';
vi.mock('node:fs/promises', () => ({
readFile: vi.fn(),
}));
describe('npmProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('suggests npm subcommands for cursorIndex 1', async () => {
const result = await npmProvider.getCompletions(['npm', 'ru'], 1, '/tmp');
expect(result.exclusive).toBe(true);
expect(result.suggestions).toEqual([
expect.objectContaining({ value: 'run' }),
]);
});
it('suggests package.json scripts for npm run at cursorIndex 2', async () => {
const mockPackageJson = {
scripts: {
start: 'node index.js',
build: 'tsc',
'build:dev': 'tsc --watch',
},
};
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockPackageJson));
const result = await npmProvider.getCompletions(
['npm', 'run', 'bu'],
2,
'/tmp',
);
expect(result.exclusive).toBe(true);
expect(result.suggestions).toHaveLength(2);
expect(result.suggestions[0].label).toBe('build');
expect(result.suggestions[0].value).toBe('build');
expect(result.suggestions[1].label).toBe('build:dev');
expect(result.suggestions[1].value).toBe('build:dev');
expect(fs.readFile).toHaveBeenCalledWith(
expect.stringContaining('package.json'),
'utf8',
);
});
it('escapes script names with shell metacharacters', async () => {
const mockPackageJson = {
scripts: {
'build(prod)': 'tsc',
'test:watch': 'vitest',
},
};
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockPackageJson));
const result = await npmProvider.getCompletions(
['npm', 'run', 'bu'],
2,
'/tmp',
);
expect(result.exclusive).toBe(true);
expect(result.suggestions).toHaveLength(1);
expect(result.suggestions[0].label).toBe('build(prod)');
// Windows does not escape spaces/parens in cmds by default in our function, but Unix does.
const isWin = process.platform === 'win32';
expect(result.suggestions[0].value).toBe(
isWin ? 'build(prod)' : 'build\\(prod\\)',
);
});
it('handles missing package.json gracefully', async () => {
vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT'));
const result = await npmProvider.getCompletions(
['npm', 'run', ''],
2,
'/tmp',
);
expect(result.exclusive).toBe(true);
expect(result.suggestions).toHaveLength(0);
});
it('returns non-exclusive for unrecognized position', async () => {
const result = await npmProvider.getCompletions(
['npm', 'install', 'react'],
2,
'/tmp',
);
expect(result.exclusive).toBe(false);
expect(result.suggestions).toHaveLength(0);
});
});
@@ -0,0 +1,81 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type { ShellCompletionProvider, CompletionResult } from './types.js';
import { escapeShellPath } from '../useShellCompletion.js';
const NPM_SUBCOMMANDS = [
'build',
'ci',
'dev',
'install',
'publish',
'run',
'start',
'test',
];
export const npmProvider: ShellCompletionProvider = {
command: 'npm',
async getCompletions(
tokens: string[],
cursorIndex: number,
cwd: string,
signal?: AbortSignal,
): Promise<CompletionResult> {
if (cursorIndex === 1) {
const partial = tokens[1] || '';
return {
suggestions: NPM_SUBCOMMANDS.filter((cmd) =>
cmd.startsWith(partial),
).map((cmd) => ({
label: cmd,
value: cmd,
description: 'npm command',
})),
exclusive: true,
};
}
if (cursorIndex === 2 && tokens[1] === 'run') {
const partial = tokens[2] || '';
try {
if (signal?.aborted) return { suggestions: [], exclusive: true };
const pkgJsonPath = path.join(cwd, 'package.json');
const content = await fs.readFile(pkgJsonPath, 'utf8');
const pkg = JSON.parse(content) as unknown;
const scripts =
pkg &&
typeof pkg === 'object' &&
'scripts' in pkg &&
pkg.scripts &&
typeof pkg.scripts === 'object'
? Object.keys(pkg.scripts)
: [];
return {
suggestions: scripts
.filter((s) => s.startsWith(partial))
.map((s) => ({
label: s,
value: escapeShellPath(s),
description: 'npm script',
})),
exclusive: true,
};
} catch {
// No package.json or invalid JSON
return { suggestions: [], exclusive: true };
}
}
return { suggestions: [], exclusive: false };
},
};
@@ -0,0 +1,24 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Suggestion } from '../../components/SuggestionsDisplay.js';
export interface CompletionResult {
suggestions: Suggestion[];
// If true, this prevents the shell from appending generic file/path completions
// to this list. Use this when the tool expects ONLY specific values (e.g. branches).
exclusive?: boolean;
}
export interface ShellCompletionProvider {
command: string; // The command trigger, e.g., 'git' or 'npm'
getCompletions(
tokens: string[], // List of arguments parsed from the input
cursorIndex: number, // Which token index the cursor is currently on
cwd: string,
signal?: AbortSignal,
): Promise<CompletionResult>;
}
@@ -170,6 +170,7 @@ async function searchResourceCandidates(
const results = await fzf.find(normalizedPattern, {
limit: MAX_SUGGESTIONS_TO_SHOW * 3,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return results.map(
(result: { item: ResourceSuggestionCandidate }) => result.item.suggestion,
);
@@ -193,6 +194,7 @@ async function searchAgentCandidates(
const results = await fzf.find(normalizedPattern, {
limit: MAX_SUGGESTIONS_TO_SHOW,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return results.map((r: { item: Suggestion }) => r.item);
}
@@ -28,6 +28,7 @@ import type { UseAtCompletionProps } from './useAtCompletion.js';
import { useAtCompletion } from './useAtCompletion.js';
import type { UseSlashCompletionProps } from './useSlashCompletion.js';
import { useSlashCompletion } from './useSlashCompletion.js';
import { useShellCompletion } from './useShellCompletion.js';
vi.mock('./useAtCompletion', () => ({
useAtCompletion: vi.fn(),
@@ -40,19 +41,35 @@ vi.mock('./useSlashCompletion', () => ({
})),
}));
vi.mock('./useShellCompletion', () => ({
useShellCompletion: vi.fn(() => ({
completionStart: 0,
completionEnd: 0,
query: '',
})),
}));
// Helper to set up mocks in a consistent way for both child hooks
const setupMocks = ({
atSuggestions = [],
slashSuggestions = [],
shellSuggestions = [],
isLoading = false,
isPerfectMatch = false,
slashCompletionRange = { completionStart: 0, completionEnd: 0 },
shellCompletionRange = { completionStart: 0, completionEnd: 0, query: '' },
}: {
atSuggestions?: Suggestion[];
slashSuggestions?: Suggestion[];
shellSuggestions?: Suggestion[];
isLoading?: boolean;
isPerfectMatch?: boolean;
slashCompletionRange?: { completionStart: number; completionEnd: number };
shellCompletionRange?: {
completionStart: number;
completionEnd: number;
query: string;
};
}) => {
// Mock for @-completions
(useAtCompletion as Mock).mockImplementation(
@@ -89,6 +106,19 @@ const setupMocks = ({
return slashCompletionRange;
},
);
// Mock for shell completions
(useShellCompletion as Mock).mockImplementation(
({ enabled, setSuggestions, setIsLoadingSuggestions }) => {
useEffect(() => {
if (enabled) {
setIsLoadingSuggestions(isLoading);
setSuggestions(shellSuggestions);
}
}, [enabled, setSuggestions, setIsLoadingSuggestions]);
return shellCompletionRange;
},
);
};
describe('useCommandCompletion', () => {
@@ -13,6 +13,7 @@ import { isSlashCommand } from '../utils/commandUtils.js';
import { toCodePoints } from '../utils/textUtils.js';
import { useAtCompletion } from './useAtCompletion.js';
import { useSlashCompletion } from './useSlashCompletion.js';
import { useShellCompletion } from './useShellCompletion.js';
import type { PromptCompletion } from './usePromptCompletion.js';
import {
usePromptCompletion,
@@ -26,6 +27,7 @@ export enum CompletionMode {
AT = 'AT',
SLASH = 'SLASH',
PROMPT = 'PROMPT',
SHELL = 'SHELL',
}
export interface UseCommandCompletionReturn {
@@ -99,91 +101,105 @@ export function useCommandCompletion({
const cursorRow = buffer.cursor[0];
const cursorCol = buffer.cursor[1];
const { completionMode, query, completionStart, completionEnd } =
useMemo(() => {
const currentLine = buffer.lines[cursorRow] || '';
const codePoints = toCodePoints(currentLine);
// FIRST: Check for @ completion (scan backwards from cursor)
// This must happen before slash command check so that `/cmd @file`
// triggers file completion, not just slash command completion.
for (let i = cursorCol - 1; i >= 0; i--) {
const char = codePoints[i];
if (char === ' ') {
let backslashCount = 0;
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
backslashCount++;
}
if (backslashCount % 2 === 0) {
break;
}
} else if (char === '@') {
let end = codePoints.length;
for (let i = cursorCol; i < codePoints.length; i++) {
if (codePoints[i] === ' ') {
let backslashCount = 0;
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
backslashCount++;
}
if (backslashCount % 2 === 0) {
end = i;
break;
}
}
}
const pathStart = i + 1;
const partialPath = currentLine.substring(pathStart, end);
return {
completionMode: CompletionMode.AT,
query: partialPath,
completionStart: pathStart,
completionEnd: end,
};
}
}
// THEN: Check for slash command (only if no @ completion is active)
if (cursorRow === 0 && isSlashCommand(currentLine.trim())) {
return {
completionMode: CompletionMode.SLASH,
query: currentLine,
completionStart: 0,
completionEnd: currentLine.length,
};
}
// Check for prompt completion - only if enabled
const trimmedText = buffer.text.trim();
const isPromptCompletionEnabled =
config?.getEnablePromptCompletion() ?? false;
if (
isPromptCompletionEnabled &&
trimmedText.length >= PROMPT_COMPLETION_MIN_LENGTH &&
!isSlashCommand(trimmedText) &&
!trimmedText.includes('@')
) {
return {
completionMode: CompletionMode.PROMPT,
query: trimmedText,
completionStart: 0,
completionEnd: trimmedText.length,
};
}
const {
completionMode,
query: memoQuery,
completionStart,
completionEnd,
} = useMemo(() => {
const currentLine = buffer.lines[cursorRow] || '';
const codePoints = toCodePoints(currentLine);
if (shellModeActive) {
return {
completionMode: CompletionMode.IDLE,
query: null,
completionMode:
currentLine.trim().length === 0
? CompletionMode.IDLE
: CompletionMode.SHELL,
query: '',
completionStart: -1,
completionEnd: -1,
};
}, [cursorRow, cursorCol, buffer.lines, buffer.text, config]);
}
// FIRST: Check for @ completion (scan backwards from cursor)
// This must happen before slash command check so that `/cmd @file`
// triggers file completion, not just slash command completion.
for (let i = cursorCol - 1; i >= 0; i--) {
const char = codePoints[i];
if (char === ' ') {
let backslashCount = 0;
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
backslashCount++;
}
if (backslashCount % 2 === 0) {
break;
}
} else if (char === '@') {
let end = codePoints.length;
for (let i = cursorCol; i < codePoints.length; i++) {
if (codePoints[i] === ' ') {
let backslashCount = 0;
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
backslashCount++;
}
if (backslashCount % 2 === 0) {
end = i;
break;
}
}
}
const pathStart = i + 1;
const partialPath = currentLine.substring(pathStart, end);
return {
completionMode: CompletionMode.AT,
query: partialPath,
completionStart: pathStart,
completionEnd: end,
};
}
}
// THEN: Check for slash command (only if no @ completion is active)
if (cursorRow === 0 && isSlashCommand(currentLine.trim())) {
return {
completionMode: CompletionMode.SLASH,
query: currentLine,
completionStart: 0,
completionEnd: currentLine.length,
};
}
// Check for prompt completion - only if enabled
const trimmedText = buffer.text.trim();
const isPromptCompletionEnabled = false;
if (
isPromptCompletionEnabled &&
trimmedText.length >= PROMPT_COMPLETION_MIN_LENGTH &&
!isSlashCommand(trimmedText) &&
!trimmedText.includes('@')
) {
return {
completionMode: CompletionMode.PROMPT,
query: trimmedText,
completionStart: 0,
completionEnd: trimmedText.length,
};
}
return {
completionMode: CompletionMode.IDLE,
query: null,
completionStart: -1,
completionEnd: -1,
};
}, [cursorRow, cursorCol, buffer.lines, buffer.text, shellModeActive]);
useAtCompletion({
enabled: active && completionMode === CompletionMode.AT,
pattern: query || '',
pattern: memoQuery || '',
config,
cwd,
setSuggestions,
@@ -193,7 +209,7 @@ export function useCommandCompletion({
const slashCompletionRange = useSlashCompletion({
enabled:
active && completionMode === CompletionMode.SLASH && !shellModeActive,
query,
query: memoQuery,
slashCommands,
commandContext,
setSuggestions,
@@ -201,10 +217,22 @@ export function useCommandCompletion({
setIsPerfectMatch,
});
const shellCompletionRange = useShellCompletion({
enabled: active && completionMode === CompletionMode.SHELL,
line: buffer.lines[cursorRow] || '',
cursorCol,
cwd,
setSuggestions,
setIsLoadingSuggestions,
});
const query =
completionMode === CompletionMode.SHELL
? shellCompletionRange.query
: memoQuery;
const promptCompletion = usePromptCompletion({
buffer,
config,
enabled: active && completionMode === CompletionMode.PROMPT,
});
useEffect(() => {
@@ -261,6 +289,9 @@ export function useCommandCompletion({
if (completionMode === CompletionMode.SLASH) {
start = slashCompletionRange.completionStart;
end = slashCompletionRange.completionEnd;
} else if (completionMode === CompletionMode.SHELL) {
start = shellCompletionRange.completionStart;
end = shellCompletionRange.completionEnd;
}
if (start === -1 || end === -1) {
@@ -290,6 +321,7 @@ export function useCommandCompletion({
completionStart,
completionEnd,
slashCompletionRange,
shellCompletionRange,
],
);
@@ -310,6 +342,9 @@ export function useCommandCompletion({
if (completionMode === CompletionMode.SLASH) {
start = slashCompletionRange.completionStart;
end = slashCompletionRange.completionEnd;
} else if (completionMode === CompletionMode.SHELL) {
start = shellCompletionRange.completionStart;
end = shellCompletionRange.completionEnd;
}
// Add space padding for Tab completion (auto-execute gets padding from getCompletedText)
@@ -348,6 +383,7 @@ export function useCommandCompletion({
completionStart,
completionEnd,
slashCompletionRange,
shellCompletionRange,
getCompletedText,
],
);
@@ -26,13 +26,11 @@ export interface PromptCompletion {
export interface UsePromptCompletionOptions {
buffer: TextBuffer;
config?: Config;
enabled: boolean;
}
export function usePromptCompletion({
buffer,
config,
enabled,
}: UsePromptCompletionOptions): PromptCompletion {
const [ghostText, setGhostText] = useState<string>('');
const [isLoadingGhostText, setIsLoadingGhostText] = useState<boolean>(false);
@@ -42,8 +40,7 @@ export function usePromptCompletion({
const lastSelectedTextRef = useRef<string>('');
const lastRequestedTextRef = useRef<string>('');
const isPromptCompletionEnabled =
enabled && (config?.getEnablePromptCompletion() ?? false);
const isPromptCompletionEnabled = false;
const clearGhostText = useCallback(() => {
setGhostText('');
@@ -20,7 +20,10 @@ import {
type MessageRecord,
CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { coreEvents } from '@google/gemini-cli-core';
import {
coreEvents,
convertSessionToClientHistory,
} from '@google/gemini-cli-core';
// Mock modules
vi.mock('fs/promises');
@@ -157,7 +160,7 @@ describe('convertSessionToHistoryFormats', () => {
it('should convert empty messages array', () => {
const result = convertSessionToHistoryFormats([]);
expect(result.uiHistory).toEqual([]);
expect(result.clientHistory).toEqual([]);
expect(convertSessionToClientHistory([])).toEqual([]);
});
it('should convert basic user and model messages', () => {
@@ -175,12 +178,13 @@ describe('convertSessionToHistoryFormats', () => {
text: 'Hi there',
});
expect(result.clientHistory).toHaveLength(2);
expect(result.clientHistory[0]).toEqual({
const clientHistory = convertSessionToClientHistory(messages);
expect(clientHistory).toHaveLength(2);
expect(clientHistory[0]).toEqual({
role: 'user',
parts: [{ text: 'Hello' }],
});
expect(result.clientHistory[1]).toEqual({
expect(clientHistory[1]).toEqual({
role: 'model',
parts: [{ text: 'Hi there' }],
});
@@ -203,8 +207,9 @@ describe('convertSessionToHistoryFormats', () => {
text: 'User input',
});
expect(result.clientHistory).toHaveLength(1);
expect(result.clientHistory[0]).toEqual({
const clientHistory = convertSessionToClientHistory(messages);
expect(clientHistory).toHaveLength(1);
expect(clientHistory[0]).toEqual({
role: 'user',
parts: [{ text: 'Expanded content' }],
});
@@ -225,7 +230,7 @@ describe('convertSessionToHistoryFormats', () => {
text: 'Help text',
});
expect(result.clientHistory).toHaveLength(0);
expect(convertSessionToClientHistory(messages)).toHaveLength(0);
});
it('should handle tool calls and responses', () => {
@@ -264,12 +269,13 @@ describe('convertSessionToHistoryFormats', () => {
],
});
expect(result.clientHistory).toHaveLength(3); // User, Model (call), User (response)
expect(result.clientHistory[0]).toEqual({
const clientHistory = convertSessionToClientHistory(messages);
expect(clientHistory).toHaveLength(3); // User, Model (call), User (response)
expect(clientHistory[0]).toEqual({
role: 'user',
parts: [{ text: 'What time is it?' }],
});
expect(result.clientHistory[1]).toEqual({
expect(clientHistory[1]).toEqual({
role: 'model',
parts: [
{
@@ -281,7 +287,7 @@ describe('convertSessionToHistoryFormats', () => {
},
],
});
expect(result.clientHistory[2]).toEqual({
expect(clientHistory[2]).toEqual({
role: 'user',
parts: [
{
@@ -13,7 +13,10 @@ import type {
ConversationRecord,
ResumedSessionData,
} from '@google/gemini-cli-core';
import { coreEvents } from '@google/gemini-cli-core';
import {
coreEvents,
convertSessionToClientHistory,
} from '@google/gemini-cli-core';
import type { SessionInfo } from '../../utils/sessionUtils.js';
import { convertSessionToHistoryFormats } from '../../utils/sessionUtils.js';
import type { Part } from '@google/genai';
@@ -78,7 +81,7 @@ export const useSessionBrowser = (
);
await onLoadHistory(
historyData.uiHistory,
historyData.clientHistory,
convertSessionToClientHistory(conversation.messages),
resumedSessionData,
);
} catch (error) {
@@ -9,6 +9,7 @@ import {
coreEvents,
type Config,
type ResumedSessionData,
convertSessionToClientHistory,
} from '@google/gemini-cli-core';
import type { Part } from '@google/genai';
import type { HistoryItemWithoutId } from '../types.js';
@@ -113,7 +114,7 @@ export function useSessionResume({
);
void loadHistoryForResume(
historyData.uiHistory,
historyData.clientHistory,
convertSessionToClientHistory(resumedSessionData.conversation.messages),
resumedSessionData,
);
}
@@ -0,0 +1,382 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import {
getTokenAtCursor,
escapeShellPath,
resolvePathCompletions,
scanPathExecutables,
} from './useShellCompletion.js';
import type { FileSystemStructure } from '@google/gemini-cli-test-utils';
import { createTmpDir, cleanupTmpDir } from '@google/gemini-cli-test-utils';
describe('useShellCompletion utilities', () => {
describe('getTokenAtCursor', () => {
it('should return empty token struct for empty line', () => {
expect(getTokenAtCursor('', 0)).toEqual({
token: '',
start: 0,
end: 0,
isFirstToken: true,
tokens: [''],
cursorIndex: 0,
commandToken: '',
});
});
it('should extract the first token at cursor position 0', () => {
const result = getTokenAtCursor('git status', 3);
expect(result).toEqual({
token: 'git',
start: 0,
end: 3,
isFirstToken: true,
tokens: ['git', 'status'],
cursorIndex: 0,
commandToken: 'git',
});
});
it('should extract the second token when cursor is on it', () => {
const result = getTokenAtCursor('git status', 7);
expect(result).toEqual({
token: 'status',
start: 4,
end: 10,
isFirstToken: false,
tokens: ['git', 'status'],
cursorIndex: 1,
commandToken: 'git',
});
});
it('should handle cursor at start of second token', () => {
const result = getTokenAtCursor('git status', 4);
expect(result).toEqual({
token: 'status',
start: 4,
end: 10,
isFirstToken: false,
tokens: ['git', 'status'],
cursorIndex: 1,
commandToken: 'git',
});
});
it('should handle escaped spaces', () => {
const result = getTokenAtCursor('cat my\\ file.txt', 16);
expect(result).toEqual({
token: 'my file.txt',
start: 4,
end: 16,
isFirstToken: false,
tokens: ['cat', 'my file.txt'],
cursorIndex: 1,
commandToken: 'cat',
});
});
it('should handle single-quoted strings', () => {
const result = getTokenAtCursor("cat 'my file.txt'", 17);
expect(result).toEqual({
token: 'my file.txt',
start: 4,
end: 17,
isFirstToken: false,
tokens: ['cat', 'my file.txt'],
cursorIndex: 1,
commandToken: 'cat',
});
});
it('should handle double-quoted strings', () => {
const result = getTokenAtCursor('cat "my file.txt"', 17);
expect(result).toEqual({
token: 'my file.txt',
start: 4,
end: 17,
isFirstToken: false,
tokens: ['cat', 'my file.txt'],
cursorIndex: 1,
commandToken: 'cat',
});
});
it('should handle cursor past all tokens (trailing space)', () => {
const result = getTokenAtCursor('git ', 4);
expect(result).toEqual({
token: '',
start: 4,
end: 4,
isFirstToken: false,
tokens: ['git', ''],
cursorIndex: 1,
commandToken: 'git',
});
});
it('should handle cursor in the middle of a word', () => {
const result = getTokenAtCursor('git checkout main', 7);
expect(result).toEqual({
token: 'checkout',
start: 4,
end: 12,
isFirstToken: false,
tokens: ['git', 'checkout', 'main'],
cursorIndex: 1,
commandToken: 'git',
});
});
it('should mark isFirstToken correctly for first word', () => {
const result = getTokenAtCursor('gi', 2);
expect(result?.isFirstToken).toBe(true);
});
it('should mark isFirstToken correctly for second word', () => {
const result = getTokenAtCursor('git sta', 7);
expect(result?.isFirstToken).toBe(false);
});
});
describe('escapeShellPath', () => {
it('should escape spaces', () => {
expect(escapeShellPath('my file.txt')).toBe('my\\ file.txt');
});
it('should escape parentheses', () => {
expect(escapeShellPath('file (copy).txt')).toBe('file\\ \\(copy\\).txt');
});
it('should not escape normal characters', () => {
expect(escapeShellPath('normal-file.txt')).toBe('normal-file.txt');
});
it('should escape tabs, newlines, carriage returns, and backslashes', () => {
expect(escapeShellPath('a\tb')).toBe('a\\\tb');
expect(escapeShellPath('a\nb')).toBe('a\\\nb');
expect(escapeShellPath('a\rb')).toBe('a\\\rb');
expect(escapeShellPath('a\\b')).toBe('a\\\\b');
});
it('should handle empty string', () => {
expect(escapeShellPath('')).toBe('');
});
});
describe('resolvePathCompletions', () => {
let tmpDir: string;
afterEach(async () => {
if (tmpDir) {
await cleanupTmpDir(tmpDir);
}
});
it('should list directory contents for empty partial', async () => {
const structure: FileSystemStructure = {
'file.txt': '',
subdir: {},
};
tmpDir = await createTmpDir(structure);
const results = await resolvePathCompletions('', tmpDir);
const values = results.map((s) => s.label);
expect(values).toContain('subdir/');
expect(values).toContain('file.txt');
});
it('should filter by prefix', async () => {
const structure: FileSystemStructure = {
'abc.txt': '',
'def.txt': '',
};
tmpDir = await createTmpDir(structure);
const results = await resolvePathCompletions('a', tmpDir);
expect(results).toHaveLength(1);
expect(results[0].label).toBe('abc.txt');
});
it('should match case-insensitively', async () => {
const structure: FileSystemStructure = {
Desktop: {},
};
tmpDir = await createTmpDir(structure);
const results = await resolvePathCompletions('desk', tmpDir);
expect(results).toHaveLength(1);
expect(results[0].label).toBe('Desktop/');
});
it('should append trailing slash to directories', async () => {
const structure: FileSystemStructure = {
mydir: {},
'myfile.txt': '',
};
tmpDir = await createTmpDir(structure);
const results = await resolvePathCompletions('my', tmpDir);
const dirSuggestion = results.find((s) => s.label.startsWith('mydir'));
expect(dirSuggestion?.label).toBe('mydir/');
expect(dirSuggestion?.description).toBe('directory');
});
it('should hide dotfiles by default', async () => {
const structure: FileSystemStructure = {
'.hidden': '',
visible: '',
};
tmpDir = await createTmpDir(structure);
const results = await resolvePathCompletions('', tmpDir);
const labels = results.map((s) => s.label);
expect(labels).not.toContain('.hidden');
expect(labels).toContain('visible');
});
it('should show dotfiles when query starts with a dot', async () => {
const structure: FileSystemStructure = {
'.hidden': '',
'.bashrc': '',
visible: '',
};
tmpDir = await createTmpDir(structure);
const results = await resolvePathCompletions('.h', tmpDir);
const labels = results.map((s) => s.label);
expect(labels).toContain('.hidden');
});
it('should show dotfiles in the current directory when query is exactly "."', async () => {
const structure: FileSystemStructure = {
'.hidden': '',
'.bashrc': '',
visible: '',
};
tmpDir = await createTmpDir(structure);
const results = await resolvePathCompletions('.', tmpDir);
const labels = results.map((s) => s.label);
expect(labels).toContain('.hidden');
expect(labels).toContain('.bashrc');
expect(labels).not.toContain('visible');
});
it('should handle dotfile completions within a subdirectory', async () => {
const structure: FileSystemStructure = {
subdir: {
'.secret': '',
'public.txt': '',
},
};
tmpDir = await createTmpDir(structure);
const results = await resolvePathCompletions('subdir/.', tmpDir);
const labels = results.map((s) => s.label);
expect(labels).toContain('.secret');
expect(labels).not.toContain('public.txt');
});
it('should strip leading quotes to resolve inner directory contents', async () => {
const structure: FileSystemStructure = {
src: {
'index.ts': '',
},
};
tmpDir = await createTmpDir(structure);
const results = await resolvePathCompletions('"src/', tmpDir);
expect(results).toHaveLength(1);
expect(results[0].label).toBe('index.ts');
const resultsSingleQuote = await resolvePathCompletions("'src/", tmpDir);
expect(resultsSingleQuote).toHaveLength(1);
expect(resultsSingleQuote[0].label).toBe('index.ts');
});
it('should properly escape resolutions with spaces inside stripped quote queries', async () => {
const structure: FileSystemStructure = {
'Folder With Spaces': {},
};
tmpDir = await createTmpDir(structure);
const results = await resolvePathCompletions('"Fo', tmpDir);
expect(results).toHaveLength(1);
expect(results[0].label).toBe('Folder With Spaces/');
expect(results[0].value).toBe(escapeShellPath('Folder With Spaces/'));
});
it('should return empty array for non-existent directory', async () => {
const results = await resolvePathCompletions(
'/nonexistent/path/foo',
'/tmp',
);
expect(results).toEqual([]);
});
it('should handle tilde expansion', async () => {
// Just ensure ~ doesn't throw
const results = await resolvePathCompletions('~/', '/tmp');
// We can't assert specific files since it depends on the test runner's home
expect(Array.isArray(results)).toBe(true);
});
it('should escape special characters in results', async () => {
const structure: FileSystemStructure = {
'my file.txt': '',
};
tmpDir = await createTmpDir(structure);
const results = await resolvePathCompletions('my', tmpDir);
expect(results).toHaveLength(1);
expect(results[0].value).toBe('my\\ file.txt');
});
it('should sort directories before files', async () => {
const structure: FileSystemStructure = {
'b-file.txt': '',
'a-dir': {},
};
tmpDir = await createTmpDir(structure);
const results = await resolvePathCompletions('', tmpDir);
expect(results[0].description).toBe('directory');
expect(results[1].description).toBe('file');
});
});
describe('scanPathExecutables', () => {
it('should return an array of executables', async () => {
const results = await scanPathExecutables();
expect(Array.isArray(results)).toBe(true);
// Very basic sanity check: common commands should be found
if (process.platform !== 'win32') {
expect(results).toContain('ls');
} else {
expect(results).toContain('dir');
expect(results).toContain('cls');
expect(results).toContain('copy');
}
});
it('should support abort signal', async () => {
const controller = new AbortController();
controller.abort();
const results = await scanPathExecutables(controller.signal);
// May return empty or partial depending on timing
expect(Array.isArray(results)).toBe(true);
});
it('should handle empty PATH', async () => {
vi.stubEnv('PATH', '');
const results = await scanPathExecutables();
expect(results).toEqual([]);
vi.unstubAllEnvs();
});
});
});
@@ -0,0 +1,620 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, useRef, useCallback, useMemo } from 'react';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
import type { Suggestion } from '../components/SuggestionsDisplay.js';
import { debugLogger } from '@google/gemini-cli-core';
import { getArgumentCompletions } from './shell-completions/index.js';
/**
* Maximum number of suggestions to return to avoid freezing the React Ink UI.
*/
const MAX_SHELL_SUGGESTIONS = 100;
/**
* Debounce interval (ms) for file system completions.
*/
const FS_COMPLETION_DEBOUNCE_MS = 50;
// Backslash-quote shell metacharacters on non-Windows platforms.
// On Unix, backslash-quote shell metacharacters (spaces, parens, etc.).
// On Windows, cmd.exe doesn't use backslash-quoting and `\` is the path
// separator, so we leave the path as-is.
const UNIX_SHELL_SPECIAL_CHARS = /[ \t\n\r'"()&|;<>!#$`{}[\]*?\\]/g;
/**
* Escapes special shell characters in a path segment.
*/
export function escapeShellPath(segment: string): string {
if (process.platform === 'win32') {
return segment;
}
return segment.replace(UNIX_SHELL_SPECIAL_CHARS, '\\$&');
}
export interface TokenInfo {
/** The raw token text (without surrounding quotes but with internal escapes). */
token: string;
/** Offset in the original line where this token begins. */
start: number;
/** Offset in the original line where this token ends (exclusive). */
end: number;
/** Whether this is the first token (command position). */
isFirstToken: boolean;
/** The fully built list of tokens parsing the string. */
tokens: string[];
/** The index in the tokens list where the cursor lies. */
cursorIndex: number;
/** The command token (always tokens[0] if length > 0, otherwise empty string) */
commandToken: string;
}
export function getTokenAtCursor(
line: string,
cursorCol: number,
): TokenInfo | null {
const tokensInfo: Array<{ token: string; start: number; end: number }> = [];
let i = 0;
while (i < line.length) {
// Skip whitespace
if (line[i] === ' ' || line[i] === '\t') {
i++;
continue;
}
const tokenStart = i;
let token = '';
while (i < line.length) {
const ch = line[i];
// Backslash escape: consume the next char literally
if (ch === '\\' && i + 1 < line.length) {
token += line[i + 1];
i += 2;
continue;
}
// Single-quoted string
if (ch === "'") {
i++; // skip opening quote
while (i < line.length && line[i] !== "'") {
token += line[i];
i++;
}
if (i < line.length) i++; // skip closing quote
continue;
}
// Double-quoted string
if (ch === '"') {
i++; // skip opening quote
while (i < line.length && line[i] !== '"') {
if (line[i] === '\\' && i + 1 < line.length) {
token += line[i + 1];
i += 2;
} else {
token += line[i];
i++;
}
}
if (i < line.length) i++; // skip closing quote
continue;
}
// Unquoted whitespace ends the token
if (ch === ' ' || ch === '\t') {
break;
}
token += ch;
i++;
}
tokensInfo.push({ token, start: tokenStart, end: i });
}
const rawTokens = tokensInfo.map((t) => t.token);
const commandToken = rawTokens.length > 0 ? rawTokens[0] : '';
if (tokensInfo.length === 0) {
return {
token: '',
start: cursorCol,
end: cursorCol,
isFirstToken: true,
tokens: [''],
cursorIndex: 0,
commandToken: '',
};
}
// Find the token that contains or is immediately adjacent to the cursor
for (let idx = 0; idx < tokensInfo.length; idx++) {
const t = tokensInfo[idx];
if (cursorCol >= t.start && cursorCol <= t.end) {
return {
token: t.token,
start: t.start,
end: t.end,
isFirstToken: idx === 0,
tokens: rawTokens,
cursorIndex: idx,
commandToken,
};
}
}
// Cursor is past all tokens — treat as starting a new token at cursor position
// (useful when the user types a space and then presses Tab)
return {
token: '',
start: cursorCol,
end: cursorCol,
isFirstToken: tokensInfo.length === 0,
tokens: [...rawTokens, ''],
cursorIndex: rawTokens.length,
commandToken,
};
}
export async function scanPathExecutables(
signal?: AbortSignal,
): Promise<string[]> {
const pathEnv = process.env['PATH'] ?? '';
const dirs = pathEnv.split(path.delimiter).filter(Boolean);
const isWindows = process.platform === 'win32';
const pathExtList = isWindows
? (process.env['PATHEXT'] ?? '.EXE;.CMD;.BAT;.COM')
.split(';')
.filter(Boolean)
.map((e) => e.toLowerCase())
: [];
const seen = new Set<string>();
const executables: string[] = [];
// Add Windows shell built-ins
if (isWindows) {
const builtins = [
'assoc',
'break',
'call',
'cd',
'chcp',
'chdir',
'cls',
'color',
'copy',
'date',
'del',
'dir',
'echo',
'endlocal',
'erase',
'exit',
'for',
'ftype',
'goto',
'if',
'md',
'mkdir',
'mklink',
'move',
'path',
'pause',
'popd',
'prompt',
'pushd',
'rd',
'rem',
'ren',
'rename',
'rmdir',
'set',
'setlocal',
'shift',
'start',
'time',
'title',
'type',
'ver',
'verify',
'vol',
];
for (const builtin of builtins) {
seen.add(builtin);
executables.push(builtin);
}
}
const dirResults = await Promise.all(
dirs.map(async (dir) => {
if (signal?.aborted) return [];
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
const validEntries: string[] = [];
// Check executability in parallel (batched per directory)
await Promise.all(
entries.map(async (entry) => {
if (signal?.aborted) return;
if (!entry.isFile() && !entry.isSymbolicLink()) return;
const name = entry.name;
if (isWindows) {
const ext = path.extname(name).toLowerCase();
if (pathExtList.length > 0 && !pathExtList.includes(ext)) return;
}
try {
await fs.access(
path.join(dir, name),
fs.constants.R_OK | fs.constants.X_OK,
);
validEntries.push(name);
} catch {
// Not executable — skip
}
}),
);
return validEntries;
} catch {
// EACCES, ENOENT, etc. — skip this directory
return [];
}
}),
);
for (const names of dirResults) {
for (const name of names) {
if (!seen.has(name)) {
seen.add(name);
executables.push(name);
}
}
}
executables.sort();
return executables;
}
function expandTilde(inputPath: string): [string, boolean] {
if (
inputPath === '~' ||
inputPath.startsWith('~/') ||
inputPath.startsWith('~' + path.sep)
) {
return [path.join(os.homedir(), inputPath.slice(1)), true];
}
return [inputPath, false];
}
export async function resolvePathCompletions(
partial: string,
cwd: string,
signal?: AbortSignal,
): Promise<Suggestion[]> {
if (partial == null) return [];
// Input Sanitization
let strippedPartial = partial;
if (strippedPartial.startsWith('"') || strippedPartial.startsWith("'")) {
strippedPartial = strippedPartial.slice(1);
}
if (strippedPartial.endsWith('"') || strippedPartial.endsWith("'")) {
strippedPartial = strippedPartial.slice(0, -1);
}
// Normalize separators \ to /
const normalizedPartial = strippedPartial.replace(/\\/g, '/');
const [expandedPartial, didExpandTilde] = expandTilde(normalizedPartial);
// Directory Detection
const endsWithSep =
normalizedPartial.endsWith('/') || normalizedPartial === '';
const dirToRead = endsWithSep
? path.resolve(cwd, expandedPartial)
: path.resolve(cwd, path.dirname(expandedPartial));
const prefix = endsWithSep ? '' : path.basename(expandedPartial);
const prefixLower = prefix.toLowerCase();
const showDotfiles = prefix.startsWith('.');
let entries: Array<import('node:fs').Dirent>;
try {
if (signal?.aborted) return [];
entries = await fs.readdir(dirToRead, { withFileTypes: true });
} catch {
// EACCES, ENOENT, etc.
return [];
}
if (signal?.aborted) return [];
const suggestions: Suggestion[] = [];
for (const entry of entries) {
if (signal?.aborted) break;
const name = entry.name;
// Hide dotfiles unless query starts with '.'
if (name.startsWith('.') && !showDotfiles) continue;
// Case-insensitive matching
if (!name.toLowerCase().startsWith(prefixLower)) continue;
const isDir = entry.isDirectory();
const displayName = isDir ? name + '/' : name;
// Build the completion value relative to what the user typed
let completionValue: string;
if (endsWithSep) {
completionValue = normalizedPartial + displayName;
} else {
const parentPart = normalizedPartial.slice(
0,
normalizedPartial.length - path.basename(normalizedPartial).length,
);
completionValue = parentPart + displayName;
}
// Restore tilde if we expanded it
if (didExpandTilde) {
const homeDir = os.homedir().replace(/\\/g, '/');
if (completionValue.startsWith(homeDir)) {
completionValue = '~' + completionValue.slice(homeDir.length);
}
}
// Output formatting: Escape special characters in the completion value
// Since normalizedPartial stripped quotes, we escape the value directly.
const escapedValue = escapeShellPath(completionValue);
suggestions.push({
label: displayName,
value: escapedValue,
description: isDir ? 'directory' : 'file',
});
if (suggestions.length >= MAX_SHELL_SUGGESTIONS) break;
}
// Sort: directories first, then alphabetically
suggestions.sort((a, b) => {
const aIsDir = a.description === 'directory';
const bIsDir = b.description === 'directory';
if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
return a.label.localeCompare(b.label);
});
return suggestions;
}
export interface UseShellCompletionProps {
/** Whether shell completion is active. */
enabled: boolean;
/** The current line text. */
line: string;
/** The current cursor column. */
cursorCol: number;
/** The current working directory for path resolution. */
cwd: string;
/** Callback to set suggestions on the parent state. */
setSuggestions: (suggestions: Suggestion[]) => void;
/** Callback to set loading state on the parent. */
setIsLoadingSuggestions: (isLoading: boolean) => void;
}
export interface UseShellCompletionReturn {
completionStart: number;
completionEnd: number;
query: string;
}
export function useShellCompletion({
enabled,
line,
cursorCol,
cwd,
setSuggestions,
setIsLoadingSuggestions,
}: UseShellCompletionProps): UseShellCompletionReturn {
const pathCachePromiseRef = useRef<Promise<string[]> | null>(null);
const pathEnvRef = useRef<string>(process.env['PATH'] ?? '');
const abortRef = useRef<AbortController | null>(null);
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const tokenInfo = useMemo(
() => (enabled ? getTokenAtCursor(line, cursorCol) : null),
[enabled, line, cursorCol],
);
const {
token: query = '',
start: completionStart = -1,
end: completionEnd = -1,
isFirstToken: isCommandPosition = false,
tokens = [],
cursorIndex = -1,
commandToken = '',
} = tokenInfo || {};
// Invalidate PATH cache when $PATH changes
useEffect(() => {
const currentPath = process.env['PATH'] ?? '';
if (currentPath !== pathEnvRef.current) {
pathCachePromiseRef.current = null;
pathEnvRef.current = currentPath;
}
});
const performCompletion = useCallback(async () => {
if (!enabled || !tokenInfo) {
setSuggestions([]);
return;
}
// Skip flags
if (query.startsWith('-')) {
setSuggestions([]);
return;
}
// Cancel any in-flight request
if (abortRef.current) {
abortRef.current.abort();
}
const controller = new AbortController();
abortRef.current = controller;
const { signal } = controller;
try {
let results: Suggestion[];
if (isCommandPosition) {
setIsLoadingSuggestions(true);
if (!pathCachePromiseRef.current) {
// We don't pass the signal here because we want the cache to finish
// even if this specific completion request is aborted.
pathCachePromiseRef.current = scanPathExecutables();
}
const executables = await pathCachePromiseRef.current;
if (signal.aborted) return;
const queryLower = query.toLowerCase();
results = executables
.filter((cmd) => cmd.toLowerCase().startsWith(queryLower))
.sort((a, b) => {
// Prioritize shorter commands as they are likely common built-ins
if (a.length !== b.length) {
return a.length - b.length;
}
return a.localeCompare(b);
})
.slice(0, MAX_SHELL_SUGGESTIONS)
.map((cmd) => ({
label: cmd,
value: escapeShellPath(cmd),
description: 'command',
}));
} else {
const argumentCompletions = await getArgumentCompletions(
commandToken,
tokens,
cursorIndex,
cwd,
signal,
);
if (signal.aborted) return;
if (argumentCompletions?.exclusive) {
results = argumentCompletions.suggestions;
} else {
const pathSuggestions = await resolvePathCompletions(
query,
cwd,
signal,
);
if (signal.aborted) return;
results = [
...(argumentCompletions?.suggestions ?? []),
...pathSuggestions,
].slice(0, MAX_SHELL_SUGGESTIONS);
}
}
if (signal.aborted) return;
setSuggestions(results);
} catch (error) {
if (
!(
signal.aborted ||
(error instanceof Error && error.name === 'AbortError')
)
) {
debugLogger.warn(
`[WARN] shell completion failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
if (!signal.aborted) {
setSuggestions([]);
}
} finally {
if (!signal.aborted) {
setIsLoadingSuggestions(false);
}
}
}, [
enabled,
tokenInfo,
query,
isCommandPosition,
tokens,
cursorIndex,
commandToken,
cwd,
setSuggestions,
setIsLoadingSuggestions,
]);
useEffect(() => {
if (!enabled) {
setSuggestions([]);
setIsLoadingSuggestions(false);
}
}, [enabled, setSuggestions, setIsLoadingSuggestions]);
// Debounced effect to trigger completion
useEffect(() => {
if (!enabled) return;
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
performCompletion();
}, FS_COMPLETION_DEBOUNCE_MS);
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, [enabled, performCompletion]);
// Cleanup on unmount
useEffect(
() => () => {
abortRef.current?.abort();
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
},
[],
);
return {
completionStart,
completionEnd,
query,
};
}
+3 -5
View File
@@ -22,7 +22,6 @@ import {
} from '../components/shared/MaxSizedBox.js';
import type { LoadedSettings } from '../../config/settings.js';
import { debugLogger } from '@google/gemini-cli-core';
import { isAlternateBufferEnabled } from '../hooks/useAlternateBuffer.js';
// Configure theming and parsing utilities.
const lowlight = createLowlight(common);
@@ -152,7 +151,6 @@ export function colorizeCode({
? false
: settings.merged.ui.showLineNumbers;
const useMaxSizedBox = !isAlternateBufferEnabled(settings);
try {
// Render the HAST tree using the adapted theme
// Apply the theme's default foreground color to the top-level Text element
@@ -162,7 +160,7 @@ export function colorizeCode({
let hiddenLinesCount = 0;
// Optimization to avoid highlighting lines that cannot possibly be displayed.
if (availableHeight !== undefined && useMaxSizedBox) {
if (availableHeight !== undefined) {
availableHeight = Math.max(availableHeight, MINIMUM_MAX_HEIGHT);
if (lines.length > availableHeight) {
const sliceIndex = lines.length - availableHeight;
@@ -200,7 +198,7 @@ export function colorizeCode({
);
});
if (useMaxSizedBox) {
if (availableHeight !== undefined) {
return (
<MaxSizedBox
maxHeight={availableHeight}
@@ -244,7 +242,7 @@ export function colorizeCode({
</Box>
));
if (useMaxSizedBox) {
if (availableHeight !== undefined) {
return (
<MaxSizedBox
maxHeight={availableHeight}
+5 -1
View File
@@ -193,7 +193,11 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
const wrappedRows = styledRows.map((row) => wrapAndProcessRow(row));
// Use the TIGHTEST widths that fit the wrapped content + padding
const adjustedWidths = actualColumnWidths.map((w) => w + COLUMN_PADDING);
const adjustedWidths = actualColumnWidths.map(
(w) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
w + COLUMN_PADDING,
);
return { wrappedHeaders, wrappedRows, adjustedWidths };
}, [styledHeaders, styledRows, terminalWidth]);
@@ -0,0 +1,110 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
ACTIVE_SHELL_MAX_LINES,
COMPLETED_SHELL_MAX_LINES,
} from '../constants.js';
import { CoreToolCallStatus } from '@google/gemini-cli-core';
/**
* Constants used for calculating available height for tool results.
* These MUST be kept in sync between ToolGroupMessage (for overflow detection)
* and ToolResultDisplay (for actual truncation).
*/
export const TOOL_RESULT_STATIC_HEIGHT = 1;
export const TOOL_RESULT_ASB_RESERVED_LINE_COUNT = 6;
export const TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT = 2;
export const TOOL_RESULT_MIN_LINES_SHOWN = 2;
/**
* Calculates the final height available for the content of a tool result display.
*
* This accounts for:
* 1. The static height of the tool message (name, status line).
* 2. Reserved space for hints and padding (different in ASB vs Standard mode).
* 3. Enforcing a minimum number of lines shown.
*/
export function calculateToolContentMaxLines(options: {
availableTerminalHeight: number | undefined;
isAlternateBuffer: boolean;
maxLinesLimit?: number;
}): number | undefined {
const { availableTerminalHeight, isAlternateBuffer, maxLinesLimit } = options;
const reservedLines = isAlternateBuffer
? TOOL_RESULT_ASB_RESERVED_LINE_COUNT
: TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT;
let contentHeight = availableTerminalHeight
? Math.max(
availableTerminalHeight - TOOL_RESULT_STATIC_HEIGHT - reservedLines,
TOOL_RESULT_MIN_LINES_SHOWN + 1,
)
: undefined;
if (maxLinesLimit) {
contentHeight =
contentHeight !== undefined
? Math.min(contentHeight, maxLinesLimit)
: maxLinesLimit;
}
return contentHeight;
}
/**
* Calculates the maximum number of lines to display for shell output.
*
* This logic distinguishes between:
* 1. Process Status: Active (Executing) vs Completed.
* 2. UI Focus: Whether the user is currently interacting with the shell.
* 3. Expansion State: Whether the user has explicitly requested to "Show More Lines" (CTRL+O).
*/
export function calculateShellMaxLines(options: {
status: CoreToolCallStatus;
isAlternateBuffer: boolean;
isThisShellFocused: boolean;
availableTerminalHeight: number | undefined;
constrainHeight: boolean;
isExpandable: boolean | undefined;
}): number | undefined {
const {
status,
isAlternateBuffer,
isThisShellFocused,
availableTerminalHeight,
constrainHeight,
isExpandable,
} = options;
// 1. If the user explicitly requested expansion (unconstrained), remove all caps.
if (!constrainHeight && isExpandable) {
return undefined;
}
// 2. Handle cases where height is unknown (Standard mode history).
if (availableTerminalHeight === undefined) {
return isAlternateBuffer ? ACTIVE_SHELL_MAX_LINES : undefined;
}
const maxLinesBasedOnHeight = Math.max(1, availableTerminalHeight - 2);
// 3. Handle ASB mode focus expansion.
// We allow a focused shell in ASB mode to take up the full available height,
// BUT only if we aren't trying to maintain a constrained view (e.g., history items).
if (isAlternateBuffer && isThisShellFocused && !constrainHeight) {
return maxLinesBasedOnHeight;
}
// 4. Fall back to process-based constants.
const isExecuting = status === CoreToolCallStatus.Executing;
const shellMaxLinesLimit = isExecuting
? ACTIVE_SHELL_MAX_LINES
: COMPLETED_SHELL_MAX_LINES;
return Math.min(maxLinesBasedOnHeight, shellMaxLinesLimit);
}
+1 -1
View File
@@ -472,7 +472,7 @@ export class ActivityLogger extends EventEmitter {
body,
pending: true,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return
return (oldEnd as any).apply(this, [chunk, ...etc]);
};
+1
View File
@@ -98,6 +98,7 @@ function resolveEnvVarsInObjectInternal<T>(
visited.add(obj);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const result = obj.map((item) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
resolveEnvVarsInObjectInternal(item, visited, customEnv),
) as unknown as T;
visited.delete(obj);
+1
View File
@@ -83,6 +83,7 @@ export const getLatestGitHubRelease = async (
if (!releaseTag) {
throw new Error(`Response did not include tag_name field`);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return releaseTag;
} catch (_error) {
debugLogger.debug(
+1
View File
@@ -40,6 +40,7 @@ export function tryParseJSON(input: string): object | null {
if (!Array.isArray(parsed) && Object.keys(parsed).length === 0) return null;
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return parsed;
} catch (_err) {
return null;
@@ -445,9 +445,76 @@ describe('SessionSelector', () => {
const sessionSelector = new SessionSelector(config);
const sessions = await sessionSelector.listSessions();
// Should list the session with gemini message
expect(sessions.length).toBe(1);
expect(sessions[0].id).toBe(sessionIdGeminiOnly);
});
it('should not list sessions marked as subagent', async () => {
const mainSessionId = randomUUID();
const subagentSessionId = randomUUID();
// Create test session files
const chatsDir = path.join(tmpDir, 'chats');
await fs.mkdir(chatsDir, { recursive: true });
// Main session - should be listed
const mainSession = {
sessionId: mainSessionId,
projectHash: 'test-hash',
startTime: '2024-01-01T10:00:00.000Z',
lastUpdated: '2024-01-01T10:30:00.000Z',
messages: [
{
type: 'user',
content: 'Hello world',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
],
kind: 'main',
};
// Subagent session - should NOT be listed
const subagentSession = {
sessionId: subagentSessionId,
projectHash: 'test-hash',
startTime: '2024-01-01T11:00:00.000Z',
lastUpdated: '2024-01-01T11:30:00.000Z',
messages: [
{
type: 'user',
content: 'Internal subagent task',
id: 'msg1',
timestamp: '2024-01-01T11:00:00.000Z',
},
],
kind: 'subagent',
};
await fs.writeFile(
path.join(
chatsDir,
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${mainSessionId.slice(0, 8)}.json`,
),
JSON.stringify(mainSession, null, 2),
);
await fs.writeFile(
path.join(
chatsDir,
`${SESSION_FILE_PREFIX}2024-01-01T11-00-${subagentSessionId.slice(0, 8)}.json`,
),
JSON.stringify(subagentSession, null, 2),
);
const sessionSelector = new SessionSelector(config);
const sessions = await sessionSelector.listSessions();
// Should only list the main session
expect(sessions.length).toBe(1);
expect(sessions[0].id).toBe(mainSessionId);
});
});
describe('extractFirstUserMessage', () => {
+7 -113
View File
@@ -16,7 +16,6 @@ import {
import * as fs from 'node:fs/promises';
import path from 'node:path';
import { stripUnsafeCharacters } from '../ui/utils/textUtils.js';
import type { Part } from '@google/genai';
import { MessageType, type HistoryItemWithoutId } from '../ui/types.js';
/**
@@ -276,6 +275,12 @@ export const getAllSessionFiles = async (
return { fileName: file, sessionInfo: null };
}
// Skip subagent sessions - these are implementation details of a tool call
// and shouldn't be surfaced for resumption in the main agent history.
if (content.kind === 'subagent') {
return { fileName: file, sessionInfo: null };
}
const firstUserMessage = extractFirstUserMessage(content.messages);
const isCurrentSession = currentSessionId
? file.includes(currentSessionId.slice(0, 8))
@@ -520,13 +525,12 @@ export class SessionSelector {
}
/**
* Converts session/conversation data into UI history and Gemini client history formats.
* Converts session/conversation data into UI history format.
*/
export function convertSessionToHistoryFormats(
messages: ConversationRecord['messages'],
): {
uiHistory: HistoryItemWithoutId[];
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>;
} {
const uiHistory: HistoryItemWithoutId[] = [];
@@ -593,117 +597,7 @@ export function convertSessionToHistoryFormats(
}
}
// Convert to Gemini client history format
const clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }> = [];
for (const msg of messages) {
// Skip system/error messages and user slash commands
if (msg.type === 'info' || msg.type === 'error' || msg.type === 'warning') {
continue;
}
if (msg.type === 'user') {
// Skip user slash commands
const contentString = partListUnionToString(msg.content);
if (
contentString.trim().startsWith('/') ||
contentString.trim().startsWith('?')
) {
continue;
}
// Add regular user message
clientHistory.push({
role: 'user',
parts: Array.isArray(msg.content)
? // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(msg.content as Part[])
: [{ text: contentString }],
});
} else if (msg.type === 'gemini') {
// Handle Gemini messages with potential tool calls
const hasToolCalls = msg.toolCalls && msg.toolCalls.length > 0;
if (hasToolCalls) {
// Create model message with function calls
const modelParts: Part[] = [];
// Add text content if present
const contentString = partListUnionToString(msg.content);
if (msg.content && contentString.trim()) {
modelParts.push({ text: contentString });
}
// Add function calls
for (const toolCall of msg.toolCalls!) {
modelParts.push({
functionCall: {
name: toolCall.name,
args: toolCall.args,
...(toolCall.id && { id: toolCall.id }),
},
});
}
clientHistory.push({
role: 'model',
parts: modelParts,
});
// Create single function response message with all tool call responses
const functionResponseParts: Part[] = [];
for (const toolCall of msg.toolCalls!) {
if (toolCall.result) {
// Convert PartListUnion result to function response format
let responseData: Part;
if (typeof toolCall.result === 'string') {
responseData = {
functionResponse: {
id: toolCall.id,
name: toolCall.name,
response: {
output: toolCall.result,
},
},
};
} else if (Array.isArray(toolCall.result)) {
// toolCall.result is an array containing properly formatted
// function responses
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
functionResponseParts.push(...(toolCall.result as Part[]));
continue;
} else {
// Fallback for non-array results
responseData = toolCall.result;
}
functionResponseParts.push(responseData);
}
}
// Only add user message if we have function responses
if (functionResponseParts.length > 0) {
clientHistory.push({
role: 'user',
parts: functionResponseParts,
});
}
} else {
// Regular Gemini message without tool calls
const contentString = partListUnionToString(msg.content);
if (msg.content && contentString.trim()) {
clientHistory.push({
role: 'model',
parts: [{ text: contentString }],
});
}
}
}
}
return {
uiHistory,
clientHistory,
};
}
+1
View File
@@ -374,6 +374,7 @@ export function setPendingSettingValue(
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const newSettings = JSON.parse(JSON.stringify(pendingSettings));
setNestedValue(newSettings, path, value);
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return newSettings;
}
@@ -26,6 +26,7 @@ import {
SessionSelector,
convertSessionToHistoryFormats,
} from '../utils/sessionUtils.js';
import { convertSessionToClientHistory } from '@google/gemini-cli-core';
import type { LoadedSettings } from '../config/settings.js';
vi.mock('../config/config.js', () => ({
@@ -42,6 +43,33 @@ vi.mock('../utils/sessionUtils.js', async (importOriginal) => {
};
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
CoreToolCallStatus: {
Validating: 'validating',
Scheduled: 'scheduled',
Error: 'error',
Success: 'success',
Executing: 'executing',
Cancelled: 'cancelled',
AwaitingApproval: 'awaiting_approval',
},
LlmRole: {
MAIN: 'main',
SUBAGENT: 'subagent',
UTILITY_TOOL: 'utility_tool',
USER: 'user',
MODEL: 'model',
SYSTEM: 'system',
TOOL: 'tool',
},
convertSessionToClientHistory: vi.fn(),
};
});
describe('GeminiAgent Session Resume', () => {
let mockConfig: Mocked<Config>;
let mockSettings: Mocked<LoadedSettings>;
@@ -142,9 +170,11 @@ describe('GeminiAgent Session Resume', () => {
{ role: 'model', parts: [{ text: 'Hi there' }] },
];
(convertSessionToHistoryFormats as unknown as Mock).mockReturnValue({
clientHistory: mockClientHistory,
uiHistory: [],
});
(convertSessionToClientHistory as unknown as Mock).mockReturnValue(
mockClientHistory,
);
const response = await agent.loadSession({
sessionId,
@@ -244,6 +274,7 @@ describe('GeminiAgent Session Resume', () => {
toolCallId: 'call-2',
status: 'failed',
title: 'Write File',
kind: 'read',
}),
}),
);
@@ -29,6 +29,7 @@ export class AcpFileSystemService implements FileSystemService {
sessionId: this.sessionId,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return response.content;
}
@@ -73,7 +73,7 @@ vi.mock(
...actual,
ReadManyFilesTool: vi.fn().mockImplementation(() => ({
name: 'read_many_files',
kind: 'native',
kind: 'read',
build: vi.fn().mockReturnValue({
getDescription: () => 'Read files',
toolLocations: () => [],
@@ -84,6 +84,28 @@ vi.mock(
})),
logToolCall: vi.fn(),
isWithinRoot: vi.fn().mockReturnValue(true),
LlmRole: {
MAIN: 'main',
SUBAGENT: 'subagent',
UTILITY_TOOL: 'utility_tool',
UTILITY_COMPRESSOR: 'utility_compressor',
UTILITY_SUMMARIZER: 'utility_summarizer',
UTILITY_ROUTER: 'utility_router',
UTILITY_LOOP_DETECTOR: 'utility_loop_detector',
UTILITY_NEXT_SPEAKER: 'utility_next_speaker',
UTILITY_EDIT_CORRECTOR: 'utility_edit_corrector',
UTILITY_AUTOCOMPLETE: 'utility_autocomplete',
UTILITY_FAST_ACK_HELPER: 'utility_fast_ack_helper',
},
CoreToolCallStatus: {
Validating: 'validating',
Scheduled: 'scheduled',
Error: 'error',
Success: 'success',
Executing: 'executing',
Cancelled: 'cancelled',
AwaitingApproval: 'awaiting_approval',
},
};
},
);
@@ -406,7 +428,7 @@ describe('Session', () => {
recordCompletedToolCalls: vi.fn(),
} as unknown as Mocked<GeminiChat>;
mockTool = {
kind: 'native',
kind: 'read',
build: vi.fn().mockReturnValue({
getDescription: () => 'Test Tool',
toolLocations: () => [],
@@ -511,6 +533,7 @@ describe('Session', () => {
update: expect.objectContaining({
sessionUpdate: 'tool_call',
status: 'in_progress',
kind: 'read',
}),
}),
);
@@ -632,6 +655,92 @@ describe('Session', () => {
);
});
it('should include _meta.kind in diff tool calls', async () => {
// Test 'add' (no original content)
const addConfirmation = {
type: 'edit',
fileName: 'new.txt',
originalContent: null,
newContent: 'New content',
onConfirm: vi.fn(),
};
// Test 'modify' (original and new content)
const modifyConfirmation = {
type: 'edit',
fileName: 'existing.txt',
originalContent: 'Old content',
newContent: 'New content',
onConfirm: vi.fn(),
};
// Test 'delete' (original content, no new content)
const deleteConfirmation = {
type: 'edit',
fileName: 'deleted.txt',
originalContent: 'Old content',
newContent: '',
onConfirm: vi.fn(),
};
const mockBuild = vi.fn();
mockTool.build = mockBuild;
// Helper to simulate tool call and check permission request
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const checkDiffKind = async (confirmation: any, expectedKind: string) => {
mockBuild.mockReturnValueOnce({
getDescription: () => 'Test Tool',
toolLocations: () => [],
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmation),
execute: vi.fn().mockResolvedValue({ llmContent: 'Result' }),
});
mockConnection.requestPermission.mockResolvedValueOnce({
outcome: {
outcome: 'selected',
optionId: ToolConfirmationOutcome.ProceedOnce,
},
});
const stream = createMockStream([
{
type: StreamEventType.CHUNK,
value: {
functionCalls: [{ name: 'test_tool', args: {} }],
},
},
]);
const emptyStream = createMockStream([]);
mockChat.sendMessageStream
.mockResolvedValueOnce(stream)
.mockResolvedValueOnce(emptyStream);
await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Call tool' }],
});
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
expect.objectContaining({
toolCall: expect.objectContaining({
content: expect.arrayContaining([
expect.objectContaining({
type: 'diff',
_meta: { kind: expectedKind },
}),
]),
}),
}),
);
};
await checkDiffKind(addConfirmation, 'add');
await checkDiffKind(modifyConfirmation, 'modify');
await checkDiffKind(deleteConfirmation, 'delete');
});
it('should handle @path resolution', async () => {
(path.resolve as unknown as Mock).mockReturnValue('/tmp/file.txt');
(fs.stat as unknown as Mock).mockResolvedValue({

Some files were not shown because too many files have changed in this diff Show More